diff --git a/README.md b/README.md index 1b71c1d..ac9cd30 100644 --- a/README.md +++ b/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` \ No newline at end of file diff --git a/app/challenge-generator/page.tsx b/app/challenge-generator/page.tsx index e91cf2a..2a0fcfa 100644 --- a/app/challenge-generator/page.tsx +++ b/app/challenge-generator/page.tsx @@ -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, diff --git a/app/components/ChallengeDataLoader.tsx b/app/components/ChallengeDataLoader.tsx deleted file mode 100644 index ea4540e..0000000 --- a/app/components/ChallengeDataLoader.tsx +++ /dev/null @@ -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({ - 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 ( -
- -

正在加载课题数据...

-
- ); - } - - // 渲染错误状态 - if (loadState.error) { - return ( -
-
- -
-

- 加载数据失败 -

-

- {loadState.error} -

- -
-
-
- ); - } - - // 渲染空状态 - if (loadState.data.length === 0) { - return ( -
- -

暂无课题数据

-

challenge-data目录中没有找到JSON文件

- -
- ); - } - - // 渲染数据统计 - return ( -
- {/* 数据统计 */} -
-
-
- -
-

月份数量

-

{loadState.data.length}

-
-
-
-
-
- -
-

总课题数

-

- {loadState.data.reduce((sum, month) => sum + month.data.totalChallenges, 0)} -

-
-
-
-
-
- -
-

最近更新

-

- {formatDate(loadState.data[0].data.generatedAt)} -

-
-
-
-
- - {/* 月份列表 */} -
-

- 数据文件 ({loadState.data.length}个) -

-
- {loadState.data.map((monthData) => ( -
-
-
-
- -
-
-

- {monthData.yearMonth} -

-

- {monthData.fileName} • {monthData.data.totalChallenges}个课题 -

-
-
-
-

- 生成时间 -

-

- {formatDate(monthData.data.generatedAt)} -

-
-
-
- ))} -
-
- - {/* 操作按钮 */} -
- -
-
- ); -} diff --git a/app/components/ChallengeForm.tsx b/app/components/ChallengeForm.tsx index 0237236..f770460 100644 --- a/app/components/ChallengeForm.tsx +++ b/app/components/ChallengeForm.tsx @@ -229,10 +229,10 @@ export default function ChallengeForm({
- {selectedSong.title} + {selectedSong.title_cn}
- {selectedSong.title_cn} + {selectedSong.title}
@@ -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" >
- {song.title} + {song.title_cn}
- {song.title_cn} + {song.title}
{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} + {`难度 ))}
@@ -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} + {`难度 ))} @@ -318,9 +328,6 @@ export default function ChallengeForm({ {stars.toFixed(1)} -

- 该难度对应的星数(constant值) -

)} diff --git a/app/components/ImageModal.tsx b/app/components/ImageModal.tsx index 5b1437d..b19963d 100644 --- a/app/components/ImageModal.tsx +++ b/app/components/ImageModal.tsx @@ -77,7 +77,12 @@ export default function ImageModal({ photo, isOpen, onClose, onInfoClick }: Imag {/* 遮罩层 */}
{ + // 移动端防止误触,只在点击遮罩层本身时关闭 + if (e.target === e.currentTarget) { + onClose(); + } + }} /> {/* 图片模态框 */} @@ -86,8 +91,8 @@ export default function ImageModal({ photo, isOpen, onClose, onInfoClick }: Imag style={modalStyle} >
- {/* 顶部工具栏 - 移动端全屏时隐藏 */} - {(!isMobile || !isFullscreen) && ( + {/* 顶部工具栏 - 移动端隐藏,桌面端显示 */} + {!isMobile && (

- {yearMonth} 课题 + {yearMonth}课题

diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index a4aafbc..70f9ecd 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -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) { ); })} - {/* 主题切换按钮 */} - +
@@ -111,17 +109,7 @@ export default function Navbar({ darkMode, onToggleDarkMode }: NavbarProps) {
- + @@ -182,27 +170,6 @@ export default function Navbar({ darkMode, onToggleDarkMode }: NavbarProps) { })} - {/* 移动端主题切换 */} -
- -
diff --git a/app/components/PhotoGallery.tsx b/app/components/PhotoGallery.tsx index 39602da..e73590f 100644 --- a/app/components/PhotoGallery.tsx +++ b/app/components/PhotoGallery.tsx @@ -53,6 +53,11 @@ export default function PhotoGallery({
onPhotoClick(photo)} + onTouchEnd={(e) => { + // 防止触摸事件触发多次点击 + e.preventDefault(); + onPhotoClick(photo); + }} > 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 = ( - <> - {/* 搜索表单 */} -
-
- - 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" - /> -
- {!sidebarCollapsed && ( - - )} -
- - {/* 排序选项 */} -
- {!sidebarCollapsed && ( - - )} - -
- - - ); - - return ( - <> - {/* 桌面端侧边栏 */} - - - ); -} diff --git a/app/hooks/useDarkMode.ts b/app/hooks/useDarkMode.ts new file mode 100644 index 0000000..250c6f8 --- /dev/null +++ b/app/hooks/useDarkMode.ts @@ -0,0 +1,133 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +export function useDarkMode() { + // 使用函数初始化state,避免服务器端渲染问题 + const [darkMode, setDarkMode] = useState(() => { + // 只在客户端执行 + 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 + }; +} diff --git a/app/page.tsx b/app/page.tsx index a07085f..b9ffe58 100644 --- a/app/page.tsx +++ b/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([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(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 ( -
+
{/* 导航栏 */} - + {/* 主内容 */}
@@ -113,7 +107,7 @@ export default function HomePage() {

- 暂无课题数据 + 暂无课题

challenge-data目录中没有找到JSON文件 @@ -136,9 +130,9 @@ export default function HomePage() {

- {monthData.yearMonth} 课题数据 + {monthData.yearMonth} - ({monthData.data.totalChallenges}个课题) + ({monthData.data.totalChallenges}首)

diff --git a/app/photos/page.tsx b/app/photos/page.tsx index 84ff163..4d4b8b7 100644 --- a/app/photos/page.tsx +++ b/app/photos/page.tsx @@ -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([]); @@ -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({ 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 ( -
+
{/* 导航栏 */} {/* 主内容区域 */}