From 0ae466531c4860b18e421354661cc0c421d9b10f Mon Sep 17 00:00:00 2001 From: AeCw Date: Fri, 19 Dec 2025 14:46:28 +0800 Subject: [PATCH] init --- README.md | 224 ++++++++++++++++++-- app/api/photos/route.ts | 129 ++++++++++++ app/api/upload/route.ts | 116 +++++++++++ app/page.tsx | 444 ++++++++++++++++++++++++++++++++++------ next.config.ts | 15 ++ package-lock.json | 107 ++++++++++ package.json | 2 + 7 files changed, 960 insertions(+), 77 deletions(-) create mode 100644 app/api/photos/route.ts create mode 100644 app/api/upload/route.ts diff --git a/README.md b/README.md index e215bc4..1b71c1d 100644 --- a/README.md +++ b/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 diff --git a/app/api/photos/route.ts b/app/api/photos/route.ts new file mode 100644 index 0000000..f6e5ddd --- /dev/null +++ b/app/api/photos/route.ts @@ -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 { + 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', + }, + }); +} diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 0000000..3abb444 --- /dev/null +++ b/app/api/upload/route.ts @@ -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', + }, + }); +} diff --git a/app/page.tsx b/app/page.tsx index 295f8fd..c73c919 100644 --- a/app/page.tsx +++ b/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([]); + 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({ + page: 1, + limit: 20, + total: 0, + totalPages: 0, + hasNextPage: false, + hasPrevPage: false, + }); + const [selectedPhoto, setSelectedPhoto] = useState(null); + + const observerRef = useRef(null); + const loadMoreRef = useRef(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) => { + 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 ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - +
+ {/* 顶部导航栏 */} +
+
+
+
+

+ 照片展示系统 +

+ +
+ +
+
+
+ + 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" + /> +
+ +
+ + +
+
+
+ +
+ {/* 加载状态 */} + {loading && ( +
+ + 加载照片中... +
+ )} + + {/* 照片网格 */} + {!loading && photos.length > 0 && ( + <> +
+

+ 共 {pagination.total} 张照片 • 第 {pagination.page}/{pagination.totalPages} 页 +

+
+ +
+ {columns.map((column, columnIndex) => ( +
+ {column.map((photo) => ( +
setSelectedPhoto(photo)} + > + {/* 图片容器 */} +
+ {photo.title} +
+
+ + {/* 图片信息 */} +
+

+ {photo.title} +

+
+
+ + {formatDate(photo.uploadedAt)} +
+
+ + {(photo.size / 1024 / 1024).toFixed(2)} MB +
+
+
+
+ ))} +
+ ))} +
+ + {/* 加载更多指示器 */} + {pagination.hasNextPage && ( +
+ {loadingMore ? ( +
+ + 加载更多照片... +
+ ) : ( +

滚动加载更多

+ )} +
+ )} + + )} + + {/* 空状态 */} + {!loading && photos.length === 0 && ( +
+
+ +
+

+ 暂无照片 +

+

+ {searchQuery ? '没有找到匹配的照片,请尝试其他搜索词。' : '还没有上传任何照片。'} +

+
+ )}
+ + {/* 照片详情模态框 */} + {selectedPhoto && ( +
+
+ + +
+ {/* 图片区域 */} +
+ {selectedPhoto.title} +
+ + {/* 信息区域 */} +
+

+ {selectedPhoto.title} +

+ +
+
+

+ 上传时间 +

+

+ {formatDate(selectedPhoto.uploadedAt)} +

+
+ +
+

+ 文件信息 +

+

+ {selectedPhoto.originalFilename || '未命名文件'} • {(selectedPhoto.size / 1024 / 1024).toFixed(2)} MB +

+
+ +
+

+ 图片链接 +

+ + {selectedPhoto.imageUrl} + +
+ +
+

+ 唯一标识 +

+

+ {selectedPhoto.id} +

+
+
+
+
+
+
+ )}
); } diff --git a/next.config.ts b/next.config.ts index e9ffa30..628d201 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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; diff --git a/package-lock.json b/package-lock.json index fc8ad32..cebcc59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 0202ba4..0ac3cd8 100644 --- a/package.json +++ b/package.json @@ -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"