update
This commit is contained in:
221
README.md
221
README.md
@@ -1,220 +1,5 @@
|
||||
# 照片展示系统
|
||||
# 漳州太鼓课题
|
||||
|
||||
基于 Next.js 和 Vercel Blob 的照片展示系统,支持瀑布流布局、无限滚动、搜索过滤和暗色/亮色主题切换。
|
||||
获取最新课题
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 📸 **瀑布流照片展示** - 响应式网格布局,自动适配不同屏幕尺寸
|
||||
- 🔍 **智能搜索** - 按标题、日期和文件名搜索照片
|
||||
- 📱 **无限滚动** - 滚动到底部自动加载更多照片
|
||||
- 🌓 **主题切换** - 支持暗色/亮色模式
|
||||
- 🖼️ **照片详情** - 点击照片查看大图和详细信息
|
||||
- ⬆️ **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
|
||||
`https://taiko.rino.ink/api/challenge-data?latest=true`
|
||||
@@ -62,8 +62,17 @@ export default function ChallengeGeneratorPage() {
|
||||
|
||||
setLoading(true);
|
||||
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,
|
||||
title: song.title,
|
||||
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 className="font-medium text-gray-900 dark:text-white">
|
||||
{selectedSong.title}
|
||||
{selectedSong.title_cn}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{selectedSong.title_cn}
|
||||
{selectedSong.title}
|
||||
</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"
|
||||
>
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
{song.title}
|
||||
{song.title_cn}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{song.title_cn}
|
||||
{song.title}
|
||||
</div>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{song.levels.map(level => (
|
||||
@@ -269,7 +269,12 @@ export default function ChallengeForm({
|
||||
key={level}
|
||||
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>
|
||||
))}
|
||||
</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'
|
||||
}`}
|
||||
>
|
||||
难度{difficulty}
|
||||
<img
|
||||
src={`/level_${difficulty}.png`}
|
||||
alt={`难度 ${difficulty}`}
|
||||
className="w-6 h-6 sm:w-5 sm:h-5"
|
||||
title={`难度 ${difficulty}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -318,9 +328,6 @@ export default function ChallengeForm({
|
||||
{stars.toFixed(1)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
该难度对应的星数(constant值)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -77,7 +77,12 @@ export default function ImageModal({ photo, isOpen, onClose, onInfoClick }: Imag
|
||||
{/* 遮罩层 */}
|
||||
<div
|
||||
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}
|
||||
>
|
||||
<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">
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
@@ -178,7 +183,11 @@ export default function ImageModal({ photo, isOpen, onClose, onInfoClick }: Imag
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 移动端点击图片切换全屏
|
||||
// 移动端双击图片切换全屏,单击不处理
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 移动端双击切换全屏
|
||||
if (isMobile) {
|
||||
toggleFullscreen();
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function MonthlyChallengeTable({
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`font-semibold ${darkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
{yearMonth} 课题
|
||||
{yearMonth}课题
|
||||
</h3>
|
||||
<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'}`}>
|
||||
|
||||
@@ -8,9 +8,18 @@ import { Menu, X, Moon, Sun, Home, Images, FileText } from 'lucide-react';
|
||||
interface NavbarProps {
|
||||
darkMode: boolean;
|
||||
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 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>
|
||||
@@ -111,17 +109,7 @@ export default function Navbar({ darkMode, onToggleDarkMode }: NavbarProps) {
|
||||
</span>
|
||||
</Link>
|
||||
</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>
|
||||
</header>
|
||||
@@ -182,27 +170,6 @@ export default function Navbar({ darkMode, onToggleDarkMode }: NavbarProps) {
|
||||
})}
|
||||
</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>
|
||||
</>
|
||||
|
||||
@@ -53,6 +53,11 @@ export default function PhotoGallery({
|
||||
<div
|
||||
className="relative aspect-square overflow-hidden cursor-pointer"
|
||||
onClick={() => onPhotoClick(photo)}
|
||||
onTouchEnd={(e) => {
|
||||
// 防止触摸事件触发多次点击
|
||||
e.preventDefault();
|
||||
onPhotoClick(photo);
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
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 { MonthlyChallengeData } from '@/app/types';
|
||||
import { Calendar, RefreshCw, AlertCircle } from 'lucide-react';
|
||||
import { useDarkMode } from '@/app/hooks/useDarkMode';
|
||||
|
||||
export default function HomePage() {
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const { darkMode, toggleDarkMode, isUsingSystem, getToggleLabel, getToggleText } = useDarkMode();
|
||||
const [monthlyData, setMonthlyData] = useState<MonthlyChallengeData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
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 () => {
|
||||
setIsLoading(true);
|
||||
@@ -63,9 +51,15 @@ export default function HomePage() {
|
||||
}, []);
|
||||
|
||||
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">
|
||||
@@ -113,7 +107,7 @@ export default function HomePage() {
|
||||
<div className="text-center py-12">
|
||||
<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>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
challenge-data目录中没有找到JSON文件
|
||||
@@ -136,9 +130,9 @@ export default function HomePage() {
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Calendar className="w-5 h-5 text-blue-500" />
|
||||
<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">
|
||||
({monthData.data.totalChallenges}个课题)
|
||||
({monthData.data.totalChallenges}首)
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import ImageModal from '@/app/components/ImageModal';
|
||||
import { Photo, Pagination } from '@/app/types';
|
||||
import { fetchPhotos } from '@/app/lib/api';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useDarkMode } from '@/app/hooks/useDarkMode';
|
||||
|
||||
export default function PhotosPage() {
|
||||
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||
@@ -15,7 +16,7 @@ export default function PhotosPage() {
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortBy, setSortBy] = useState('newest');
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const { darkMode, toggleDarkMode, isUsingSystem, getToggleLabel, getToggleText } = useDarkMode();
|
||||
const [pagination, setPagination] = useState<Pagination>({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
@@ -102,18 +103,6 @@ export default function PhotosPage() {
|
||||
fetchPhotosData(1, false);
|
||||
}, [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) => {
|
||||
@@ -147,11 +136,14 @@ export default function PhotosPage() {
|
||||
};
|
||||
|
||||
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}
|
||||
isUsingSystem={isUsingSystem}
|
||||
toggleLabel={getToggleLabel()}
|
||||
toggleText={getToggleText()}
|
||||
/>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
|
||||
Reference in New Issue
Block a user