init
This commit is contained in:
224
README.md
224
README.md
@@ -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
129
app/api/photos/route.ts
Normal 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
116
app/api/upload/route.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
444
app/page.tsx
444
app/page.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
107
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user