This commit is contained in:
2025-12-19 14:46:28 +08:00
parent 704ee1884e
commit 0ae466531c
7 changed files with 960 additions and 77 deletions

224
README.md
View File

@@ -1,36 +1,220 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# 照片展示系统
## Getting Started
基于 Next.js 和 Vercel Blob 的照片展示系统,支持瀑布流布局、无限滚动、搜索过滤和暗色/亮色主题切换。
First, run the development server:
## 功能特性
- 📸 **瀑布流照片展示** - 响应式网格布局,自动适配不同屏幕尺寸
- 🔍 **智能搜索** - 按标题、日期和文件名搜索照片
- 📱 **无限滚动** - 滚动到底部自动加载更多照片
- 🌓 **主题切换** - 支持暗色/亮色模式
- 🖼️ **照片详情** - 点击照片查看大图和详细信息
- ⬆️ **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
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
访问 http://localhost:3000 查看应用。
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
## API 文档
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
### 上传照片
## Learn More
**端点**: `POST /api/upload`
To learn more about Next.js, take a look at the following resources:
**鉴权**: 需要在请求头中提供 `X-API-Key`
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
**请求格式**: `multipart/form-data`
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
**参数**:
- `file`: 图片文件 (JPEG, PNG, GIF, WebP, 最大 10MB)
- `title`: 照片标题
## Deploy on Vercel
**示例请求**:
```bash
curl -X POST http://localhost:3000/api/upload \
-H "X-API-Key: your_api_key" \
-F "title=美丽的风景" \
-F "file=@/path/to/photo.jpg"
```
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
**成功响应**:
```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"
}
}
```
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
### 获取照片列表
**端点**: `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

129
app/api/photos/route.ts Normal file
View File

@@ -0,0 +1,129 @@
import { list } from '@vercel/blob';
import { NextRequest, NextResponse } from 'next/server';
// 照片类型定义
interface Photo {
id: string;
url: string;
title: string;
uploadedAt: string;
size: number;
contentType: string;
originalFilename?: string;
imageUrl: string;
}
// 辅助函数:从 URL 获取 JSON 数据
async function fetchJsonFromUrl(url: string): Promise<any> {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error(`Error fetching JSON from ${url}:`, error);
return null;
}
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '20');
const searchQuery = searchParams.get('search') || '';
const sortBy = searchParams.get('sort') || 'newest';
// 计算分页
const skip = (page - 1) * limit;
// 获取所有 Blob 文件
const { blobs } = await list({
prefix: 'photos/',
limit: 1000, // 获取足够多的文件以便过滤和排序
});
// 过滤出 metadata.json 文件
const metadataBlobs = blobs.filter(blob => blob.pathname.endsWith('.metadata.json'));
// 并行获取所有 metadata 数据
const metadataPromises = metadataBlobs.map(async (blob) => {
const metadata = await fetchJsonFromUrl(blob.url);
if (metadata) {
return {
...metadata,
// 确保有必要的字段
id: metadata.id || blob.pathname.replace('.metadata.json', ''),
url: metadata.imageUrl || metadata.url,
imageUrl: metadata.imageUrl || metadata.url,
} as Photo;
}
return null;
});
const metadataResults = await Promise.all(metadataPromises);
let photos: Photo[] = metadataResults.filter((photo): photo is Photo => photo !== null);
// 应用搜索过滤
if (searchQuery) {
const query = searchQuery.toLowerCase();
photos = photos.filter(photo =>
photo.title.toLowerCase().includes(query) ||
photo.uploadedAt.toLowerCase().includes(query) ||
(photo.originalFilename && photo.originalFilename.toLowerCase().includes(query))
);
}
// 应用排序
photos.sort((a, b) => {
if (sortBy === 'newest') {
return new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime();
} else if (sortBy === 'oldest') {
return new Date(a.uploadedAt).getTime() - new Date(b.uploadedAt).getTime();
} else if (sortBy === 'title') {
return a.title.localeCompare(b.title);
}
return 0;
});
// 应用分页
const total = photos.length;
const totalPages = Math.ceil(total / limit);
const paginatedPhotos = photos.slice(skip, skip + limit);
return NextResponse.json({
success: true,
data: {
photos: paginatedPhotos,
pagination: {
page,
limit,
total,
totalPages,
hasNextPage: page < totalPages,
hasPrevPage: page > 1,
},
},
});
} catch (error) {
console.error('Get photos error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// 添加 OPTIONS 方法处理 CORS 预检请求
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}

116
app/api/upload/route.ts Normal file
View File

@@ -0,0 +1,116 @@
import { put } from '@vercel/blob';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
// 验证 API 密钥
const apiKey = request.headers.get('x-api-key');
const expectedApiKey = process.env.UPLOAD_API_KEY;
if (!apiKey || apiKey !== expectedApiKey) {
return NextResponse.json(
{ error: 'Unauthorized: Invalid API key' },
{ status: 401 }
);
}
// 解析表单数据
const formData = await request.formData();
const file = formData.get('file') as File;
const title = formData.get('title') as string;
if (!file) {
return NextResponse.json(
{ error: 'No file provided' },
{ status: 400 }
);
}
if (!title || title.trim() === '') {
return NextResponse.json(
{ error: 'Title is required' },
{ status: 400 }
);
}
// 验证文件类型
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: 'Invalid file type. Only JPEG, PNG, GIF, and WebP are allowed' },
{ status: 400 }
);
}
// 验证文件大小 (最大 10MB)
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
return NextResponse.json(
{ error: 'File too large. Maximum size is 10MB' },
{ status: 400 }
);
}
// 生成唯一文件名
const timestamp = Date.now();
const randomString = Math.random().toString(36).substring(2, 15);
const extension = file.name.split('.').pop() || 'jpg';
const filename = `photos/${timestamp}-${randomString}.${extension}`;
const metadataFilename = `photos/${timestamp}-${randomString}.metadata.json`;
// 上传图片到 Vercel Blob
const blob = await put(filename, file, {
access: 'public',
contentType: file.type,
addRandomSuffix: false,
});
// 创建元数据对象
const metadata = {
id: blob.pathname,
url: blob.url,
title: title.trim(),
uploadedAt: new Date().toISOString(),
size: file.size,
contentType: file.type,
originalFilename: file.name,
imageUrl: blob.url,
};
// 创建 metadata.json 文件内容
const metadataContent = JSON.stringify(metadata, null, 2);
const metadataBlob = new Blob([metadataContent], { type: 'application/json' });
// 上传 metadata.json 到 Vercel Blob
await put(metadataFilename, metadataBlob, {
access: 'public',
contentType: 'application/json',
addRandomSuffix: false,
});
return NextResponse.json({
success: true,
message: 'Photo uploaded successfully',
photo: metadata,
}, { status: 201 });
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// 添加 OPTIONS 方法处理 CORS 预检请求
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-API-Key',
},
});
}

View File

@@ -1,65 +1,395 @@
import Image from "next/image";
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import Image from 'next/image';
import { Search, Loader2, Moon, Sun, Calendar, Image as ImageIcon } from 'lucide-react';
// 照片类型定义
interface Photo {
id: string;
url: string;
title: string;
uploadedAt: string;
size: number;
contentType: string;
originalFilename?: string;
imageUrl: string;
}
// 分页信息类型
interface Pagination {
page: number;
limit: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPrevPage: boolean;
}
export default function Home() {
const [photos, setPhotos] = useState<Photo[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState('newest');
const [darkMode, setDarkMode] = useState(false);
const [pagination, setPagination] = useState<Pagination>({
page: 1,
limit: 20,
total: 0,
totalPages: 0,
hasNextPage: false,
hasPrevPage: false,
});
const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
// 获取照片数据
const fetchPhotos = useCallback(async (page = 1, isLoadMore = false) => {
if (isLoadMore) {
setLoadingMore(true);
} else {
setLoading(true);
}
try {
const params = new URLSearchParams({
page: page.toString(),
limit: '20',
sort: sortBy,
...(searchQuery && { search: searchQuery }),
});
const response = await fetch(`/api/photos?${params}`);
const data = await response.json();
if (data.success) {
if (isLoadMore) {
setPhotos(prev => [...prev, ...data.data.photos]);
} else {
setPhotos(data.data.photos);
}
setPagination(data.data.pagination);
}
} catch (error) {
console.error('Error fetching photos:', error);
} finally {
setLoading(false);
setLoadingMore(false);
}
}, [searchQuery, sortBy]);
// 初始加载和搜索/排序变化时重新加载
useEffect(() => {
fetchPhotos(1, false);
}, [fetchPhotos]);
// 无限滚动加载
useEffect(() => {
if (!pagination.hasNextPage || loadingMore) return;
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
fetchPhotos(pagination.page + 1, true);
}
},
{ threshold: 0.5 }
);
if (loadMoreRef.current) {
observerRef.current.observe(loadMoreRef.current);
}
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [pagination, loadingMore, fetchPhotos]);
// 处理搜索
const handleSearch = useCallback((e: React.FormEvent) => {
e.preventDefault();
fetchPhotos(1, false);
}, [fetchPhotos]);
// 处理排序变化
const handleSortChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
setSortBy(e.target.value);
}, []);
// 格式化日期
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
// 切换暗色模式
const toggleDarkMode = () => {
setDarkMode(!darkMode);
if (!darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
// 计算瀑布流列数
const getColumnCount = () => {
if (typeof window === 'undefined') return 4;
if (window.innerWidth < 640) return 2;
if (window.innerWidth < 1024) return 3;
return 4;
};
// 将照片分配到瀑布流列中
const distributeToColumns = (photos: Photo[], columnCount: number): Photo[][] => {
const columns: Photo[][] = Array.from({ length: columnCount }, () => []);
photos.forEach((photo, index) => {
columns[index % columnCount].push(photo);
});
return columns;
};
const columnCount = getColumnCount();
const columns = distributeToColumns(photos, columnCount);
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
<div className={`min-h-screen transition-colors duration-200 ${darkMode ? 'dark bg-gray-900' : 'bg-gray-50'}`}>
{/* 顶部导航栏 */}
<header className="sticky top-0 z-50 border-b bg-white/80 dark:bg-gray-900/80 backdrop-blur-md dark:border-gray-700">
<div className="container mx-auto px-4 py-4">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
</h1>
<button
onClick={toggleDarkMode}
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 className="flex flex-col sm:flex-row gap-4">
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<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) => setSearchQuery(e.target.value)}
placeholder="搜索标题或日期..."
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>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
</button>
</form>
<select
value={sortBy}
onChange={handleSortChange}
className="px-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"
>
<option value="newest"></option>
<option value="oldest"></option>
<option value="title"></option>
</select>
</div>
</div>
</div>
</header>
<main className="container mx-auto px-4 py-8">
{/* 加载状态 */}
{loading && (
<div className="flex justify-center items-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
<span className="ml-3 text-gray-600 dark:text-gray-300">...</span>
</div>
)}
{/* 照片网格 */}
{!loading && photos.length > 0 && (
<>
<div className="mb-6">
<p className="text-gray-600 dark:text-gray-300">
{pagination.total} {pagination.page}/{pagination.totalPages}
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{columns.map((column, columnIndex) => (
<div key={columnIndex} className="flex flex-col gap-4">
{column.map((photo) => (
<div
key={photo.id}
className="group relative overflow-hidden rounded-xl bg-white dark:bg-gray-800 shadow-lg hover:shadow-2xl transition-all duration-300 cursor-pointer"
onClick={() => setSelectedPhoto(photo)}
>
{/* 图片容器 */}
<div className="relative aspect-square overflow-hidden">
<Image
src={photo.imageUrl}
alt={photo.title}
fill
className="object-cover transition-transform duration-500 group-hover:scale-110"
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
unoptimized // 因为图片来自外部 URL
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
</div>
{/* 图片信息 */}
<div className="p-4">
<h3 className="font-semibold text-gray-900 dark:text-white line-clamp-1 mb-2">
{photo.title}
</h3>
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center gap-1">
<Calendar className="w-4 h-4" />
<span>{formatDate(photo.uploadedAt)}</span>
</div>
<div className="flex items-center gap-1">
<ImageIcon className="w-4 h-4" />
<span>{(photo.size / 1024 / 1024).toFixed(2)} MB</span>
</div>
</div>
</div>
</div>
))}
</div>
))}
</div>
{/* 加载更多指示器 */}
{pagination.hasNextPage && (
<div ref={loadMoreRef} className="py-8 text-center">
{loadingMore ? (
<div className="flex justify-center items-center">
<Loader2 className="w-6 h-6 animate-spin text-blue-600" />
<span className="ml-3 text-gray-600 dark:text-gray-300">...</span>
</div>
) : (
<p className="text-gray-500 dark:text-gray-400"></p>
)}
</div>
)}
</>
)}
{/* 空状态 */}
{!loading && photos.length === 0 && (
<div className="text-center py-20">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 mb-4">
<ImageIcon className="w-8 h-8 text-gray-400" />
</div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
</h3>
<p className="text-gray-600 dark:text-gray-400 max-w-md mx-auto">
{searchQuery ? '没有找到匹配的照片,请尝试其他搜索词。' : '还没有上传任何照片。'}
</p>
</div>
)}
</main>
{/* 照片详情模态框 */}
{selectedPhoto && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80">
<div className="relative max-w-4xl max-h-[90vh] w-full bg-white dark:bg-gray-900 rounded-2xl overflow-hidden">
<button
onClick={() => setSelectedPhoto(null)}
className="absolute top-4 right-4 z-10 p-2 rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors"
>
</button>
<div className="grid md:grid-cols-2 gap-0">
{/* 图片区域 */}
<div className="relative aspect-square md:aspect-auto md:h-[70vh]">
<Image
src={selectedPhoto.imageUrl}
alt={selectedPhoto.title}
fill
className="object-contain"
unoptimized
/>
</div>
{/* 信息区域 */}
<div className="p-6 md:p-8 overflow-y-auto">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
{selectedPhoto.title}
</h2>
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
</h4>
<p className="text-gray-900 dark:text-white">
{formatDate(selectedPhoto.uploadedAt)}
</p>
</div>
<div>
<h4 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
</h4>
<p className="text-gray-900 dark:text-white">
{selectedPhoto.originalFilename || '未命名文件'} {(selectedPhoto.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
<div>
<h4 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
</h4>
<a
href={selectedPhoto.imageUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:underline break-all"
>
{selectedPhoto.imageUrl}
</a>
</div>
<div>
<h4 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
</h4>
<p className="text-gray-900 dark:text-white font-mono text-sm break-all">
{selectedPhoto.id}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -2,6 +2,21 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.public.blob.vercel-storage.com',
pathname: '/**',
},
// 添加其他可能的 Blob 存储域名
{
protocol: 'https',
hostname: '*.blob.vercel-storage.com',
pathname: '/**',
},
],
},
};
export default nextConfig;

107
package-lock.json generated
View File

@@ -8,6 +8,8 @@
"name": "taikozz",
"version": "0.1.0",
"dependencies": {
"@vercel/blob": "^2.0.0",
"lucide-react": "^0.562.0",
"next": "16.1.0",
"react": "19.2.3",
"react-dom": "19.2.3"
@@ -453,6 +455,15 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@fastify/busboy": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@fastify/busboy/-/busboy-2.1.1.tgz",
"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.1.tgz",
@@ -2113,6 +2124,22 @@
"win32"
]
},
"node_modules/@vercel/blob": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/@vercel/blob/-/blob-2.0.0.tgz",
"integrity": "sha512-oAj7Pdy83YKSwIaMFoM7zFeLYWRc+qUpW3PiDSblxQMnGFb43qs4bmfq7dr/+JIfwhs6PTwe1o2YBwKhyjWxXw==",
"license": "Apache-2.0",
"dependencies": {
"async-retry": "^1.3.3",
"is-buffer": "^2.0.5",
"is-node-process": "^1.2.0",
"throttleit": "^2.1.0",
"undici": "^5.28.4"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz",
@@ -2363,6 +2390,15 @@
"node": ">= 0.4"
}
},
"node_modules/async-retry": {
"version": "1.3.3",
"resolved": "https://registry.npmmirror.com/async-retry/-/async-retry-1.3.3.tgz",
"integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==",
"license": "MIT",
"dependencies": {
"retry": "0.13.1"
}
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -4013,6 +4049,29 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-buffer": {
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/is-buffer/-/is-buffer-2.0.5.tgz",
"integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/is-bun-module": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/is-bun-module/-/is-bun-module-2.0.0.tgz",
@@ -4185,6 +4244,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-node-process": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/is-node-process/-/is-node-process-1.2.0.tgz",
"integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==",
"license": "MIT"
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz",
@@ -4833,6 +4898,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.562.0",
"resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.562.0.tgz",
"integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
@@ -5491,6 +5565,15 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/retry": {
"version": "0.13.1",
"resolved": "https://registry.npmmirror.com/retry/-/retry-0.13.1.tgz",
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz",
@@ -6039,6 +6122,18 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/throttleit": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/throttleit/-/throttleit-2.1.0.tgz",
"integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -6293,6 +6388,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/undici": {
"version": "5.29.0",
"resolved": "https://registry.npmmirror.com/undici/-/undici-5.29.0.tgz",
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
"license": "MIT",
"dependencies": {
"@fastify/busboy": "^2.0.0"
},
"engines": {
"node": ">=14.0"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",

View File

@@ -9,6 +9,8 @@
"lint": "eslint"
},
"dependencies": {
"@vercel/blob": "^2.0.0",
"lucide-react": "^0.562.0",
"next": "16.1.0",
"react": "19.2.3",
"react-dom": "19.2.3"