This commit is contained in:
2025-12-23 15:28:28 +08:00
parent baddf406f0
commit 9092e7b1ea
12 changed files with 213 additions and 654 deletions

221
README.md
View File

@@ -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

View File

@@ -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,

View File

@@ -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>
);
}

View File

@@ -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>
)} )}

View File

@@ -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();
} }

View File

@@ -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'}`}>

View File

@@ -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>
</> </>

View File

@@ -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}

View File

@@ -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
View 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
};
}

View File

@@ -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>

View File

@@ -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()}
/> />
{/* 主内容区域 */} {/* 主内容区域 */}