update
This commit is contained in:
221
README.md
221
README.md
@@ -1,220 +1,5 @@
|
|||||||
# 照片展示系统
|
# 漳州太鼓课题
|
||||||
|
|
||||||
基于 Next.js 和 Vercel Blob 的照片展示系统,支持瀑布流布局、无限滚动、搜索过滤和暗色/亮色主题切换。
|
获取最新课题
|
||||||
|
|
||||||
## 功能特性
|
`https://taiko.rino.ink/api/challenge-data?latest=true`
|
||||||
|
|
||||||
- 📸 **瀑布流照片展示** - 响应式网格布局,自动适配不同屏幕尺寸
|
|
||||||
- 🔍 **智能搜索** - 按标题、日期和文件名搜索照片
|
|
||||||
- 📱 **无限滚动** - 滚动到底部自动加载更多照片
|
|
||||||
- 🌓 **主题切换** - 支持暗色/亮色模式
|
|
||||||
- 🖼️ **照片详情** - 点击照片查看大图和详细信息
|
|
||||||
- ⬆️ **API 上传** - 通过 API 上传照片(支持 Discord bot 集成)
|
|
||||||
- 📊 **分页排序** - 支持按时间、标题等多种排序方式
|
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
- **前端**: Next.js 16, React 19, TypeScript, Tailwind CSS
|
|
||||||
- **存储**: Vercel Blob (对象存储)
|
|
||||||
- **图标**: Lucide React
|
|
||||||
- **部署**: Vercel (推荐)
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
### 1. 环境配置
|
|
||||||
|
|
||||||
复制环境变量模板并配置您的 Vercel Blob 凭证:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.local.example .env.local
|
|
||||||
```
|
|
||||||
|
|
||||||
编辑 `.env.local` 文件:
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Vercel Blob Configuration
|
|
||||||
BLOB_READ_WRITE_TOKEN=your_blob_read_write_token_here
|
|
||||||
|
|
||||||
# Upload API Authentication
|
|
||||||
UPLOAD_API_KEY=your_secure_api_key_here_for_discord_bot
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 启动开发服务器
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
访问 http://localhost:3000 查看应用。
|
|
||||||
|
|
||||||
## API 文档
|
|
||||||
|
|
||||||
### 上传照片
|
|
||||||
|
|
||||||
**端点**: `POST /api/upload`
|
|
||||||
|
|
||||||
**鉴权**: 需要在请求头中提供 `X-API-Key`
|
|
||||||
|
|
||||||
**请求格式**: `multipart/form-data`
|
|
||||||
|
|
||||||
**参数**:
|
|
||||||
- `file`: 图片文件 (JPEG, PNG, GIF, WebP, 最大 10MB)
|
|
||||||
- `title`: 照片标题
|
|
||||||
|
|
||||||
**示例请求**:
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:3000/api/upload \
|
|
||||||
-H "X-API-Key: your_api_key" \
|
|
||||||
-F "title=美丽的风景" \
|
|
||||||
-F "file=@/path/to/photo.jpg"
|
|
||||||
```
|
|
||||||
|
|
||||||
**成功响应**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "Photo uploaded successfully",
|
|
||||||
"photo": {
|
|
||||||
"id": "photos/1702961234567-abc123.jpg",
|
|
||||||
"url": "https://xxx.public.blob.vercel-storage.com/photos/1702961234567-abc123.jpg",
|
|
||||||
"title": "美丽的风景",
|
|
||||||
"uploadedAt": "2025-12-19T11:47:04.123Z",
|
|
||||||
"size": 2048576,
|
|
||||||
"contentType": "image/jpeg",
|
|
||||||
"originalFilename": "photo.jpg",
|
|
||||||
"imageUrl": "https://xxx.public.blob.vercel-storage.com/photos/1702961234567-abc123.jpg"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 获取照片列表
|
|
||||||
|
|
||||||
**端点**: `GET /api/photos`
|
|
||||||
|
|
||||||
**查询参数**:
|
|
||||||
- `page`: 页码 (默认: 1)
|
|
||||||
- `limit`: 每页数量 (默认: 20)
|
|
||||||
- `search`: 搜索关键词 (标题、日期、文件名)
|
|
||||||
- `sort`: 排序方式 (`newest`, `oldest`, `title`)
|
|
||||||
|
|
||||||
**示例请求**:
|
|
||||||
```bash
|
|
||||||
curl "http://localhost:3000/api/photos?page=1&limit=20&search=风景&sort=newest"
|
|
||||||
```
|
|
||||||
|
|
||||||
**成功响应**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": {
|
|
||||||
"photos": [...],
|
|
||||||
"pagination": {
|
|
||||||
"page": 1,
|
|
||||||
"limit": 20,
|
|
||||||
"total": 150,
|
|
||||||
"totalPages": 8,
|
|
||||||
"hasNextPage": true,
|
|
||||||
"hasPrevPage": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 与 Discord Bot 集成
|
|
||||||
|
|
||||||
### 设置 Discord Bot
|
|
||||||
|
|
||||||
1. 在 Discord Developer Portal 创建新的应用和 bot
|
|
||||||
2. 获取 bot token
|
|
||||||
3. 启用必要的权限
|
|
||||||
|
|
||||||
### 发送照片到 API
|
|
||||||
|
|
||||||
在 Discord bot 代码中,使用以下示例上传照片:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 示例:使用 Discord.js 上传照片
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('title', '来自 Discord 的照片');
|
|
||||||
formData.append('file', attachmentBuffer, 'discord-photo.jpg');
|
|
||||||
|
|
||||||
const response = await fetch('https://your-domain.com/api/upload', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-API-Key': process.env.UPLOAD_API_KEY,
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
console.log('上传结果:', result);
|
|
||||||
```
|
|
||||||
|
|
||||||
## 部署到 Vercel
|
|
||||||
|
|
||||||
1. 将代码推送到 GitHub 仓库
|
|
||||||
2. 在 Vercel 中导入项目
|
|
||||||
3. 配置环境变量:
|
|
||||||
- `BLOB_READ_WRITE_TOKEN`: Vercel Blob 访问令牌
|
|
||||||
- `UPLOAD_API_KEY`: 上传 API 密钥
|
|
||||||
4. 部署!
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
app/
|
|
||||||
├── api/
|
|
||||||
│ ├── upload/
|
|
||||||
│ │ └── route.ts # 照片上传 API
|
|
||||||
│ └── photos/
|
|
||||||
│ └── route.ts # 照片获取 API
|
|
||||||
├── components/ # React 组件
|
|
||||||
├── layout.tsx # 根布局
|
|
||||||
├── page.tsx # 主页面 (照片展示)
|
|
||||||
└── globals.css # 全局样式
|
|
||||||
```
|
|
||||||
|
|
||||||
## 开发说明
|
|
||||||
|
|
||||||
### 添加新功能
|
|
||||||
|
|
||||||
1. **添加新的 API 端点**: 在 `app/api/` 下创建新的路由文件
|
|
||||||
2. **添加 UI 组件**: 在 `app/components/` 下创建 React 组件
|
|
||||||
3. **修改样式**: 使用 Tailwind CSS 类名,或编辑 `globals.css`
|
|
||||||
|
|
||||||
### 测试
|
|
||||||
|
|
||||||
1. **开发服务器**: `npm run dev`
|
|
||||||
2. **代码检查**: `npm run lint`
|
|
||||||
3. **构建测试**: `npm run build`
|
|
||||||
|
|
||||||
## 故障排除
|
|
||||||
|
|
||||||
### 常见问题
|
|
||||||
|
|
||||||
1. **图片无法加载**
|
|
||||||
- 检查 Next.js 图片配置中的远程域名
|
|
||||||
- 确认 Vercel Blob 文件是公开可访问的
|
|
||||||
|
|
||||||
2. **上传 API 返回 401 错误**
|
|
||||||
- 检查 `X-API-Key` 请求头是否正确
|
|
||||||
- 确认 `.env.local` 中的 `UPLOAD_API_KEY` 配置
|
|
||||||
|
|
||||||
3. **无限滚动不工作**
|
|
||||||
- 检查 Intersection Observer 是否正确配置
|
|
||||||
- 确认 API 返回正确的分页信息
|
|
||||||
|
|
||||||
### 环境变量
|
|
||||||
|
|
||||||
- `BLOB_READ_WRITE_TOKEN`: 从 Vercel Blob 设置中获取
|
|
||||||
- `UPLOAD_API_KEY`: 自定义的 API 密钥,用于保护上传端点
|
|
||||||
|
|
||||||
## 许可证
|
|
||||||
|
|
||||||
MIT
|
|
||||||
@@ -62,8 +62,17 @@ export default function ChallengeGeneratorPage() {
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { songs } = await fetchSongs({ title: query, limit: 50 });
|
// 同时搜索英文标题和中文标题
|
||||||
const options: SongOption[] = songs.map(song => ({
|
const [englishResults, chineseResults] = await Promise.all([
|
||||||
|
fetchSongs({ title: query, limit: 50 }),
|
||||||
|
fetchSongs({ title_cn: query, limit: 50 })
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 合并结果,去重
|
||||||
|
const allSongs = [...englishResults.songs, ...chineseResults.songs];
|
||||||
|
const uniqueSongs = Array.from(new Map(allSongs.map(song => [song.id, song])).values());
|
||||||
|
|
||||||
|
const options: SongOption[] = uniqueSongs.map(song => ({
|
||||||
id: song.id,
|
id: song.id,
|
||||||
title: song.title,
|
title: song.title,
|
||||||
title_cn: song.title_cn,
|
title_cn: song.title_cn,
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { MonthlyChallengeData, DataLoadState } from '@/app/types';
|
|
||||||
import { FileText, AlertCircle, RefreshCw, Calendar } from 'lucide-react';
|
|
||||||
|
|
||||||
interface ChallengeDataLoaderProps {
|
|
||||||
onDataLoaded?: (data: MonthlyChallengeData[]) => void;
|
|
||||||
darkMode?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ChallengeDataLoader({
|
|
||||||
onDataLoaded,
|
|
||||||
darkMode = false
|
|
||||||
}: ChallengeDataLoaderProps) {
|
|
||||||
const [loadState, setLoadState] = useState<DataLoadState>({
|
|
||||||
isLoading: true,
|
|
||||||
error: null,
|
|
||||||
data: []
|
|
||||||
});
|
|
||||||
|
|
||||||
// 加载数据
|
|
||||||
const loadChallengeData = async () => {
|
|
||||||
setLoadState(prev => ({ ...prev, isLoading: true, error: null }));
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 获取challenge-data目录下的所有JSON文件
|
|
||||||
const response = await fetch('/api/challenge-data');
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`加载数据失败: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// 转换数据格式
|
|
||||||
const monthlyData: MonthlyChallengeData[] = data.files.map((file: any) => ({
|
|
||||||
yearMonth: file.yearMonth,
|
|
||||||
fileName: file.fileName,
|
|
||||||
data: file.data
|
|
||||||
}));
|
|
||||||
|
|
||||||
setLoadState({
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
data: monthlyData
|
|
||||||
});
|
|
||||||
|
|
||||||
// 通知父组件数据已加载
|
|
||||||
if (onDataLoaded) {
|
|
||||||
onDataLoaded(monthlyData);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载课题数据失败:', error);
|
|
||||||
setLoadState({
|
|
||||||
isLoading: false,
|
|
||||||
error: error instanceof Error ? error.message : '未知错误',
|
|
||||||
data: []
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 组件挂载时加载数据
|
|
||||||
useEffect(() => {
|
|
||||||
loadChallengeData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 重新加载数据
|
|
||||||
const handleReload = () => {
|
|
||||||
loadChallengeData();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 格式化日期
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 渲染加载状态
|
|
||||||
if (loadState.isLoading) {
|
|
||||||
return (
|
|
||||||
<div className={`flex flex-col items-center justify-center py-12 ${darkMode ? 'text-gray-400' : 'text-gray-600'}`}>
|
|
||||||
<RefreshCw className="w-8 h-8 animate-spin mb-4" />
|
|
||||||
<p>正在加载课题数据...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 渲染错误状态
|
|
||||||
if (loadState.error) {
|
|
||||||
return (
|
|
||||||
<div className={`rounded-xl p-6 ${darkMode ? 'bg-red-900/20 border border-red-800' : 'bg-red-50 border border-red-200'}`}>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<AlertCircle className={`w-6 h-6 mt-0.5 ${darkMode ? 'text-red-400' : 'text-red-500'}`} />
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className={`font-semibold mb-2 ${darkMode ? 'text-red-300' : 'text-red-800'}`}>
|
|
||||||
加载数据失败
|
|
||||||
</h3>
|
|
||||||
<p className={`text-sm mb-4 ${darkMode ? 'text-red-400' : 'text-red-600'}`}>
|
|
||||||
{loadState.error}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={handleReload}
|
|
||||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 ${darkMode ? 'bg-red-800 hover:bg-red-700 text-white' : 'bg-red-100 hover:bg-red-200 text-red-800'}`}
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
重新加载
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 渲染空状态
|
|
||||||
if (loadState.data.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className={`text-center py-12 ${darkMode ? 'text-gray-400' : 'text-gray-600'}`}>
|
|
||||||
<FileText className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
|
||||||
<h3 className="text-lg font-medium mb-2">暂无课题数据</h3>
|
|
||||||
<p className="mb-6">challenge-data目录中没有找到JSON文件</p>
|
|
||||||
<button
|
|
||||||
onClick={handleReload}
|
|
||||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 mx-auto ${darkMode ? 'bg-gray-700 hover:bg-gray-600 text-white' : 'bg-gray-100 hover:bg-gray-200 text-gray-800'}`}
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
重新检查
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 渲染数据统计
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 数据统计 */}
|
|
||||||
<div className={`grid grid-cols-1 md:grid-cols-3 gap-4 ${darkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
|
||||||
<div className={`p-4 rounded-xl ${darkMode ? 'bg-gray-800' : 'bg-gray-50'}`}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Calendar className="w-5 h-5 text-blue-500" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm opacity-75">月份数量</p>
|
|
||||||
<p className="text-2xl font-bold">{loadState.data.length}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={`p-4 rounded-xl ${darkMode ? 'bg-gray-800' : 'bg-gray-50'}`}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<FileText className="w-5 h-5 text-green-500" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm opacity-75">总课题数</p>
|
|
||||||
<p className="text-2xl font-bold">
|
|
||||||
{loadState.data.reduce((sum, month) => sum + month.data.totalChallenges, 0)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={`p-4 rounded-xl ${darkMode ? 'bg-gray-800' : 'bg-gray-50'}`}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<RefreshCw className="w-5 h-5 text-purple-500" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm opacity-75">最近更新</p>
|
|
||||||
<p className="text-sm font-medium">
|
|
||||||
{formatDate(loadState.data[0].data.generatedAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 月份列表 */}
|
|
||||||
<div>
|
|
||||||
<h3 className={`text-lg font-semibold mb-4 ${darkMode ? 'text-gray-300' : 'text-gray-800'}`}>
|
|
||||||
数据文件 ({loadState.data.length}个)
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{loadState.data.map((monthData) => (
|
|
||||||
<div
|
|
||||||
key={monthData.yearMonth}
|
|
||||||
className={`p-4 rounded-xl border ${darkMode ? 'border-gray-700 bg-gray-800' : 'border-gray-200 bg-white'}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${darkMode ? 'bg-blue-900' : 'bg-blue-100'}`}>
|
|
||||||
<Calendar className={`w-5 h-5 ${darkMode ? 'text-blue-300' : 'text-blue-600'}`} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className={`font-semibold ${darkMode ? 'text-white' : 'text-gray-900'}`}>
|
|
||||||
{monthData.yearMonth}
|
|
||||||
</h4>
|
|
||||||
<p className={`text-sm ${darkMode ? 'text-gray-400' : 'text-gray-600'}`}>
|
|
||||||
{monthData.fileName} • {monthData.data.totalChallenges}个课题
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className={`text-sm ${darkMode ? 'text-gray-400' : 'text-gray-600'}`}>
|
|
||||||
生成时间
|
|
||||||
</p>
|
|
||||||
<p className={`text-sm font-medium ${darkMode ? 'text-gray-300' : 'text-gray-800'}`}>
|
|
||||||
{formatDate(monthData.data.generatedAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<button
|
|
||||||
onClick={handleReload}
|
|
||||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 ${darkMode ? 'bg-gray-700 hover:bg-gray-600 text-white' : 'bg-gray-100 hover:bg-gray-200 text-gray-800'}`}
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
刷新数据
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -229,10 +229,10 @@ export default function ChallengeForm({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-gray-900 dark:text-white">
|
<div className="font-medium text-gray-900 dark:text-white">
|
||||||
{selectedSong.title}
|
{selectedSong.title_cn}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{selectedSong.title_cn}
|
{selectedSong.title}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -258,10 +258,10 @@ export default function ChallengeForm({
|
|||||||
className="w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0"
|
className="w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0"
|
||||||
>
|
>
|
||||||
<div className="font-medium text-gray-900 dark:text-white">
|
<div className="font-medium text-gray-900 dark:text-white">
|
||||||
{song.title}
|
{song.title_cn}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{song.title_cn}
|
{song.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 mt-1">
|
<div className="flex gap-1 mt-1">
|
||||||
{song.levels.map(level => (
|
{song.levels.map(level => (
|
||||||
@@ -269,7 +269,12 @@ export default function ChallengeForm({
|
|||||||
key={level}
|
key={level}
|
||||||
className="px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
|
className="px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
难度{level}
|
<img
|
||||||
|
src={`/level_${level}.png`}
|
||||||
|
alt={`难度 ${level}`}
|
||||||
|
className="w-6 h-6 sm:w-5 sm:h-5"
|
||||||
|
title={`难度 ${level}`}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -299,7 +304,12 @@ export default function ChallengeForm({
|
|||||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
难度{difficulty}
|
<img
|
||||||
|
src={`/level_${difficulty}.png`}
|
||||||
|
alt={`难度 ${difficulty}`}
|
||||||
|
className="w-6 h-6 sm:w-5 sm:h-5"
|
||||||
|
title={`难度 ${difficulty}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -318,9 +328,6 @@ export default function ChallengeForm({
|
|||||||
{stars.toFixed(1)}
|
{stars.toFixed(1)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
该难度对应的星数(constant值)
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,12 @@ export default function ImageModal({ photo, isOpen, onClose, onInfoClick }: Imag
|
|||||||
{/* 遮罩层 */}
|
{/* 遮罩层 */}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 bg-black/90 transition-opacity duration-300"
|
className="fixed inset-0 z-50 bg-black/90 transition-opacity duration-300"
|
||||||
onClick={onClose}
|
onClick={(e) => {
|
||||||
|
// 移动端防止误触,只在点击遮罩层本身时关闭
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 图片模态框 */}
|
{/* 图片模态框 */}
|
||||||
@@ -86,8 +91,8 @@ export default function ImageModal({ photo, isOpen, onClose, onInfoClick }: Imag
|
|||||||
style={modalStyle}
|
style={modalStyle}
|
||||||
>
|
>
|
||||||
<div className={`relative ${isMobile && isFullscreen ? 'w-full h-full' : 'w-full h-full max-w-7xl max-h-[110vh]'}`}>
|
<div className={`relative ${isMobile && isFullscreen ? 'w-full h-full' : 'w-full h-full max-w-7xl max-h-[110vh]'}`}>
|
||||||
{/* 顶部工具栏 - 移动端全屏时隐藏 */}
|
{/* 顶部工具栏 - 移动端隐藏,桌面端显示 */}
|
||||||
{(!isMobile || !isFullscreen) && (
|
{!isMobile && (
|
||||||
<div className="absolute top-4 left-1/2 transform -translate-x-1/2 z-10 flex items-center space-x-2 bg-black/50 backdrop-blur-sm rounded-full px-4 py-2">
|
<div className="absolute top-4 left-1/2 transform -translate-x-1/2 z-10 flex items-center space-x-2 bg-black/50 backdrop-blur-sm rounded-full px-4 py-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleZoomOut}
|
onClick={handleZoomOut}
|
||||||
@@ -178,7 +183,11 @@ export default function ImageModal({ photo, isOpen, onClose, onInfoClick }: Imag
|
|||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// 移动端点击图片切换全屏
|
// 移动端双击图片切换全屏,单击不处理
|
||||||
|
}}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// 移动端双击切换全屏
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
toggleFullscreen();
|
toggleFullscreen();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default function MonthlyChallengeTable({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className={`font-semibold ${darkMode ? 'text-white' : 'text-gray-900'}`}>
|
<h3 className={`font-semibold ${darkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||||
{yearMonth} 课题
|
{yearMonth}课题
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-4 mt-1">
|
<div className="flex items-center gap-4 mt-1">
|
||||||
<span className={`text-sm flex items-center gap-1 ${darkMode ? 'text-gray-400' : 'text-gray-600'}`}>
|
<span className={`text-sm flex items-center gap-1 ${darkMode ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||||
|
|||||||
@@ -8,9 +8,18 @@ import { Menu, X, Moon, Sun, Home, Images, FileText } from 'lucide-react';
|
|||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
darkMode: boolean;
|
darkMode: boolean;
|
||||||
onToggleDarkMode: () => void;
|
onToggleDarkMode: () => void;
|
||||||
|
isUsingSystem?: boolean;
|
||||||
|
toggleLabel?: string;
|
||||||
|
toggleText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Navbar({ darkMode, onToggleDarkMode }: NavbarProps) {
|
export default function Navbar({
|
||||||
|
darkMode,
|
||||||
|
onToggleDarkMode,
|
||||||
|
isUsingSystem = false,
|
||||||
|
toggleLabel = darkMode ? '切换到亮色模式' : '切换到暗色模式',
|
||||||
|
toggleText = darkMode ? '亮色模式' : '暗色模式'
|
||||||
|
}: NavbarProps) {
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
@@ -72,18 +81,7 @@ export default function Navbar({ darkMode, onToggleDarkMode }: NavbarProps) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* 主题切换按钮 */}
|
|
||||||
<button
|
|
||||||
onClick={onToggleDarkMode}
|
|
||||||
className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
|
||||||
aria-label={darkMode ? '切换到亮色模式' : '切换到暗色模式'}
|
|
||||||
>
|
|
||||||
{darkMode ? (
|
|
||||||
<Sun className="w-5 h-5 text-yellow-500" />
|
|
||||||
) : (
|
|
||||||
<Moon className="w-5 h-5 text-gray-700" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,17 +109,7 @@ export default function Navbar({ darkMode, onToggleDarkMode }: NavbarProps) {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={onToggleDarkMode}
|
|
||||||
className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
|
||||||
aria-label={darkMode ? '切换到亮色模式' : '切换到暗色模式'}
|
|
||||||
>
|
|
||||||
{darkMode ? (
|
|
||||||
<Sun className="w-5 h-5 text-yellow-500" />
|
|
||||||
) : (
|
|
||||||
<Moon className="w-5 h-5 text-gray-700" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -182,27 +170,6 @@ export default function Navbar({ darkMode, onToggleDarkMode }: NavbarProps) {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* 移动端主题切换 */}
|
|
||||||
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<button
|
|
||||||
onClick={onToggleDarkMode}
|
|
||||||
className="flex items-center justify-between w-full px-4 py-3 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
{darkMode ? (
|
|
||||||
<Sun className="w-5 h-5 text-yellow-500" />
|
|
||||||
) : (
|
|
||||||
<Moon className="w-5 h-5 text-gray-700" />
|
|
||||||
)}
|
|
||||||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
{darkMode ? '亮色模式' : '暗色模式'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
点击切换
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ export default function PhotoGallery({
|
|||||||
<div
|
<div
|
||||||
className="relative aspect-square overflow-hidden cursor-pointer"
|
className="relative aspect-square overflow-hidden cursor-pointer"
|
||||||
onClick={() => onPhotoClick(photo)}
|
onClick={() => onPhotoClick(photo)}
|
||||||
|
onTouchEnd={(e) => {
|
||||||
|
// 防止触摸事件触发多次点击
|
||||||
|
e.preventDefault();
|
||||||
|
onPhotoClick(photo);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={photo.imageUrl}
|
src={photo.imageUrl}
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Search, Moon, Sun, Calendar, Image as ImageIcon, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
||||||
import { Pagination } from '@/app/types';
|
|
||||||
|
|
||||||
interface SidebarProps {
|
|
||||||
darkMode: boolean;
|
|
||||||
sidebarCollapsed: boolean;
|
|
||||||
searchQuery: string;
|
|
||||||
sortBy: string;
|
|
||||||
pagination: Pagination;
|
|
||||||
loading: boolean;
|
|
||||||
onToggleDarkMode: () => void;
|
|
||||||
onToggleSidebar: () => void;
|
|
||||||
onSearchChange: (query: string) => void;
|
|
||||||
onSortChange: (sortBy: string) => void;
|
|
||||||
onSearchSubmit: (e: React.FormEvent) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Sidebar({
|
|
||||||
darkMode,
|
|
||||||
sidebarCollapsed,
|
|
||||||
searchQuery,
|
|
||||||
sortBy,
|
|
||||||
pagination,
|
|
||||||
loading,
|
|
||||||
onToggleDarkMode,
|
|
||||||
onToggleSidebar,
|
|
||||||
onSearchChange,
|
|
||||||
onSortChange,
|
|
||||||
onSearchSubmit,
|
|
||||||
}: SidebarProps) {
|
|
||||||
const sidebarContent = (
|
|
||||||
<>
|
|
||||||
{/* 搜索表单 */}
|
|
||||||
<form onSubmit={onSearchSubmit} className="mb-6">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
|
||||||
placeholder={sidebarCollapsed ? "搜索..." : "搜索标题或日期..."}
|
|
||||||
className="w-full pl-10 pr-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{!sidebarCollapsed && (
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="w-full mt-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
搜索
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* 排序选项 */}
|
|
||||||
<div className="mb-6">
|
|
||||||
{!sidebarCollapsed && (
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
排序方式
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<select
|
|
||||||
value={sortBy}
|
|
||||||
onChange={(e) => onSortChange(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
>
|
|
||||||
<option value="newest">最新上传</option>
|
|
||||||
<option value="oldest">最早上传</option>
|
|
||||||
<option value="title">按标题排序</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* 桌面端侧边栏 */}
|
|
||||||
<aside className={`
|
|
||||||
hidden lg:flex flex-col
|
|
||||||
h-screen sticky top-0
|
|
||||||
transition-all duration-300 ease-in-out
|
|
||||||
${sidebarCollapsed ? 'w-16' : 'w-64'}
|
|
||||||
bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700
|
|
||||||
overflow-y-auto
|
|
||||||
`}>
|
|
||||||
<div className="p-4 flex-1">
|
|
||||||
{sidebarContent}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 侧边栏收缩按钮 */}
|
|
||||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<button
|
|
||||||
onClick={onToggleSidebar}
|
|
||||||
className="w-full flex items-center justify-center p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
|
||||||
aria-label={sidebarCollapsed ? '展开侧边栏' : '收缩侧边栏'}
|
|
||||||
>
|
|
||||||
{sidebarCollapsed ? (
|
|
||||||
<ChevronRight className="w-5 h-5 text-gray-700 dark:text-gray-300" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ChevronLeft className="w-5 h-5 text-gray-700 dark:text-gray-300" />
|
|
||||||
<span className="ml-2 text-sm text-gray-700 dark:text-gray-300">收缩</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
133
app/hooks/useDarkMode.ts
Normal file
133
app/hooks/useDarkMode.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useDarkMode() {
|
||||||
|
// 使用函数初始化state,避免服务器端渲染问题
|
||||||
|
const [darkMode, setDarkMode] = useState<boolean | null>(() => {
|
||||||
|
// 只在客户端执行
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
||||||
|
const savedPreference = localStorage.getItem('darkMode');
|
||||||
|
if (savedPreference !== null) {
|
||||||
|
return savedPreference === 'true';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isSystemDark, setIsSystemDark] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始化时立即设置文档类
|
||||||
|
useEffect(() => {
|
||||||
|
const initialDarkMode = darkMode === null ? isSystemDark : darkMode;
|
||||||
|
updateDocumentClass(initialDarkMode);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 检测系统暗色模式变化
|
||||||
|
useEffect(() => {
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
|
||||||
|
const handleChange = (e: MediaQueryListEvent) => {
|
||||||
|
setIsSystemDark(e.matches);
|
||||||
|
// 如果用户没有覆盖系统设置,则跟随系统变化
|
||||||
|
if (darkMode === null) {
|
||||||
|
updateDocumentClass(e.matches);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaQuery.addEventListener('change', handleChange);
|
||||||
|
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||||
|
}, [darkMode]);
|
||||||
|
|
||||||
|
// 当darkMode或isSystemDark变化时更新文档类
|
||||||
|
useEffect(() => {
|
||||||
|
if (darkMode === null) {
|
||||||
|
updateDocumentClass(isSystemDark);
|
||||||
|
} else {
|
||||||
|
updateDocumentClass(darkMode);
|
||||||
|
}
|
||||||
|
}, [darkMode, isSystemDark]);
|
||||||
|
|
||||||
|
// 更新文档类
|
||||||
|
const updateDocumentClass = (isDark: boolean) => {
|
||||||
|
if (isDark) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换暗色模式
|
||||||
|
const toggleDarkMode = () => {
|
||||||
|
setDarkMode(prev => {
|
||||||
|
let newValue: boolean | null;
|
||||||
|
|
||||||
|
if (prev === null) {
|
||||||
|
// 当前是系统模式,切换到与系统相反的模式
|
||||||
|
newValue = !isSystemDark;
|
||||||
|
} else {
|
||||||
|
// 当前是用户模式,切换回系统模式
|
||||||
|
newValue = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存到localStorage
|
||||||
|
if (newValue === null) {
|
||||||
|
localStorage.removeItem('darkMode');
|
||||||
|
updateDocumentClass(isSystemDark);
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('darkMode', newValue.toString());
|
||||||
|
updateDocumentClass(newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newValue;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取当前显示模式
|
||||||
|
const getCurrentMode = () => {
|
||||||
|
if (darkMode === null) {
|
||||||
|
return isSystemDark ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
return darkMode ? 'dark' : 'light';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取当前是否是暗色模式(用于样式)
|
||||||
|
const isDark = darkMode === null ? isSystemDark : darkMode;
|
||||||
|
|
||||||
|
// 获取当前模式描述
|
||||||
|
const getModeDescription = () => {
|
||||||
|
if (darkMode === null) {
|
||||||
|
return `系统模式 (${isSystemDark ? '暗色' : '亮色'})`;
|
||||||
|
}
|
||||||
|
return darkMode ? '暗色模式' : '亮色模式';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取切换按钮的aria-label
|
||||||
|
const getToggleLabel = () => {
|
||||||
|
if (darkMode === null) {
|
||||||
|
return isSystemDark ? '切换到亮色模式(覆盖系统设置)' : '切换到暗色模式(覆盖系统设置)';
|
||||||
|
}
|
||||||
|
return darkMode ? '切换到系统模式' : '切换到系统模式';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取切换按钮的显示文本
|
||||||
|
const getToggleText = () => {
|
||||||
|
if (darkMode === null) {
|
||||||
|
return isSystemDark ? '切换到亮色' : '切换到暗色';
|
||||||
|
}
|
||||||
|
return '使用系统设置';
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
darkMode: isDark,
|
||||||
|
isUsingSystem: darkMode === null,
|
||||||
|
toggleDarkMode,
|
||||||
|
getCurrentMode,
|
||||||
|
getModeDescription,
|
||||||
|
getToggleLabel,
|
||||||
|
getToggleText
|
||||||
|
};
|
||||||
|
}
|
||||||
32
app/page.tsx
32
app/page.tsx
@@ -5,26 +5,14 @@ import Navbar from '@/app/components/Navbar';
|
|||||||
import MonthlyChallengeTable from '@/app/components/MonthlyChallengeTable';
|
import MonthlyChallengeTable from '@/app/components/MonthlyChallengeTable';
|
||||||
import { MonthlyChallengeData } from '@/app/types';
|
import { MonthlyChallengeData } from '@/app/types';
|
||||||
import { Calendar, RefreshCw, AlertCircle } from 'lucide-react';
|
import { Calendar, RefreshCw, AlertCircle } from 'lucide-react';
|
||||||
|
import { useDarkMode } from '@/app/hooks/useDarkMode';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [darkMode, setDarkMode] = useState(false);
|
const { darkMode, toggleDarkMode, isUsingSystem, getToggleLabel, getToggleText } = useDarkMode();
|
||||||
const [monthlyData, setMonthlyData] = useState<MonthlyChallengeData[]>([]);
|
const [monthlyData, setMonthlyData] = useState<MonthlyChallengeData[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// 切换暗色模式
|
|
||||||
const toggleDarkMode = () => {
|
|
||||||
setDarkMode(prevDarkMode => {
|
|
||||||
const newDarkMode = !prevDarkMode;
|
|
||||||
if (newDarkMode) {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
return newDarkMode;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 加载数据
|
// 加载数据
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -63,9 +51,15 @@ export default function HomePage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen transition-colors duration-200 ${darkMode ? 'dark' : ''}`}>
|
<div className="min-h-screen transition-colors duration-200">
|
||||||
{/* 导航栏 */}
|
{/* 导航栏 */}
|
||||||
<Navbar darkMode={darkMode} onToggleDarkMode={toggleDarkMode} />
|
<Navbar
|
||||||
|
darkMode={darkMode}
|
||||||
|
onToggleDarkMode={toggleDarkMode}
|
||||||
|
isUsingSystem={isUsingSystem}
|
||||||
|
toggleLabel={getToggleLabel()}
|
||||||
|
toggleText={getToggleText()}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 主内容 */}
|
{/* 主内容 */}
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
@@ -113,7 +107,7 @@ export default function HomePage() {
|
|||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<Calendar className="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
<Calendar className="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||||
暂无课题数据
|
暂无课题
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
challenge-data目录中没有找到JSON文件
|
challenge-data目录中没有找到JSON文件
|
||||||
@@ -136,9 +130,9 @@ export default function HomePage() {
|
|||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<Calendar className="w-5 h-5 text-blue-500" />
|
<Calendar className="w-5 h-5 text-blue-500" />
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
{monthData.yearMonth} 课题数据
|
{monthData.yearMonth}
|
||||||
<span className="ml-2 text-sm font-normal text-gray-500 dark:text-gray-400">
|
<span className="ml-2 text-sm font-normal text-gray-500 dark:text-gray-400">
|
||||||
({monthData.data.totalChallenges}个课题)
|
({monthData.data.totalChallenges}首)
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import ImageModal from '@/app/components/ImageModal';
|
|||||||
import { Photo, Pagination } from '@/app/types';
|
import { Photo, Pagination } from '@/app/types';
|
||||||
import { fetchPhotos } from '@/app/lib/api';
|
import { fetchPhotos } from '@/app/lib/api';
|
||||||
import { Search } from 'lucide-react';
|
import { Search } from 'lucide-react';
|
||||||
|
import { useDarkMode } from '@/app/hooks/useDarkMode';
|
||||||
|
|
||||||
export default function PhotosPage() {
|
export default function PhotosPage() {
|
||||||
const [photos, setPhotos] = useState<Photo[]>([]);
|
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||||
@@ -15,7 +16,7 @@ export default function PhotosPage() {
|
|||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [sortBy, setSortBy] = useState('newest');
|
const [sortBy, setSortBy] = useState('newest');
|
||||||
const [darkMode, setDarkMode] = useState(false);
|
const { darkMode, toggleDarkMode, isUsingSystem, getToggleLabel, getToggleText } = useDarkMode();
|
||||||
const [pagination, setPagination] = useState<Pagination>({
|
const [pagination, setPagination] = useState<Pagination>({
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
@@ -102,18 +103,6 @@ export default function PhotosPage() {
|
|||||||
fetchPhotosData(1, false);
|
fetchPhotosData(1, false);
|
||||||
}, [fetchPhotosData]);
|
}, [fetchPhotosData]);
|
||||||
|
|
||||||
// 切换暗色模式
|
|
||||||
const toggleDarkMode = () => {
|
|
||||||
setDarkMode(prevDarkMode => {
|
|
||||||
const newDarkMode = !prevDarkMode;
|
|
||||||
if (newDarkMode) {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
return newDarkMode;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 打开图片模态框(点击卡片时)
|
// 打开图片模态框(点击卡片时)
|
||||||
const openImageModal = (photo: Photo) => {
|
const openImageModal = (photo: Photo) => {
|
||||||
@@ -147,11 +136,14 @@ export default function PhotosPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen transition-colors duration-200 ${darkMode ? 'dark' : ''}`}>
|
<div className="min-h-screen transition-colors duration-200">
|
||||||
{/* 导航栏 */}
|
{/* 导航栏 */}
|
||||||
<Navbar
|
<Navbar
|
||||||
darkMode={darkMode}
|
darkMode={darkMode}
|
||||||
onToggleDarkMode={toggleDarkMode}
|
onToggleDarkMode={toggleDarkMode}
|
||||||
|
isUsingSystem={isUsingSystem}
|
||||||
|
toggleLabel={getToggleLabel()}
|
||||||
|
toggleText={getToggleText()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 主内容区域 */}
|
{/* 主内容区域 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user