├── postcss.config.js ├── public └── static │ ├── favicon.ico │ ├── favicon-16.png │ ├── favicon-32.png │ ├── favicon-48.png │ └── favicon.svg ├── .gitignore ├── app ├── favicon.ico │ └── route.ts ├── api │ └── config │ │ └── route.ts ├── types │ ├── image.ts │ ├── upload.ts │ └── index.ts ├── hooks │ ├── useConfig.ts │ ├── useApiKey.ts │ ├── useTheme.ts │ └── useUploadState.ts ├── components │ ├── LoadingSpinner.tsx │ ├── StatusMessage.tsx │ ├── ImageDetail │ │ ├── ImagePreview.tsx │ │ ├── ImageInfo.tsx │ │ ├── DeleteButton.tsx │ │ └── ImageUrls.tsx │ ├── DeleteConfirm.tsx │ ├── upload │ │ ├── UploadModeToggle.tsx │ │ ├── ZipUploadDropzone.tsx │ │ ├── ImagePreviewGrid.tsx │ │ ├── ZipPreview.tsx │ │ ├── UploadStatusIndicator.tsx │ │ ├── ImageSidebar.tsx │ │ ├── UploadDropzone.tsx │ │ ├── TagSelector.tsx │ │ └── ZipUploadProgress.tsx │ ├── ToastContainer.tsx │ ├── Toast.tsx │ ├── RandomApiModal │ │ ├── OrientationSelector.tsx │ │ ├── FormatSelector.tsx │ │ ├── LinkOutput.tsx │ │ └── TagSelector.tsx │ ├── TagManagementModal.tsx │ ├── TagManagement │ │ ├── TagCreateForm.tsx │ │ ├── TagItem.tsx │ │ ├── TagList.tsx │ │ ├── TagDeleteConfirm.tsx │ │ └── TagEditModal.tsx │ ├── ImageInfo.tsx │ ├── ExpirySelector.tsx │ ├── ContextMenu.tsx │ ├── ImagePreview.tsx │ ├── Header.tsx │ └── ui │ │ └── icons.tsx ├── lib │ └── queryKeys.ts ├── utils │ ├── baseUrl.ts │ ├── clipboard.ts │ ├── auth.ts │ ├── cdnImage.ts │ ├── imageUtils.ts │ ├── copyImageUtils.ts │ ├── request.ts │ ├── imageQueue.ts │ ├── zipProcessor.ts │ └── concurrentUpload.ts ├── providers │ └── QueryProvider.tsx └── layout.tsx ├── worker ├── migrations │ ├── 0002_performance_indexes.sql │ └── 0001_init.sql ├── tsconfig.json ├── src │ ├── types │ │ └── queue.ts │ ├── services │ │ ├── storage.ts │ │ ├── auth.ts │ │ └── cache.ts │ ├── handlers │ │ ├── queue.ts │ │ ├── favicon.ts │ │ ├── random.ts │ │ └── system.ts │ ├── utils │ │ ├── response.ts │ │ ├── validation.ts │ │ └── imageTransform.ts │ └── types.ts ├── package.json ├── schema.sql └── wrangler.example.toml ├── next-env.d.ts ├── .env.example ├── magic.mgc ├── eslint.config.mjs ├── tsconfig.json ├── package.json ├── .github └── workflows │ └── deploy-worker.yml ├── CHANGELOG_CN.md ├── next.config.mjs ├── CHANGELOG.md └── CLAUDE.md /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | }, 5 | } -------------------------------------------------------------------------------- /public/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yuri-NagaSaki/CattoPic/HEAD/public/static/favicon.ico -------------------------------------------------------------------------------- /public/static/favicon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yuri-NagaSaki/CattoPic/HEAD/public/static/favicon-16.png -------------------------------------------------------------------------------- /public/static/favicon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yuri-NagaSaki/CattoPic/HEAD/public/static/favicon-32.png -------------------------------------------------------------------------------- /public/static/favicon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yuri-NagaSaki/CattoPic/HEAD/public/static/favicon-48.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .next/ 2 | out/ 3 | node_modules/ 4 | .env.local 5 | .wrangler/ 6 | 7 | # Worker configuration (contains sensitive IDs) 8 | worker/wrangler.toml -------------------------------------------------------------------------------- /app/favicon.ico/route.ts: -------------------------------------------------------------------------------- 1 | export function GET(request: Request): Response { 2 | return Response.redirect(new URL('/static/favicon.ico', request.url), 302); 3 | } 4 | 5 | -------------------------------------------------------------------------------- /worker/migrations/0002_performance_indexes.sql: -------------------------------------------------------------------------------- 1 | -- Performance optimization indexes 2 | -- Add composite index for orientation + upload_time (most common query pattern) 3 | CREATE INDEX IF NOT EXISTS idx_images_orientation_upload_time 4 | ON images(orientation, upload_time DESC); 5 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | import "./.next/dev/types/routes.d.ts"; 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 7 | -------------------------------------------------------------------------------- /app/api/config/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | // Required for static export 4 | export const dynamic = "force-static"; 5 | 6 | export async function GET() { 7 | return NextResponse.json({ 8 | apiUrl: process.env.API_URL || "", 9 | remotePatterns: process.env.NEXT_PUBLIC_REMOTE_PATTERNS || "", 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Development environment 2 | NEXT_PUBLIC_API_URL=http://localhost:8686 3 | NEXT_PUBLIC_REMOTE_PATTERNS=https://r2.catcat.li 4 | 5 | # Production environment for Netlify 6 | # Set these in Netlify dashboard: Site settings > Environment variables 7 | # NEXT_PUBLIC_API_URL=https://your-backend-url.com 8 | # NEXT_PUBLIC_REMOTE_PATTERNS=your-backend-url.com 9 | -------------------------------------------------------------------------------- /magic.mgc: -------------------------------------------------------------------------------- 1 | � -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import nextCoreWebVitals from "eslint-config-next/core-web-vitals"; 2 | import nextTypescript from "eslint-config-next/typescript"; 3 | 4 | const eslintConfig = [ 5 | ...nextCoreWebVitals, 6 | ...nextTypescript, 7 | { 8 | ignores: [ 9 | "node_modules/**", 10 | ".next/**", 11 | "out/**", 12 | "build/**", 13 | "next-env.d.ts", 14 | ], 15 | }, 16 | ]; 17 | 18 | export default eslintConfig; 19 | -------------------------------------------------------------------------------- /app/types/image.ts: -------------------------------------------------------------------------------- 1 | export interface ImageData { 2 | id: string 3 | status: 'success' | 'error' 4 | originalName?: string 5 | format?: string 6 | orientation?: 'landscape' | 'portrait' 7 | expiryTime?: string 8 | tags?: string[] 9 | urls?: { 10 | original: string 11 | webp: string 12 | avif: string 13 | } 14 | sizes?: { 15 | original: number 16 | webp: number 17 | avif: number 18 | } 19 | error?: string 20 | } 21 | 22 | export interface CopyStatus { 23 | type: string 24 | } -------------------------------------------------------------------------------- /worker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ES2022", 5 | "moduleResolution": "bundler", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "noEmit": true, 13 | "lib": ["ES2022"], 14 | "types": ["@cloudflare/workers-types"] 15 | }, 16 | "include": ["src/**/*"], 17 | "exclude": ["node_modules"] 18 | } 19 | -------------------------------------------------------------------------------- /worker/src/types/queue.ts: -------------------------------------------------------------------------------- 1 | // Queue 消息类型定义 2 | 3 | export interface ImagePaths { 4 | original: string; 5 | webp?: string; 6 | avif?: string; 7 | } 8 | 9 | export interface DeleteImageMessage { 10 | type: 'delete_image'; 11 | imageId: string; 12 | paths: ImagePaths; 13 | } 14 | 15 | export interface DeleteTagImagesMessage { 16 | type: 'delete_tag_images'; 17 | tagName: string; 18 | imagePaths: Array<{ 19 | id: string; 20 | paths: ImagePaths; 21 | }>; 22 | } 23 | 24 | export type QueueMessage = DeleteImageMessage | DeleteTagImagesMessage; 25 | -------------------------------------------------------------------------------- /worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cattopic-worker", 3 | "version": "1.0.0", 4 | "description": "CattoPic Cloudflare Worker API", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "wrangler dev", 8 | "build": "wrangler deploy", 9 | "deploy": "wrangler deploy", 10 | "tail": "wrangler tail" 11 | }, 12 | "keywords": ["cloudflare", "worker", "cattopic"], 13 | "author": "", 14 | "license": "ISC", 15 | "packageManager": "pnpm@10.24.0", 16 | "dependencies": { 17 | "hono": "^4.10.7" 18 | }, 19 | "devDependencies": { 20 | "@cloudflare/workers-types": "^4.20251205.0", 21 | "typescript": "^5.9.3", 22 | "wrangler": "^4.53.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/hooks/useConfig.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | interface Config { 4 | apiUrl: string; 5 | remotePatterns: string; 6 | } 7 | 8 | export function useConfig() { 9 | const [config, setConfig] = useState({ 10 | apiUrl: "", 11 | remotePatterns: "", 12 | }); 13 | const [loading, setLoading] = useState(true); 14 | 15 | useEffect(() => { 16 | fetch("/api/config") 17 | .then((res) => res.json()) 18 | .then((data) => { 19 | setConfig(data); 20 | setLoading(false); 21 | }) 22 | .catch((err) => { 23 | console.error("Failed to load config:", err); 24 | setLoading(false); 25 | }); 26 | }, []); 27 | 28 | return { config, loading }; 29 | } 30 | -------------------------------------------------------------------------------- /public/static/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 12 | -------------------------------------------------------------------------------- /app/hooks/useApiKey.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useSyncExternalStore } from 'react'; 4 | import { API_KEY_CHANGE_EVENT, getApiKey } from '../utils/auth'; 5 | 6 | export function useApiKey(): string | null | undefined { 7 | return useSyncExternalStore( 8 | (onStoreChange) => { 9 | if (typeof window === 'undefined') return () => {}; 10 | 11 | const handler = () => onStoreChange(); 12 | 13 | window.addEventListener('storage', handler); 14 | window.addEventListener(API_KEY_CHANGE_EVENT, handler); 15 | 16 | return () => { 17 | window.removeEventListener('storage', handler); 18 | window.removeEventListener(API_KEY_CHANGE_EVENT, handler); 19 | }; 20 | }, 21 | () => getApiKey(), 22 | () => undefined 23 | ); 24 | } 25 | 26 | -------------------------------------------------------------------------------- /app/components/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface LoadingSpinnerProps { 4 | className?: string; 5 | } 6 | 7 | export const LoadingSpinner = ({ className = '' }: LoadingSpinnerProps) => { 8 | return ( 9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | ); 17 | }; -------------------------------------------------------------------------------- /app/lib/queryKeys.ts: -------------------------------------------------------------------------------- 1 | export const queryKeys = { 2 | // Image related keys 3 | images: { 4 | all: ['images'] as const, 5 | lists: () => [...queryKeys.images.all, 'list'] as const, 6 | list: (filters: { page?: number; limit?: number; tag?: string; orientation?: string; format?: string }) => 7 | [...queryKeys.images.lists(), filters] as const, 8 | recentUploads: () => [...queryKeys.images.all, 'recentUploads'] as const, 9 | details: () => [...queryKeys.images.all, 'detail'] as const, 10 | detail: (id: string) => [...queryKeys.images.details(), id] as const, 11 | }, 12 | 13 | // Tag related keys 14 | tags: { 15 | all: ['tags'] as const, 16 | list: () => [...queryKeys.tags.all, 'list'] as const, 17 | }, 18 | 19 | // Config 20 | config: { 21 | all: ['config'] as const, 22 | }, 23 | } as const; 24 | -------------------------------------------------------------------------------- /app/components/StatusMessage.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { motion } from 'motion/react' 4 | 5 | interface StatusMessageProps { 6 | type: 'success' | 'error' | 'warning' 7 | message: string 8 | } 9 | 10 | export default function StatusMessage({ type, message }: StatusMessageProps) { 11 | return ( 12 | 24 | {message} 25 | 26 | ) 27 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "noEmit": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "bundler", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "react-jsx", 19 | "incremental": true, 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ], 25 | "paths": { 26 | "@/*": [ 27 | "./*" 28 | ] 29 | } 30 | }, 31 | "include": [ 32 | "next-env.d.ts", 33 | "**/*.ts", 34 | "**/*.tsx", 35 | ".next/types/**/*.ts", 36 | ".next/dev/types/**/*.ts" 37 | ], 38 | "exclude": [ 39 | "node_modules", 40 | "worker" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /app/utils/baseUrl.ts: -------------------------------------------------------------------------------- 1 | // 从环境变量获取后端地址,默认为相对路径 2 | export const BASE_URL = process.env.NEXT_PUBLIC_API_URL || ""; 3 | 4 | /** 5 | * 为URL添加基础地址 6 | * @param url 相对路径或完整URL 7 | * @returns 完整URL 8 | */ 9 | export function getFullUrl(url: string): string { 10 | if (!url) return ""; 11 | if (url.startsWith("http://") || url.startsWith("https://")) { 12 | // console.log("完整URL", url); 13 | return url; 14 | } 15 | 16 | // 如果是相对路径,添加BASE_URL 17 | try { 18 | // 在浏览器环境下 19 | if (typeof window !== "undefined") { 20 | const nurl = new URL(url, BASE_URL || window.location.origin).toString(); 21 | return nurl; 22 | } 23 | // 在服务器环境下 24 | // console.log(`${BASE_URL}${url.startsWith("/") ? url : `/${url}`}`); 25 | return `${BASE_URL}${url.startsWith("/") ? url : `/${url}`}`; 26 | } catch (error) { 27 | console.error("URL格式错误:", error); 28 | return url; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/hooks/useTheme.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | 3 | // 获取初始主题状态 4 | function getInitialTheme(): boolean { 5 | if (typeof window === 'undefined') return true 6 | try { 7 | const savedTheme = localStorage.getItem('theme') 8 | if (savedTheme) { 9 | return savedTheme === 'dark' 10 | } 11 | return document.documentElement.classList.contains('dark') 12 | } catch { 13 | return document.documentElement.classList.contains('dark') 14 | } 15 | } 16 | 17 | export function useTheme() { 18 | const [isDarkMode, setIsDarkMode] = useState(getInitialTheme) 19 | 20 | // 同步 DOM 状态 21 | useEffect(() => { 22 | document.documentElement.classList.toggle('dark', isDarkMode) 23 | }, [isDarkMode]) 24 | 25 | const toggleTheme = () => { 26 | const newTheme = !isDarkMode 27 | setIsDarkMode(newTheme) 28 | localStorage.setItem('theme', newTheme ? 'dark' : 'light') 29 | document.documentElement.classList.toggle('dark', newTheme) 30 | } 31 | 32 | return { isDarkMode, toggleTheme } 33 | } 34 | -------------------------------------------------------------------------------- /app/types/upload.ts: -------------------------------------------------------------------------------- 1 | import { UploadResult } from './index' 2 | 3 | // 单个文件的上传状态 4 | export type FileUploadStatus = 'pending' | 'uploading' | 'processing' | 'success' | 'error' 5 | 6 | // 整体上传阶段 7 | export type UploadPhase = 'idle' | 'uploading' | 'processing' | 'completed' 8 | 9 | // 上传文件项 10 | export interface UploadFileItem { 11 | id: string 12 | file: File 13 | status: FileUploadStatus 14 | error?: string 15 | result?: UploadResult 16 | } 17 | 18 | // 上传状态 19 | export interface UploadState { 20 | phase: UploadPhase 21 | files: UploadFileItem[] 22 | completedCount: number 23 | errorCount: number 24 | abortController: AbortController | null 25 | } 26 | 27 | // 上传状态操作 28 | export interface UploadStateActions { 29 | initializeUpload: (files: { id: string; file: File }[]) => AbortController 30 | setPhase: (phase: UploadPhase) => void 31 | setAllFilesStatus: (status: FileUploadStatus) => void 32 | setResults: (results: UploadResult[]) => void 33 | updateFileStatus: (fileId: string, status: FileUploadStatus, result?: UploadResult) => void 34 | cancelUpload: () => void 35 | reset: () => void 36 | } 37 | -------------------------------------------------------------------------------- /app/providers/QueryProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 4 | import { useState } from 'react'; 5 | 6 | export function QueryProvider({ children }: { children: React.ReactNode }) { 7 | const [queryClient] = useState( 8 | () => 9 | new QueryClient({ 10 | defaultOptions: { 11 | queries: { 12 | // Data is considered fresh for 5 minutes 13 | staleTime: 5 * 60 * 1000, 14 | // Cache is garbage collected after 30 minutes 15 | gcTime: 30 * 60 * 1000, 16 | // Refetch on window focus for stale data 17 | refetchOnWindowFocus: true, 18 | // Refetch on reconnect 19 | refetchOnReconnect: true, 20 | // Retry failed requests 3 times 21 | retry: 3, 22 | retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), 23 | }, 24 | }, 25 | }) 26 | ); 27 | 28 | return ( 29 | 30 | {children} 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /app/components/ImageDetail/ImagePreview.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Image from 'next/image' 4 | import { motion } from 'motion/react' 5 | import { ImageData } from '../../types/image' 6 | import { getFullUrl } from '../../utils/baseUrl' 7 | 8 | interface ImagePreviewProps { 9 | image: ImageData 10 | } 11 | 12 | export function ImagePreview({ image }: ImagePreviewProps) { 13 | const originalUrl = getFullUrl(image.urls?.webp || image.urls?.original || '') 14 | 15 | return ( 16 |
17 | 24 | {image.originalName 31 | 32 |
33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cattopic-frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "eslint ." 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-icons": "^1.3.2", 13 | "@tanstack/react-query": "^5.90.12", 14 | "@tanstack/react-virtual": "^3.13.13", 15 | "jszip": "^3.10.1", 16 | "lucide-react": "^0.487.0", 17 | "motion": "^12.23.25", 18 | "next": "16.0.8", 19 | "react": "19.2.1", 20 | "react-dom": "19.2.1", 21 | "react-intersection-observer": "^9.16.0", 22 | "react-masonry-css": "^1.0.16", 23 | "sharp": "^0.34.5" 24 | }, 25 | "devDependencies": { 26 | "@tailwindcss/postcss": "^4.1.17", 27 | "@types/node": "^20.19.26", 28 | "@types/react": "19.2.7", 29 | "@types/react-dom": "19.2.3", 30 | "dotenv": "^16.4.7", 31 | "eslint": "^9.39.1", 32 | "eslint-config-next": "^16.0.8", 33 | "postcss": "^8.4.35", 34 | "tailwindcss": "^4.1.17", 35 | "typescript": "^5.9.3" 36 | }, 37 | "overrides": { 38 | "@types/react": "19.2.7", 39 | "@types/react-dom": "19.2.3" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/utils/clipboard.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 跨浏览器复制文本到剪贴板 3 | * @param text 要复制的文本 4 | * @returns Promise 是否复制成功 5 | */ 6 | export async function copyToClipboard(text: string): Promise { 7 | // 方法 1: 使用 Clipboard API (现代浏览器) 8 | if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { 9 | try { 10 | await navigator.clipboard.writeText(text); 11 | return true; 12 | } catch (err) { 13 | console.error('Clipboard API 失败:', err); 14 | // 如果失败,尝试其他方法 15 | } 16 | } 17 | 18 | // 方法 2: 使用 document.execCommand (兼容旧浏览器) 19 | try { 20 | const textarea = document.createElement('textarea'); 21 | textarea.value = text; 22 | 23 | // 确保不可见但处于文档中 24 | textarea.style.position = 'fixed'; 25 | textarea.style.opacity = '0'; 26 | textarea.style.pointerEvents = 'none'; 27 | textarea.style.left = '-999px'; 28 | textarea.style.top = '0'; 29 | 30 | document.body.appendChild(textarea); 31 | textarea.focus(); 32 | textarea.select(); 33 | 34 | const success = document.execCommand('copy'); 35 | document.body.removeChild(textarea); 36 | 37 | if (success) { 38 | return true; 39 | } else { 40 | console.error('execCommand 复制失败'); 41 | } 42 | } catch (err) { 43 | console.error('文档复制方法失败:', err); 44 | } 45 | 46 | return false; 47 | } 48 | -------------------------------------------------------------------------------- /app/components/DeleteConfirm.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner } from './ui/icons'; 2 | 3 | interface DeleteConfirmProps { 4 | isDeleting: boolean; 5 | onCancel: () => void; 6 | onConfirm: () => void; 7 | } 8 | 9 | export const DeleteConfirm = ({ isDeleting, onCancel, onConfirm }: DeleteConfirmProps) => { 10 | return ( 11 |
12 |

13 | 确定要删除此图片吗?(将同时删除所有相关格式) 14 |

15 |
16 | 23 | 37 |
38 |
39 | ); 40 | }; -------------------------------------------------------------------------------- /.github/workflows/deploy-worker.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Worker 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'worker/**' 9 | - '.github/workflows/deploy-worker.yml' 10 | workflow_dispatch: 11 | 12 | jobs: 13 | deploy: 14 | runs-on: ubuntu-latest 15 | name: Deploy to Cloudflare Workers 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | 21 | - name: Setup pnpm 22 | uses: pnpm/action-setup@v4 23 | with: 24 | version: 9 25 | 26 | - name: Setup Node.js 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: '20' 30 | cache: 'pnpm' 31 | cache-dependency-path: worker/pnpm-lock.yaml 32 | 33 | - name: Install dependencies 34 | run: pnpm install 35 | working-directory: worker 36 | 37 | - name: Generate wrangler.toml 38 | working-directory: worker 39 | env: 40 | WRANGLER_TOML_CONTENT: ${{ secrets.WRANGLER_TOML }} 41 | run: printf '%s\n' "$WRANGLER_TOML_CONTENT" > wrangler.toml 42 | 43 | - name: Deploy to Cloudflare 44 | working-directory: worker 45 | env: 46 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 47 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 48 | run: | 49 | pnpm wrangler deploy > /dev/null 2>&1 50 | echo "✅ Deploy completed" 51 | -------------------------------------------------------------------------------- /app/components/ImageDetail/ImageInfo.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ImageData } from '../../types/image' 4 | 5 | interface ImageInfoProps { 6 | image: ImageData 7 | } 8 | 9 | export function ImageInfo({ image }: ImageInfoProps) { 10 | return ( 11 |
12 |
13 | {image.format && ( 14 |
15 | 原始格式: 16 | {image.format.toUpperCase()} 17 |
18 | )} 19 | 20 | {image.orientation && ( 21 |
22 | 图片方向: 23 | {image.orientation} 24 |
25 | )} 26 | 27 | {image.expiryTime && ( 28 |
29 | 过期时间: 30 | 31 | {new Date(image.expiryTime).toLocaleString()} 32 | 33 |
34 | )} 35 |
36 |
37 | ) 38 | } -------------------------------------------------------------------------------- /worker/src/services/storage.ts: -------------------------------------------------------------------------------- 1 | // R2 Storage Service 2 | export class StorageService { 3 | constructor(private bucket: R2Bucket) {} 4 | 5 | async upload(key: string, data: ArrayBuffer | Uint8Array, contentType: string): Promise { 6 | await this.bucket.put(key, data, { 7 | httpMetadata: { contentType } 8 | }); 9 | } 10 | 11 | async get(key: string): Promise { 12 | return this.bucket.get(key); 13 | } 14 | 15 | async delete(key: string): Promise { 16 | await this.bucket.delete(key); 17 | } 18 | 19 | async deleteMany(keys: string[]): Promise { 20 | if (keys.length === 0) return; 21 | await this.bucket.delete(keys); 22 | } 23 | 24 | async list(prefix: string, limit?: number): Promise { 25 | return this.bucket.list({ prefix, limit }); 26 | } 27 | 28 | async exists(key: string): Promise { 29 | const head = await this.bucket.head(key); 30 | return head !== null; 31 | } 32 | 33 | // Generate storage paths for an image 34 | static generatePaths(id: string, orientation: 'landscape' | 'portrait', format: string): { 35 | original: string; 36 | webp: string; 37 | avif: string; 38 | } { 39 | const ext = format === 'gif' ? 'gif' : format; 40 | return { 41 | original: `original/${orientation}/${id}.${ext}`, 42 | webp: `${orientation}/webp/${id}.webp`, 43 | avif: `${orientation}/avif/${id}.avif` 44 | }; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /worker/src/services/auth.ts: -------------------------------------------------------------------------------- 1 | // D1 Authentication Service 2 | export class AuthService { 3 | constructor(private db: D1Database) {} 4 | 5 | async validateApiKey(key: string): Promise { 6 | if (!key) return false; 7 | 8 | // Single query: UPDATE with RETURNING to combine SELECT + UPDATE 9 | const result = await this.db.prepare(` 10 | UPDATE api_keys SET last_used_at = ? WHERE key = ? 11 | RETURNING id 12 | `).bind(new Date().toISOString(), key).first<{ id: number }>(); 13 | 14 | return result !== null; 15 | } 16 | 17 | async addApiKey(key: string): Promise { 18 | await this.db.prepare(` 19 | INSERT OR IGNORE INTO api_keys (key, created_at) VALUES (?, ?) 20 | `).bind(key, new Date().toISOString()).run(); 21 | } 22 | 23 | async removeApiKey(key: string): Promise { 24 | await this.db.prepare(` 25 | DELETE FROM api_keys WHERE key = ? 26 | `).bind(key).run(); 27 | } 28 | 29 | async listApiKeys(): Promise { 30 | const result = await this.db.prepare(` 31 | SELECT key FROM api_keys ORDER BY created_at DESC 32 | `).all<{ key: string }>(); 33 | return result.results?.map(r => r.key) || []; 34 | } 35 | 36 | // Extract API key from Authorization header 37 | static extractApiKey(authHeader: string | null): string | null { 38 | if (!authHeader) return null; 39 | 40 | const match = authHeader.match(/^Bearer\s+(.+)$/i); 41 | return match ? match[1] : null; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /worker/schema.sql: -------------------------------------------------------------------------------- 1 | -- CattoPic D1 Database Schema 2 | 3 | CREATE TABLE IF NOT EXISTS images ( 4 | id TEXT PRIMARY KEY, 5 | original_name TEXT NOT NULL, 6 | upload_time TEXT NOT NULL, 7 | expiry_time TEXT, 8 | orientation TEXT NOT NULL CHECK (orientation IN ('landscape', 'portrait')), 9 | format TEXT NOT NULL, 10 | width INTEGER NOT NULL, 11 | height INTEGER NOT NULL, 12 | path_original TEXT NOT NULL, 13 | path_webp TEXT, 14 | path_avif TEXT, 15 | size_original INTEGER NOT NULL, 16 | size_webp INTEGER DEFAULT 0, 17 | size_avif INTEGER DEFAULT 0 18 | ); 19 | 20 | CREATE INDEX IF NOT EXISTS idx_images_orientation ON images(orientation); 21 | CREATE INDEX IF NOT EXISTS idx_images_upload_time ON images(upload_time DESC); 22 | 23 | CREATE TABLE IF NOT EXISTS tags ( 24 | id INTEGER PRIMARY KEY AUTOINCREMENT, 25 | name TEXT NOT NULL UNIQUE 26 | ); 27 | 28 | CREATE TABLE IF NOT EXISTS image_tags ( 29 | image_id TEXT NOT NULL, 30 | tag_id INTEGER NOT NULL, 31 | PRIMARY KEY (image_id, tag_id), 32 | FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE, 33 | FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE 34 | ); 35 | 36 | CREATE TABLE IF NOT EXISTS api_keys ( 37 | id INTEGER PRIMARY KEY AUTOINCREMENT, 38 | key TEXT NOT NULL UNIQUE, 39 | created_at TEXT NOT NULL DEFAULT (datetime('now')), 40 | last_used_at TEXT 41 | ); 42 | 43 | CREATE TABLE IF NOT EXISTS config ( 44 | key TEXT PRIMARY KEY, 45 | value TEXT NOT NULL 46 | ); 47 | -------------------------------------------------------------------------------- /app/utils/auth.ts: -------------------------------------------------------------------------------- 1 | const API_KEY_KEY = "cattopic_api_key"; 2 | export const API_KEY_CHANGE_EVENT = "cattopic_api_key_change"; 3 | const BASE_URL = process.env.NEXT_PUBLIC_API_URL || ""; 4 | export const getApiKey = (): string | null => { 5 | if (typeof window !== "undefined") { 6 | return localStorage.getItem(API_KEY_KEY); 7 | } 8 | return null; 9 | }; 10 | 11 | export const setApiKey = (apiKey: string): void => { 12 | if (typeof window !== "undefined") { 13 | localStorage.setItem(API_KEY_KEY, apiKey); 14 | window.dispatchEvent(new Event(API_KEY_CHANGE_EVENT)); 15 | } 16 | }; 17 | 18 | export const removeApiKey = (): void => { 19 | if (typeof window !== "undefined") { 20 | localStorage.removeItem(API_KEY_KEY); 21 | window.dispatchEvent(new Event(API_KEY_CHANGE_EVENT)); 22 | } 23 | }; 24 | 25 | export const validateApiKey = async (apiKey: string): Promise => { 26 | try { 27 | const response = await fetch(`${BASE_URL}/api/validate-api-key`, { 28 | method: "POST", 29 | headers: { 30 | "Content-Type": "application/json", 31 | Authorization: `Bearer ${apiKey}`, 32 | }, 33 | }); 34 | 35 | if (!response.ok) { 36 | const errorText = await response.text(); 37 | console.error("API Key validation failed:", { 38 | status: response.status, 39 | statusText: response.statusText, 40 | responseText: errorText 41 | }); 42 | return false; 43 | } 44 | 45 | const data = await response.json(); 46 | return data.valid === true; 47 | } catch (error) { 48 | console.error("API Key validation error:", error); 49 | return false; 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /worker/src/handlers/queue.ts: -------------------------------------------------------------------------------- 1 | // Queue Consumer Handler - 处理异步 R2 文件删除 2 | import { StorageService } from '../services/storage'; 3 | import type { Env } from '../types'; 4 | import type { QueueMessage, ImagePaths } from '../types/queue'; 5 | 6 | // 删除单个图片的所有 R2 文件 7 | async function deleteImageFiles(paths: ImagePaths, storage: StorageService): Promise { 8 | const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.length > 0; 9 | const keysToDelete = Array.from(new Set([paths.original, paths.webp, paths.avif].filter(isNonEmptyString))); 10 | await storage.deleteMany(keysToDelete); 11 | } 12 | 13 | export async function handleQueueBatch( 14 | batch: MessageBatch, 15 | env: Env 16 | ): Promise { 17 | const storage = new StorageService(env.R2_BUCKET); 18 | 19 | for (const message of batch.messages) { 20 | try { 21 | switch (message.body.type) { 22 | case 'delete_image': 23 | // 单个图片:删除其所有 R2 文件 24 | console.log(`Deleting R2 files for image: ${message.body.imageId}`); 25 | await deleteImageFiles(message.body.paths, storage); 26 | break; 27 | 28 | case 'delete_tag_images': 29 | // 批量图片:并行删除所有关联图片的 R2 文件 30 | console.log(`Deleting R2 files for tag: ${message.body.tagName}, ${message.body.imagePaths.length} images`); 31 | await Promise.all( 32 | message.body.imagePaths.map(img => deleteImageFiles(img.paths, storage)) 33 | ); 34 | break; 35 | } 36 | message.ack(); 37 | } catch (error) { 38 | console.error('Queue message failed:', error); 39 | message.retry(); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /CHANGELOG_CN.md: -------------------------------------------------------------------------------- 1 | # 更新日志 2 | 3 | 此项目的所有重要更改都将记录在此文件中。 4 | 5 | 格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 6 | 7 | ## [未发布] 8 | 9 | ### 新增 10 | 11 | - **ZIP 批量上传** - 支持通过 ZIP 压缩包批量上传图片 12 | - 使用 JSZip 在浏览器端解压 13 | - 分批处理(每批 50 张)防止内存溢出 14 | - 实时显示解压和上传进度 15 | - 支持为所有图片设置统一标签 16 | - 自动跳过非图片文件和超过 70MB 的文件 17 | 18 | ### 变更 19 | 20 | - 当 WebP/AVIF 文件未生成/缺失时(例如超过 10MB 的上传),改用 Cloudflare Transform Images URL(`/cdn-cgi/image/...`)作为兜底输出方式。 21 | - `/api/random` 改为 302 重定向到实际图片 URL(不再由 Worker 代理回源返回图片字节,Transform-URL 场景更稳定)。 22 | - 关闭 Next.js 图片优化(图片已使用 Transform-URL 输出,无需再二次优化)。 23 | - Transform-URL 参数改为严格按配置输出(不再附加额外参数;未设置最大尺寸时不强制 AVIF 缩放)。 24 | - 管理页瀑布流列表引入 TanStack Virtual 虚拟渲染,保持大图库场景下 DOM 数量稳定。 25 | - 上传页侧边栏(预览/结果)引入 TanStack Virtual 虚拟渲染,提升大批量场景下的滚动流畅度。 26 | - UI 列表/网格统一使用 `/cdn-cgi/image/width=...` 请求缩略图,降低带宽与解码开销。 27 | - `/api/images` 新增 `format` 后端筛选(`all|gif|webp|avif|original`),减少大图库场景下前端筛选与处理开销。 28 | - 管理页单页加载数量从 24 提升到 60,减少滚动过程中的请求次数与抖动。 29 | - 默认 `maxUploadCount` 调整为 50,并发上传数量统一调整为 5(含 AVIF)。 30 | 31 | ### 废弃 32 | 33 | ### 移除 34 | 35 | ### 修复 36 | 37 | - 修复 WebP 和 AVIF 图片的方向检测 - 现在会正确读取图片实际尺寸,而不是默认返回 1920x1080。 38 | - 修复删除图片后上传页/管理页未及时刷新(TanStack Query 缓存 + recent uploads 列表导致需强刷)。 39 | - 修复管理页「随机图 API 生成器」未能正确解析真实 API Base URL(改为从 `/api/config` 获取),仍输出占位链接 `https://your-worker.workers.dev` 的问题。 40 | - 修复 `/api/images` 分页参数无边界问题,并统一对 `/api/images/:id` 的标签更新进行清洗/归一化处理。 41 | - 修复管理页在未提供 API Key 时仍发起受保护接口请求的问题。 42 | - 修复管理页虚拟瀑布流在生产构建中出现 React #301 无限重渲染崩溃的问题。 43 | - 修复 `/favicon.ico` 请求返回 404(改为重定向到 `/static/favicon.ico`)。 44 | - 修复未设置 API Key 时仍发送 `Authorization: Bearer null` 的问题。 45 | - 统一清洗并校验标签路由参数(重命名/删除标签),拒绝非法标签名。 46 | - 上传接口支持 multipart 使用 `image` 或 `file` 作为文件字段名。 47 | 48 | ### 安全 49 | 50 | - 收紧标签清洗规则,避免标签管理相关接口出现意外字符输入。 51 | -------------------------------------------------------------------------------- /worker/src/utils/response.ts: -------------------------------------------------------------------------------- 1 | // Response Utilities 2 | export function jsonResponse(data: T, status: number = 200): Response { 3 | return new Response(JSON.stringify(data), { 4 | status, 5 | headers: { 6 | 'Content-Type': 'application/json', 7 | 'Access-Control-Allow-Origin': '*', 8 | 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 9 | 'Access-Control-Allow-Headers': 'Content-Type, Authorization' 10 | } 11 | }); 12 | } 13 | 14 | export function successResponse(data: T): Response { 15 | return jsonResponse({ success: true, ...data }); 16 | } 17 | 18 | export function errorResponse(error: string, status: number = 400): Response { 19 | return jsonResponse({ success: false, error }, status); 20 | } 21 | 22 | export function unauthorizedResponse(): Response { 23 | return errorResponse('Unauthorized', 401); 24 | } 25 | 26 | export function notFoundResponse(message: string = 'Not found'): Response { 27 | return errorResponse(message, 404); 28 | } 29 | 30 | export function imageResponse( 31 | data: ArrayBuffer | Uint8Array, 32 | contentType: string, 33 | cacheControl: string = 'public, max-age=31536000' 34 | ): Response { 35 | return new Response(data, { 36 | headers: { 37 | 'Content-Type': contentType, 38 | 'Cache-Control': cacheControl, 39 | 'Access-Control-Allow-Origin': '*' 40 | } 41 | }); 42 | } 43 | 44 | export function corsResponse(): Response { 45 | return new Response(null, { 46 | headers: { 47 | 'Access-Control-Allow-Origin': '*', 48 | 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 49 | 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 50 | 'Access-Control-Max-Age': '86400' 51 | } 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /app/components/upload/UploadModeToggle.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ImageIcon, ArchiveIcon } from '../ui/icons' 4 | 5 | export type UploadMode = 'images' | 'zip' 6 | 7 | interface UploadModeToggleProps { 8 | mode: UploadMode 9 | onChange: (mode: UploadMode) => void 10 | disabled?: boolean 11 | } 12 | 13 | export default function UploadModeToggle({ 14 | mode, 15 | onChange, 16 | disabled = false, 17 | }: UploadModeToggleProps) { 18 | return ( 19 |
20 | 32 | 44 |
45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /worker/wrangler.example.toml: -------------------------------------------------------------------------------- 1 | # CattoPic Worker Configuration Template 2 | # Copy this file to wrangler.toml and fill in your values 3 | # cp wrangler.example.toml wrangler.toml 4 | 5 | name = 'cattopic-worker' 6 | main = 'src/index.ts' 7 | compatibility_date = '2025-12-10' 8 | compatibility_flags = ['nodejs_compat'] 9 | 10 | [vars] 11 | ENVIRONMENT = 'production' 12 | # Your R2 bucket public URL (custom domain or workers.dev URL) 13 | R2_PUBLIC_URL = '' 14 | 15 | # Images binding for image transformations (Cloudflare Images) 16 | [images] 17 | binding = "IMAGES" 18 | 19 | # R2 Bucket binding 20 | [[r2_buckets]] 21 | binding = 'R2_BUCKET' 22 | # Your R2 bucket name 23 | bucket_name = '' 24 | 25 | # D1 Database binding 26 | [[d1_databases]] 27 | binding = 'DB' 28 | # Your D1 database name 29 | database_name = '' 30 | # Your D1 database ID (get from: wrangler d1 list) 31 | database_id = '' 32 | 33 | # KV Namespace for caching 34 | [[kv_namespaces]] 35 | binding = "CACHE_KV" 36 | # Your KV namespace ID (get from: wrangler kv namespace list) 37 | id = "" 38 | 39 | # Queue for async R2 deletion 40 | [[queues.producers]] 41 | # Your queue name 42 | queue = "" 43 | binding = "DELETE_QUEUE" 44 | 45 | [[queues.consumers]] 46 | # Same queue name as above 47 | queue = "" 48 | max_batch_size = 10 49 | max_batch_timeout = 5 50 | 51 | # Cron Triggers - cleanup expired images every hour 52 | [triggers] 53 | crons = ['0 * * * *'] 54 | 55 | # Development settings 56 | [dev] 57 | port = 8787 58 | local_protocol = 'http' 59 | 60 | # Observability - logging configuration 61 | [observability] 62 | [observability.logs] 63 | enabled = true 64 | head_sampling_rate = 1 65 | invocation_logs = true 66 | persist = true 67 | -------------------------------------------------------------------------------- /worker/migrations/0001_init.sql: -------------------------------------------------------------------------------- 1 | -- 图片主表 2 | CREATE TABLE images ( 3 | id TEXT PRIMARY KEY, 4 | original_name TEXT NOT NULL, 5 | upload_time TEXT NOT NULL, 6 | expiry_time TEXT, 7 | orientation TEXT NOT NULL CHECK (orientation IN ('landscape', 'portrait')), 8 | format TEXT NOT NULL, 9 | width INTEGER NOT NULL, 10 | height INTEGER NOT NULL, 11 | path_original TEXT NOT NULL, 12 | path_webp TEXT, 13 | path_avif TEXT, 14 | size_original INTEGER NOT NULL, 15 | size_webp INTEGER DEFAULT 0, 16 | size_avif INTEGER DEFAULT 0 17 | ); 18 | 19 | -- 索引优化查询 20 | CREATE INDEX idx_images_orientation ON images(orientation); 21 | CREATE INDEX idx_images_upload_time ON images(upload_time DESC); 22 | CREATE INDEX idx_images_expiry_time ON images(expiry_time) WHERE expiry_time IS NOT NULL; 23 | 24 | -- 标签表 25 | CREATE TABLE tags ( 26 | id INTEGER PRIMARY KEY AUTOINCREMENT, 27 | name TEXT NOT NULL UNIQUE 28 | ); 29 | 30 | CREATE INDEX idx_tags_name ON tags(name); 31 | 32 | -- 图片-标签关联表 (多对多) 33 | CREATE TABLE image_tags ( 34 | image_id TEXT NOT NULL, 35 | tag_id INTEGER NOT NULL, 36 | PRIMARY KEY (image_id, tag_id), 37 | FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE, 38 | FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE 39 | ); 40 | 41 | CREATE INDEX idx_image_tags_tag_id ON image_tags(tag_id); 42 | CREATE INDEX idx_image_tags_image_id ON image_tags(image_id); 43 | 44 | -- API 密钥表 45 | CREATE TABLE api_keys ( 46 | id INTEGER PRIMARY KEY AUTOINCREMENT, 47 | key TEXT NOT NULL UNIQUE, 48 | created_at TEXT NOT NULL DEFAULT (datetime('now')), 49 | last_used_at TEXT 50 | ); 51 | 52 | CREATE INDEX idx_api_keys_key ON api_keys(key); 53 | 54 | -- 系统配置表 55 | CREATE TABLE config ( 56 | key TEXT PRIMARY KEY, 57 | value TEXT NOT NULL 58 | ); 59 | -------------------------------------------------------------------------------- /app/utils/cdnImage.ts: -------------------------------------------------------------------------------- 1 | import { getFullUrl } from './baseUrl'; 2 | 3 | export interface CdnCgiImageOptions { 4 | width?: number; 5 | height?: number; 6 | quality?: number; 7 | format?: 'auto' | 'webp' | 'avif'; 8 | fit?: 'scale-down' | 'cover' | 'contain'; 9 | } 10 | 11 | function clampInt(value: unknown, min: number, max: number): number { 12 | const num = typeof value === 'number' ? value : Number(value); 13 | if (!Number.isFinite(num)) return min; 14 | return Math.max(min, Math.min(max, Math.trunc(num))); 15 | } 16 | 17 | function buildOptionsString(options: CdnCgiImageOptions): string { 18 | const parts: string[] = []; 19 | if (options.width) parts.push(`width=${clampInt(options.width, 1, 4096)}`); 20 | if (options.height) parts.push(`height=${clampInt(options.height, 1, 4096)}`); 21 | parts.push(`fit=${options.fit || 'scale-down'}`); 22 | parts.push(`quality=${clampInt(options.quality ?? 75, 1, 100)}`); 23 | parts.push(`format=${options.format || 'auto'}`); 24 | return parts.join(','); 25 | } 26 | 27 | export function toCdnCgiImageUrl(inputUrl: string, options: CdnCgiImageOptions): string { 28 | const fullUrl = getFullUrl(inputUrl); 29 | if (!fullUrl) return ''; 30 | 31 | let url: URL; 32 | try { 33 | url = new URL(fullUrl); 34 | } catch { 35 | return fullUrl; 36 | } 37 | 38 | const optionsString = buildOptionsString(options); 39 | const prefix = '/cdn-cgi/image/'; 40 | 41 | if (url.pathname.startsWith(prefix)) { 42 | const rest = url.pathname.slice(prefix.length); 43 | const slashIndex = rest.indexOf('/'); 44 | if (slashIndex === -1) { 45 | return fullUrl; 46 | } 47 | const originPath = rest.slice(slashIndex); 48 | url.pathname = `${prefix}${optionsString}${originPath}`; 49 | return url.toString(); 50 | } 51 | 52 | url.pathname = `${prefix}${optionsString}${url.pathname}`; 53 | return url.toString(); 54 | } 55 | 56 | -------------------------------------------------------------------------------- /app/components/ToastContainer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useCallback, useEffect } from "react"; 4 | import Toast, { ToastType } from "./Toast"; 5 | 6 | // 创建唯一标识 7 | const generateId = () => `toast-${Date.now()}-${Math.floor(Math.random() * 1000)}`; 8 | 9 | type ToastItem = { 10 | id: string; 11 | message: string; 12 | type: ToastType; 13 | }; 14 | 15 | // 全局存储toast状态和回调方法 16 | let toastQueue: ToastItem[] = []; 17 | let addToastCallback: ((toast: ToastItem) => void) | null = null; 18 | 19 | // 添加Toast的全局方法 20 | export const showToast = (message: string, type: ToastType = "success") => { 21 | const newToast = { id: generateId(), message, type }; 22 | 23 | if (addToastCallback) { 24 | addToastCallback(newToast); 25 | } else { 26 | toastQueue.push(newToast); 27 | } 28 | }; 29 | 30 | export default function ToastContainer() { 31 | const [toasts, setToasts] = useState([]); 32 | 33 | // 添加Toast的回调 34 | const addToast = useCallback((toast: ToastItem) => { 35 | setToasts(prev => [...prev, toast]); 36 | }, []); 37 | 38 | // 移除Toast 39 | const removeToast = useCallback((id: string) => { 40 | setToasts(prev => prev.filter(toast => toast.id !== id)); 41 | }, []); 42 | 43 | // 初始化时注册回调并处理队列中的Toast 44 | useEffect(() => { 45 | addToastCallback = addToast; 46 | 47 | // 处理队列中已有的Toast 48 | if (toastQueue.length > 0) { 49 | const pendingToasts = [...toastQueue]; 50 | toastQueue = []; 51 | pendingToasts.forEach(addToast); 52 | } 53 | 54 | return () => { 55 | addToastCallback = null; 56 | }; 57 | }, [addToast]); 58 | 59 | return ( 60 | <> 61 | {toasts.map(toast => ( 62 | removeToast(toast.id)} 67 | /> 68 | ))} 69 | 70 | ); 71 | } -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | import fs from 'node:fs'; 3 | import path from 'node:path'; 4 | import dotenv from 'dotenv'; 5 | 6 | const parentEnvPath = path.resolve(process.cwd(), '../.env'); 7 | if (fs.existsSync(parentEnvPath)) { 8 | const parentEnv = dotenv.parse(fs.readFileSync(parentEnvPath)); 9 | for (const [key, value] of Object.entries(parentEnv)) { 10 | process.env[key] = value; 11 | } 12 | } 13 | 14 | /** @type {boolean} */ 15 | // Set to false to enable server mode with image optimization (requires Vercel) 16 | const isStaticExport = false; 17 | 18 | const parseRemotePatterns = (patterns) => { 19 | if (!patterns || isStaticExport) { 20 | console.log('isStaticExport:', isStaticExport); 21 | return undefined; 22 | } 23 | 24 | const patternList = patterns.split(','); 25 | return patternList.map(pattern => { 26 | pattern = pattern.trim(); 27 | if (pattern.startsWith('http://') || pattern.startsWith('https://')) { 28 | const url = new URL(pattern); 29 | return { 30 | protocol: url.protocol.replace(':', ''), 31 | hostname: url.hostname 32 | }; 33 | } 34 | 35 | return { 36 | protocol: 'http', 37 | hostname: pattern 38 | }; 39 | }); 40 | }; 41 | 42 | const remotePatterns = parseRemotePatterns(process.env.NEXT_PUBLIC_REMOTE_PATTERNS); 43 | 44 | const nextConfig = { 45 | reactStrictMode: true, 46 | output: isStaticExport ? 'export' : undefined, 47 | images: { 48 | // Disable Next.js image optimization; images are already delivered as transformed URLs. 49 | unoptimized: true, 50 | remotePatterns: remotePatterns 51 | }, 52 | env: { 53 | NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || '', 54 | NEXT_PUBLIC_REMOTE_PATTERNS: process.env.NEXT_PUBLIC_REMOTE_PATTERNS || '', 55 | API_URL: process.env.NEXT_PUBLIC_API_URL || '' 56 | } 57 | }; 58 | 59 | export default nextConfig; 60 | -------------------------------------------------------------------------------- /app/components/Toast.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useEffect } from "react"; 4 | import { motion, AnimatePresence } from 'motion/react'; 5 | import { StatusIcon } from "./ui/icons"; 6 | 7 | export type ToastType = "success" | "error" | "info"; 8 | 9 | interface ToastProps { 10 | message: string; 11 | type?: ToastType; 12 | duration?: number; 13 | onClose: () => void; 14 | } 15 | 16 | export default function Toast({ 17 | message, 18 | type = "success", 19 | duration = 2000, 20 | onClose 21 | }: ToastProps) { 22 | const [isVisible, setIsVisible] = useState(true); 23 | 24 | useEffect(() => { 25 | const timer = setTimeout(() => { 26 | setIsVisible(false); 27 | setTimeout(onClose, 300); // 等待动画结束后关闭 28 | }, duration); 29 | 30 | return () => clearTimeout(timer); 31 | }, [duration, onClose]); 32 | 33 | const getIcon = () => { 34 | switch (type) { 35 | case "success": 36 | return ; 37 | case "error": 38 | return ; 39 | case "info": 40 | return ; 41 | default: 42 | return null; 43 | } 44 | }; 45 | 46 | return ( 47 | 48 | {isVisible && ( 49 | 56 |
57 | {getIcon()} 58 | {message} 59 |
60 |
61 | )} 62 |
63 | ); 64 | } -------------------------------------------------------------------------------- /app/components/RandomApiModal/OrientationSelector.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { motion } from 'motion/react'; 4 | import type { Orientation } from './index'; 5 | 6 | interface OrientationSelectorProps { 7 | value: Orientation; 8 | onChange: (value: Orientation) => void; 9 | } 10 | 11 | const options: { value: Orientation; label: string; description: string }[] = [ 12 | { value: 'auto', label: 'Auto', description: '自动检测设备' }, 13 | { value: 'landscape', label: 'Landscape', description: '横向' }, 14 | { value: 'portrait', label: 'Portrait', description: '纵向' }, 15 | ]; 16 | 17 | export default function OrientationSelector({ value, onChange }: OrientationSelectorProps) { 18 | return ( 19 |
20 |

方向

21 |
22 | {options.map((option) => { 23 | const isSelected = value === option.value; 24 | return ( 25 | onChange(option.value)} 28 | className={` 29 | relative flex-1 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors duration-200 30 | ${isSelected 31 | ? 'text-white' 32 | : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200' 33 | } 34 | `} 35 | whileTap={{ scale: 0.98 }} 36 | > 37 | {isSelected && ( 38 | 43 | )} 44 | 45 | {option.label} 46 | 47 | {option.description} 48 | 49 | 50 | 51 | ); 52 | })} 53 |
54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /app/components/RandomApiModal/FormatSelector.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { motion } from 'motion/react'; 4 | import type { Format } from './index'; 5 | 6 | interface FormatSelectorProps { 7 | value: Format; 8 | onChange: (value: Format) => void; 9 | } 10 | 11 | const options: { value: Format; label: string; description: string }[] = [ 12 | { value: 'auto', label: 'Auto', description: '自动选择' }, 13 | { value: 'original', label: 'Original', description: '原始格式' }, 14 | { value: 'webp', label: 'WebP', description: '压缩格式' }, 15 | { value: 'avif', label: 'AVIF', description: '高效压缩' }, 16 | ]; 17 | 18 | export default function FormatSelector({ value, onChange }: FormatSelectorProps) { 19 | return ( 20 |
21 |

格式

22 |
23 | {options.map((option) => { 24 | const isSelected = value === option.value; 25 | return ( 26 | onChange(option.value)} 29 | className={` 30 | relative flex-1 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-200 31 | ${isSelected 32 | ? 'text-white' 33 | : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200' 34 | } 35 | `} 36 | whileTap={{ scale: 0.98 }} 37 | > 38 | {isSelected && ( 39 | 44 | )} 45 | 46 | {option.label} 47 | 48 | {option.description} 49 | 50 | 51 | 52 | ); 53 | })} 54 |
55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter, Noto_Sans_SC } from "next/font/google"; 3 | import "./globals.css"; 4 | import { QueryProvider } from "./providers/QueryProvider"; 5 | 6 | // Configure Inter font 7 | const inter = Inter({ 8 | subsets: ['latin'], 9 | display: 'swap', 10 | weight: ['400', '700'], 11 | }); 12 | 13 | // Configure Noto Sans SC font with bold weight 14 | const notoSansSC = Noto_Sans_SC({ 15 | subsets: ['latin'], 16 | weight: ['700'], // Only use bold weight 17 | display: 'swap', 18 | }); 19 | 20 | export const metadata: Metadata = { 21 | title: "CattoPic - 图片管理", 22 | description: "一个简单而强大的图片管理工具", 23 | icons: { 24 | icon: [ 25 | { url: "/static/favicon.ico", sizes: "any" }, 26 | { url: "/static/favicon.svg", type: "image/svg+xml" }, 27 | { url: "/static/favicon-48.png", sizes: "48x48", type: "image/png" }, 28 | { url: "/static/favicon-32.png", sizes: "32x32", type: "image/png" }, 29 | { url: "/static/favicon-16.png", sizes: "16x16", type: "image/png" }, 30 | ], 31 | apple: [ 32 | { url: "/static/favicon-48.png", sizes: "48x48", type: "image/png" }, 33 | ], 34 | }, 35 | }; 36 | 37 | export default function RootLayout({ 38 | children, 39 | }: { 40 | children: React.ReactNode; 41 | }) { 42 | return ( 43 | 44 | 45 | {/* 动态背景 */} 46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | 54 | 55 | {children} 56 | 57 | 58 | {/* 页脚 */} 59 |
60 | Create By{" "} 61 | 66 | 猫猫博客 67 | 68 |
69 | 70 | 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /app/utils/imageUtils.ts: -------------------------------------------------------------------------------- 1 | // 格式化文件大小 2 | export const formatFileSize = (bytes: number): string => { 3 | if (bytes < 1024) return bytes + " B"; 4 | else if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB"; 5 | else if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + " MB"; 6 | else return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB"; 7 | }; 8 | 9 | // 获取格式标签 10 | export const getFormatLabel = (format: string): string => { 11 | const formatMap: { [key: string]: string } = { 12 | png: "PNG", 13 | jpg: "JPG", 14 | jpeg: "JPEG", 15 | webp: "WebP", 16 | gif: "GIF", 17 | avif: "AVIF", 18 | }; 19 | return formatMap[format.toLowerCase()] || format.toUpperCase(); 20 | }; 21 | 22 | // 获取方向标签 23 | export const getOrientationLabel = (orientation: string): string => { 24 | const orientationMap: { [key: string]: string } = { 25 | landscape: "横向", 26 | portrait: "纵向", 27 | square: "方形", 28 | }; 29 | return orientationMap[orientation.toLowerCase()] || orientation; 30 | }; 31 | 32 | // 构建URL 33 | export const buildUrl = (path: string, format: string): string => { 34 | const originalPath = path; 35 | 36 | let orientation = ""; 37 | if (originalPath.includes("/landscape/")) { 38 | orientation = "landscape"; 39 | } else if (originalPath.includes("/portrait/")) { 40 | orientation = "portrait"; 41 | } else if (originalPath.includes("/square/")) { 42 | orientation = "square"; 43 | } 44 | 45 | const fileNameParts = originalPath.split('/').pop()?.split('.') || []; 46 | const fileName = fileNameParts[0] || ""; 47 | const originalExt = fileNameParts[1]; 48 | const urlParts = originalPath.split('/'); 49 | const domain = urlParts.slice(0, 3).join('/'); 50 | 51 | let relativePath = ''; 52 | if (format === "original") { 53 | relativePath = `original/${orientation}/${fileName}.${originalExt}`; 54 | } else if (format === "webp") { 55 | relativePath = `${orientation}/webp/${fileName}.webp`; 56 | } else if (format === "avif") { 57 | relativePath = `${orientation}/avif/${fileName}.avif`; 58 | } 59 | 60 | if (originalPath.startsWith('http')) { 61 | return `${domain}/${relativePath}`; 62 | } 63 | 64 | return `/images/${relativePath}`; 65 | }; 66 | 67 | // 构建Markdown链接格式 68 | export const buildMarkdownLink = (url: string, filename: string): string => { 69 | return `![${filename}](${url})`; 70 | }; 71 | -------------------------------------------------------------------------------- /app/components/TagManagementModal.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { motion, AnimatePresence } from 'motion/react'; 4 | import { TagIcon, Cross1Icon } from './ui/icons'; 5 | import TagManagement from './TagManagement'; 6 | 7 | interface TagManagementModalProps { 8 | isOpen: boolean; 9 | onClose: () => void; 10 | } 11 | 12 | export default function TagManagementModal({ isOpen, onClose }: TagManagementModalProps) { 13 | if (!isOpen) return null; 14 | 15 | return ( 16 | 17 | {isOpen && ( 18 | { 24 | if (e.target === e.currentTarget) onClose(); 25 | }} 26 | > 27 | 34 | {/* 标题栏 */} 35 |
36 |
37 |
38 | 39 |
40 |

标签管理

41 |
42 | 48 |
49 | 50 | {/* 内容区域 */} 51 |
52 | 53 |
54 |
55 |
56 | )} 57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /app/components/TagManagement/TagCreateForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState } from 'react'; 4 | import { motion } from 'motion/react'; 5 | import { PlusIcon, Spinner } from '../ui/icons'; 6 | 7 | interface TagCreateFormProps { 8 | onSubmit: (name: string) => Promise; 9 | isProcessing: boolean; 10 | } 11 | 12 | export default function TagCreateForm({ onSubmit, isProcessing }: TagCreateFormProps) { 13 | const [name, setName] = useState(''); 14 | const [isSubmitting, setIsSubmitting] = useState(false); 15 | 16 | const handleSubmit = async (e: React.FormEvent) => { 17 | e.preventDefault(); 18 | if (!name.trim() || isSubmitting) return; 19 | 20 | setIsSubmitting(true); 21 | const success = await onSubmit(name.trim()); 22 | setIsSubmitting(false); 23 | 24 | if (success) { 25 | setName(''); 26 | } 27 | }; 28 | 29 | return ( 30 |
31 |

32 | 创建新标签 33 |

34 |
35 | setName(e.target.value)} 39 | placeholder="输入标签名称..." 40 | className="flex-1 px-4 py-2.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-700 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 transition-all" 41 | disabled={isProcessing} 42 | /> 43 | 50 | {isSubmitting ? ( 51 | <> 52 | 53 | 创建中... 54 | 55 | ) : ( 56 | <> 57 | 58 | 创建标签 59 | 60 | )} 61 | 62 |
63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /app/components/ImageDetail/DeleteButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react' 4 | import { ImageData } from '../../types/image' 5 | import { TrashIcon, Spinner } from '../ui/icons' 6 | 7 | interface DeleteButtonProps { 8 | image: ImageData 9 | onDelete: (id: string) => Promise 10 | } 11 | 12 | export function DeleteButton({ image, onDelete }: DeleteButtonProps) { 13 | const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) 14 | const [isDeleting, setIsDeleting] = useState(false) 15 | 16 | const handleDelete = async () => { 17 | try { 18 | setIsDeleting(true) 19 | 20 | let imageId = image.id 21 | 22 | // If the ID is not available or not reliable, extract it from the URL 23 | if (!imageId && image.urls?.original) { 24 | const urlParts = image.urls.original.split('/') 25 | const filename = urlParts[urlParts.length - 1] 26 | imageId = filename.split('.')[0] // Remove file extension to get ID 27 | } 28 | 29 | if (!imageId) { 30 | throw new Error("无法获取图像ID") 31 | } 32 | 33 | await onDelete(imageId) 34 | setShowDeleteConfirm(false) 35 | } catch (err) { 36 | console.error("删除失败:", err) 37 | } finally { 38 | setIsDeleting(false) 39 | } 40 | } 41 | 42 | if (!showDeleteConfirm) { 43 | return ( 44 | 51 | ) 52 | } 53 | 54 | return ( 55 |
56 | 63 | 80 |
81 | ) 82 | } -------------------------------------------------------------------------------- /app/components/TagManagement/TagItem.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Tag } from '../../types'; 4 | import { CheckIcon, TrashIcon } from '../ui/icons'; 5 | import { Pencil } from 'lucide-react'; 6 | 7 | interface TagItemProps { 8 | tag: Tag; 9 | isSelected: boolean; 10 | onToggleSelect: () => void; 11 | onEdit: () => void; 12 | onDelete: () => void; 13 | } 14 | 15 | export default function TagItem({ 16 | tag, 17 | isSelected, 18 | onToggleSelect, 19 | onEdit, 20 | onDelete, 21 | }: TagItemProps) { 22 | return ( 23 |
28 | {/* 选择框 */} 29 | 39 | 40 |
41 | {/* 标签名称 */} 42 |
43 | 44 | {tag.name} 45 | 46 |
47 | 48 | {/* 使用数量 */} 49 |
50 | 51 | {tag.count} 张图片 52 | 53 |
54 | 55 | {/* 操作按钮 */} 56 |
57 | 64 | 71 |
72 |
73 |
74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /app/types/index.ts: -------------------------------------------------------------------------------- 1 | // 通用类型 2 | export interface ApiResponse { 3 | success: boolean; 4 | data?: T; 5 | error?: string; 6 | } 7 | 8 | export interface PaginatedResponse extends ApiResponse { 9 | data?: { 10 | items: T[]; 11 | total: number; 12 | page: number; 13 | pageSize: number; 14 | }; 15 | } 16 | 17 | // 标签类型 18 | export interface Tag { 19 | name: string; 20 | count: number; 21 | } 22 | 23 | // 图片相关类型 24 | export interface ImageFile { 25 | id: string; 26 | originalName: string; 27 | uploadTime: string; 28 | expiryTime?: string; 29 | orientation: 'landscape' | 'portrait'; 30 | tags: string[]; 31 | format: string; 32 | width: number; 33 | height: number; 34 | paths: { 35 | original: string; 36 | webp: string; 37 | avif: string; 38 | }; 39 | sizes: { 40 | original: number; 41 | webp: number; 42 | avif: number; 43 | }; 44 | urls: { 45 | original: string; 46 | webp: string; 47 | avif: string; 48 | }; 49 | } 50 | 51 | export interface ImageListResponse { 52 | images: ImageFile[]; 53 | page: number; 54 | totalPages: number; 55 | total: number; 56 | } 57 | 58 | export interface ImageFilterState { 59 | format: string; 60 | orientation: string; 61 | tag: string; 62 | } 63 | 64 | // 组件 Props 类型 65 | export interface ImageCardProps { 66 | image: ImageFile; 67 | onClick: () => void; 68 | } 69 | 70 | export interface ImageModalProps { 71 | image: ImageFile | null; 72 | isOpen: boolean; 73 | onClose: () => void; 74 | onDelete: (id: string) => Promise; 75 | } 76 | 77 | export interface ApiKeyModalProps { 78 | isOpen: boolean; 79 | onClose: () => void; 80 | onSuccess: (apiKey: string) => void; 81 | } 82 | 83 | export interface ImageFiltersProps { 84 | onFilterChange: (format: string, orientation: string, tag: string) => void; 85 | } 86 | 87 | // 上传结果类型定义 88 | export interface UploadResult { 89 | id: string; 90 | status: "success" | "error"; 91 | originalName?: string; 92 | clientFileId?: string; 93 | format?: string; 94 | urls?: { 95 | original: string; 96 | webp: string; 97 | avif: string; 98 | }; 99 | orientation?: 'landscape' | 'portrait'; 100 | tags?: string[]; 101 | sizes?: { 102 | original: number; 103 | webp: number; 104 | avif: number; 105 | }; 106 | expiryTime?: string; 107 | error?: string; 108 | } 109 | 110 | export interface UploadResponse { 111 | results: UploadResult[]; 112 | } 113 | 114 | // 状态消息类型 115 | export interface StatusMessage { 116 | type: "success" | "error" | "warning"; 117 | message: string; 118 | } 119 | 120 | // 配置类型 121 | export interface ConfigSettings { 122 | maxUploadCount: number; 123 | maxFileSize: number; 124 | supportedFormats: string[]; 125 | imageQuality: number; 126 | } 127 | -------------------------------------------------------------------------------- /app/components/upload/ZipUploadDropzone.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useRef, useState } from 'react' 4 | import { ArchiveIcon } from '../ui/icons' 5 | 6 | interface ZipUploadDropzoneProps { 7 | onFileSelected: (file: File) => void 8 | disabled?: boolean 9 | } 10 | 11 | export default function ZipUploadDropzone({ 12 | onFileSelected, 13 | disabled = false, 14 | }: ZipUploadDropzoneProps) { 15 | const fileInputRef = useRef(null) 16 | const [isDragOver, setIsDragOver] = useState(false) 17 | 18 | const handleFileSelect = (e: React.ChangeEvent) => { 19 | const file = e.target.files?.[0] 20 | if (file) { 21 | onFileSelected(file) 22 | } 23 | // 清空input,允许重新选择相同文件 24 | e.target.value = '' 25 | } 26 | 27 | const handleDrop = (e: React.DragEvent) => { 28 | e.preventDefault() 29 | setIsDragOver(false) 30 | 31 | if (disabled) return 32 | 33 | const file = e.dataTransfer.files[0] 34 | if (file && file.name.toLowerCase().endsWith('.zip')) { 35 | onFileSelected(file) 36 | } 37 | } 38 | 39 | const handleDragOver = (e: React.DragEvent) => { 40 | e.preventDefault() 41 | if (!disabled) { 42 | setIsDragOver(true) 43 | } 44 | } 45 | 46 | const handleDragLeave = (e: React.DragEvent) => { 47 | e.preventDefault() 48 | setIsDragOver(false) 49 | } 50 | 51 | return ( 52 |
!disabled && fileInputRef.current?.click()} 60 | > 61 |
62 | 63 |
64 |

拖放ZIP压缩包到这里

65 |

66 | 支持包含多张图片的ZIP文件 67 |

68 |

69 | 支持 JPG、PNG、GIF、WebP、AVIF 格式 70 |

71 | 79 | 90 |
91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). 6 | 7 | ## [Unreleased] 8 | 9 | ### Added 10 | 11 | - **ZIP Batch Upload** - Upload images in bulk via ZIP archive 12 | - Browser-side extraction using JSZip 13 | - Batch processing (50 images per batch) to prevent memory overflow 14 | - Real-time extraction and upload progress display 15 | - Unified tag setting for all images 16 | - Auto-skip non-image files and files over 70MB 17 | 18 | ### Changed 19 | 20 | - Use Cloudflare Transform Images URL (`/cdn-cgi/image/...`) as a fallback WebP/AVIF delivery method when stored variants are missing (e.g. uploads over 10MB). 21 | - `/api/random` now redirects (302) to the selected image URL instead of proxying the image bytes (more reliable for transformed variants). 22 | - Disable Next.js image optimization since images are already delivered as transformed URLs. 23 | - Transform-URL parameters now follow the configured settings (no extra flags; no forced AVIF resize unless a max size is specified). 24 | - Virtualize the Manage page masonry gallery with TanStack Virtual to keep DOM size stable for large libraries. 25 | - Virtualize Upload sidebars (preview + results) with TanStack Virtual to keep scrolling smooth for large batches. 26 | - Request resized thumbnail URLs via `/cdn-cgi/image/width=...` for UI grids to reduce bandwidth/decode cost. 27 | - Add server-side `format` filtering to `/api/images` (`all|gif|webp|avif|original`) to reduce client-side work for large libraries. 28 | - Increase Manage page page size from 24 to 60 to reduce request churn while scrolling. 29 | - Increase default `maxUploadCount` to 50 and use concurrency=5 for uploads (including AVIF). 30 | 31 | ### Deprecated 32 | 33 | ### Removed 34 | 35 | ### Fixed 36 | 37 | - Fix orientation detection for WebP and AVIF images - now correctly reads actual image dimensions instead of defaulting to 1920x1080. 38 | - Fix deleted images not disappearing from Upload/Manage pages without a hard refresh (TanStack Query cache + recent uploads list). 39 | - Fix Manage page Random API generator to resolve the real API base URL (via `/api/config`) instead of the placeholder `https://your-worker.workers.dev`. 40 | - Clamp `/api/images` pagination parameters and normalize/sanitize tag updates in `/api/images/:id`. 41 | - Avoid fetching protected image data before an API key is available on the Manage page. 42 | - Fix a production-only React render-loop crash (#301) in the Manage page virtual masonry. 43 | - Fix `/favicon.ico` returning 404 by redirecting to `/static/favicon.ico`. 44 | - Avoid sending `Authorization: Bearer null` when no API key is set. 45 | - Normalize and validate tag route params for tag rename/delete endpoints. 46 | - Accept multipart uploads using either `image` or `file` field names. 47 | 48 | ### Security 49 | 50 | - Tighten tag sanitization to avoid unexpected characters in tag management endpoints. 51 | -------------------------------------------------------------------------------- /app/utils/copyImageUtils.ts: -------------------------------------------------------------------------------- 1 | import { getFullUrl } from "./baseUrl"; 2 | import type { ImageFile } from "../types"; 3 | 4 | /** 5 | * 复制文本到剪贴板 6 | */ 7 | export const copyToClipboard = async (text: string): Promise => { 8 | try { 9 | // 优先使用 Clipboard API 10 | if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { 11 | await navigator.clipboard.writeText(text); 12 | return true; 13 | } 14 | 15 | // 后备方案:使用传统的 document.execCommand 方法 16 | const textArea = document.createElement('textarea'); 17 | textArea.value = text; 18 | 19 | // 防止滚动 20 | textArea.style.top = '0'; 21 | textArea.style.left = '0'; 22 | textArea.style.position = 'fixed'; 23 | textArea.style.opacity = '0'; 24 | 25 | document.body.appendChild(textArea); 26 | textArea.focus(); 27 | textArea.select(); 28 | 29 | try { 30 | const successful = document.execCommand('copy'); 31 | document.body.removeChild(textArea); 32 | return successful; 33 | } catch (err) { 34 | document.body.removeChild(textArea); 35 | console.error("复制失败:", err); 36 | return false; 37 | } 38 | } catch (err) { 39 | console.error("复制失败:", err); 40 | return false; 41 | } 42 | }; 43 | 44 | /** 45 | * 构建Markdown图片链接 46 | */ 47 | export const buildMarkdownLink = (url: string, altText: string): string => { 48 | return `![${altText}](${url})`; 49 | }; 50 | 51 | /** 52 | * 构建HTML图片标签 53 | */ 54 | export const buildHtmlImgTag = (url: string, altText: string): string => { 55 | return `${altText}`; 56 | }; 57 | 58 | /** 59 | * 复制图片链接(原始格式) 60 | */ 61 | export const copyOriginalUrl = async (image: ImageFile): Promise => { 62 | const url = getFullUrl(image.urls?.original || ''); 63 | return copyToClipboard(url); 64 | }; 65 | 66 | /** 67 | * 复制图片链接(WebP格式) 68 | */ 69 | export const copyWebpUrl = async (image: ImageFile): Promise => { 70 | const url = getFullUrl(image.urls?.webp || image.urls?.original || ''); 71 | return copyToClipboard(url); 72 | }; 73 | 74 | /** 75 | * 复制图片链接(AVIF格式) 76 | */ 77 | export const copyAvifUrl = async (image: ImageFile): Promise => { 78 | const url = getFullUrl(image.urls?.avif || ''); 79 | return copyToClipboard(url); 80 | }; 81 | 82 | /** 83 | * 复制Markdown格式的图片链接 84 | */ 85 | export const copyMarkdownLink = async (image: ImageFile): Promise => { 86 | // 优先使用WebP链接 87 | const url = getFullUrl(image.urls?.webp || image.urls?.original || ''); 88 | const markdown = buildMarkdownLink(url, image.originalName); 89 | return copyToClipboard(markdown); 90 | }; 91 | 92 | /** 93 | * 复制HTML格式的图片标签 94 | */ 95 | export const copyHtmlImgTag = async (image: ImageFile): Promise => { 96 | // 优先使用WebP链接 97 | const url = getFullUrl(image.urls?.webp || image.urls?.original || ''); 98 | const html = buildHtmlImgTag(url, image.originalName); 99 | return copyToClipboard(html); 100 | }; 101 | -------------------------------------------------------------------------------- /app/components/upload/ImagePreviewGrid.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ImageIcon, TrashIcon } from '../ui/icons' 4 | import Image from 'next/image' 5 | 6 | interface ImagePreview { 7 | id: string 8 | url: string 9 | file: File 10 | } 11 | 12 | interface ImagePreviewGridProps { 13 | previews: ImagePreview[] 14 | onRemoveFile: (id: string) => void 15 | onRemoveAll: () => void 16 | } 17 | 18 | export default function ImagePreviewGrid({ previews, onRemoveFile, onRemoveAll }: ImagePreviewGridProps) { 19 | return ( 20 |
21 |
22 |

23 | 24 | 已选择 {previews.length} 张图片 25 |

26 | 34 |
35 | 36 |
37 | {previews.map(preview => ( 38 |
39 | {preview.url ? ( 40 |
41 | {preview.file.name} 47 |
48 | 55 |
56 | ) : ( 57 |
58 | 59 |
60 | )} 61 |
{preview.file.name}
62 |
63 | ))} 64 |
65 |
66 | ) 67 | } -------------------------------------------------------------------------------- /app/components/TagManagement/TagList.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { motion } from 'motion/react'; 4 | import TagItem from './TagItem'; 5 | import { Tag } from '../../types'; 6 | import { TagIcon, CheckIcon } from '../ui/icons'; 7 | 8 | interface TagListProps { 9 | tags: Tag[]; 10 | selectedTags: Set; 11 | onToggleSelect: (name: string) => void; 12 | onSelectAll: () => void; 13 | onEdit: (tag: Tag) => void; 14 | onDelete: (tag: Tag) => void; 15 | } 16 | 17 | export default function TagList({ 18 | tags, 19 | selectedTags, 20 | onToggleSelect, 21 | onSelectAll, 22 | onEdit, 23 | onDelete, 24 | }: TagListProps) { 25 | const allSelected = tags.length > 0 && selectedTags.size === tags.length; 26 | 27 | if (tags.length === 0) { 28 | return ( 29 |
30 | 31 |

暂无标签

32 |

请创建您的第一个标签

33 |
34 | ); 35 | } 36 | 37 | return ( 38 |
39 | {/* 表头 */} 40 |
41 | 51 |
52 | 标签名称 53 | 使用数量 54 | 操作 55 |
56 |
57 | 58 | {/* 标签项列表 */} 59 |
60 | {tags.map((tag, index) => ( 61 | 67 | onToggleSelect(tag.name)} 71 | onEdit={() => onEdit(tag)} 72 | onDelete={() => onDelete(tag)} 73 | /> 74 | 75 | ))} 76 |
77 | 78 | {/* 总计 */} 79 |
80 | 共 {tags.length} 个标签 81 |
82 |
83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /worker/src/services/cache.ts: -------------------------------------------------------------------------------- 1 | // Cache TTL constants (seconds) 2 | export const CACHE_TTL = { 3 | IMAGES_LIST: 3600, // 1 hour (主动失效为主) 4 | IMAGE_DETAIL: 3600, // 1 hour 5 | TAGS_LIST: 3600, // 1 hour 6 | CONFIG: 86400, // 1 day 7 | } as const; 8 | 9 | // Cache key generators 10 | export const CacheKeys = { 11 | // Use empty string instead of 'all' to avoid collision with actual 'all' tag/orientation 12 | imagesList: (page: number, limit: number, tag?: string, orientation?: string, format?: string) => 13 | `images:list:${page}:${limit}:${tag ?? ''}:${orientation ?? ''}:${format ?? ''}`, 14 | 15 | imageDetail: (id: string) => `images:detail:${id}`, 16 | 17 | tagsList: () => 'tags:list', 18 | 19 | config: () => 'config', 20 | 21 | // Prefix for batch invalidation 22 | imagesListPrefix: () => 'images:list:', 23 | }; 24 | 25 | export class CacheService { 26 | constructor(private kv: KVNamespace) {} 27 | 28 | async get(key: string): Promise { 29 | try { 30 | const cached = await this.kv.get(key, 'json'); 31 | return cached as T | null; 32 | } catch { 33 | return null; 34 | } 35 | } 36 | 37 | async set(key: string, value: T, ttlSeconds: number): Promise { 38 | try { 39 | await this.kv.put(key, JSON.stringify(value), { 40 | expirationTtl: ttlSeconds, 41 | }); 42 | } catch (err) { 43 | console.error('Cache set error:', err); 44 | } 45 | } 46 | 47 | async delete(key: string): Promise { 48 | try { 49 | await this.kv.delete(key); 50 | } catch (err) { 51 | console.error('Cache delete error:', err); 52 | } 53 | } 54 | 55 | // Invalidate all keys with a given prefix 56 | async invalidateByPrefix(prefix: string): Promise { 57 | try { 58 | const list = await this.kv.list({ prefix }); 59 | const deletePromises = list.keys.map(k => this.kv.delete(k.name)); 60 | await Promise.all(deletePromises); 61 | } catch (err) { 62 | console.error('Cache invalidateByPrefix error:', err); 63 | } 64 | } 65 | 66 | // Convenience method: invalidate all image list caches 67 | async invalidateImagesList(): Promise { 68 | await this.invalidateByPrefix(CacheKeys.imagesListPrefix()); 69 | } 70 | 71 | // Convenience method: invalidate single image detail cache 72 | async invalidateImageDetail(id: string): Promise { 73 | await this.delete(CacheKeys.imageDetail(id)); 74 | } 75 | 76 | // Convenience method: invalidate tags list cache 77 | async invalidateTagsList(): Promise { 78 | await this.delete(CacheKeys.tagsList()); 79 | } 80 | 81 | // Convenience method: invalidate all related caches after image operations 82 | async invalidateAfterImageChange(imageId?: string): Promise { 83 | const promises: Promise[] = [this.invalidateImagesList()]; 84 | if (imageId) { 85 | promises.push(this.invalidateImageDetail(imageId)); 86 | } 87 | await Promise.all(promises); 88 | } 89 | 90 | // Convenience method: invalidate all related caches after tag operations 91 | async invalidateAfterTagChange(): Promise { 92 | await Promise.all([ 93 | this.invalidateTagsList(), 94 | this.invalidateImagesList(), // Tag changes may affect image list filtering 95 | ]); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | CattoPic is an image hosting service with a Next.js frontend (deployed on Vercel) and a Cloudflare Worker backend (Hono framework) using R2 for storage and D1 for metadata. 8 | 9 | **Important: This project uses pnpm as the package manager. Do not use npm or yarn.** 10 | 11 | ## Commands 12 | 13 | ### Frontend (Next.js) 14 | ```bash 15 | pnpm dev # Start dev server at localhost:3000 16 | pnpm build # Build for production 17 | pnpm lint # Run ESLint 18 | ``` 19 | 20 | ### Worker (Cloudflare) 21 | ```bash 22 | cd worker 23 | pnpm dev # Start local worker at localhost:8787 24 | pnpm deploy # Deploy to Cloudflare 25 | pnpm wrangler d1 execute CattoPic-D1 --remote --file=schema.sql # Init DB schema 26 | ``` 27 | 28 | ## Architecture 29 | 30 | ``` 31 | ├── app/ # Next.js 16 frontend (App Router) 32 | │ ├── components/ # React components 33 | │ ├── hooks/ # Custom React hooks 34 | │ ├── utils/ # Frontend utilities 35 | │ └── manage/ # Admin management page 36 | │ 37 | └── worker/ # Cloudflare Worker backend 38 | └── src/ 39 | ├── index.ts # Hono router, routes & middleware 40 | ├── handlers/ # API endpoint handlers 41 | │ ├── upload.ts # Image upload with compression 42 | │ ├── images.ts # CRUD operations 43 | │ ├── random.ts # Public random image API 44 | │ └── tags.ts # Tag management 45 | └── services/ 46 | ├── storage.ts # R2 storage operations 47 | ├── metadata.ts # D1 database queries 48 | ├── compression.ts # Cloudflare Images compression 49 | └── auth.ts # API key validation 50 | ``` 51 | 52 | ## Key Patterns 53 | 54 | ### Image Storage Structure 55 | Images are stored in R2 with orientation-based paths: 56 | - `original/{orientation}/{id}.{ext}` - Original file 57 | - `{orientation}/webp/{id}.webp` - WebP compressed 58 | - `{orientation}/avif/{id}.avif` - AVIF compressed 59 | 60 | GIF files are stored only as originals (no conversion). 61 | 62 | ### API Authentication 63 | Protected endpoints require `Authorization: Bearer ` header. Public endpoints: `/api/random`, `/r2/*`. 64 | 65 | ### Random Image API 66 | `GET /api/random` defaults to auto-orientation based on User-Agent (mobile→portrait, desktop→landscape). Supports `?orientation=landscape|portrait|auto`, `?tags=`, `?exclude=`, `?format=`. 67 | 68 | ## Environment Variables 69 | 70 | ### Frontend (.env.local) 71 | ``` 72 | NEXT_PUBLIC_WORKER_URL=http://localhost:8787 # or production Worker URL 73 | ``` 74 | 75 | ### Worker (wrangler.toml) 76 | Bindings: `R2_BUCKET` (R2), `DB` (D1), `IMAGES` (Cloudflare Images for compression) 77 | 78 | ## Changelog 79 | 80 | **Important: When making functional changes to the codebase, you MUST update the changelog files.** 81 | 82 | - `CHANGELOG.md` - English version 83 | - `CHANGELOG_CN.md` - Chinese version 84 | 85 | Follow [Keep a Changelog](https://keepachangelog.com/) format with these sections: 86 | - `Added` - New features 87 | - `Changed` - Changes to existing functionality 88 | - `Deprecated` - Features to be removed 89 | - `Removed` - Removed features 90 | - `Fixed` - Bug fixes 91 | - `Security` - Security fixes 92 | -------------------------------------------------------------------------------- /worker/src/handlers/favicon.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from 'hono'; 2 | 3 | // Inline SVG favicon 4 | const FAVICON_SVG = ` 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 15 | `; 16 | 17 | // 32x32 PNG favicon as base64 (for browsers that don't support SVG favicons) 18 | const FAVICON_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABmJLR0QA/wD/AP+gvaeTAAAEz0lEQVRYha2XTWxUVRSAv3Nnpv/TqZUfDZgQA1FXorEPcaEESQxqosTgxo2ttkgNEhIqFIlWEwICC1JFQjUUlF2JsJHIz4YoRh4KGhdoEEIUMaWmtB37Nz/3uJi+mTczb4ZWPZPJe/Puued859xzz3kjlJGWloFoKFS5QoR7RTVhCV0SSZzv6WkcLrduJiLBj1VaW+MbBH1bIAaSnSigovwCuIq4ItZNSf0PPT2S/N8AWluH9wm8JlNTxJuo+EH810mBi1hxwbpicbsPxi7/K4DW1qEXDHKkwOsi4+XGptYOCuqCuCGsO2mN+0FvdOC2AGtah8+BONPwOgOiRYZLgakgLtj3dx+oPxoI0Nwcn10Z1n5A/AoCAYK9nhY0lr27DtatA1HjB6iKWEdARDOeBRovGJPsJ9944NdbK7z+ZsvoeoA8ABTntgp8QH7DM4ZW3ulqv1kX9tsXFacwXOXCTcH4dLZKshpoGB+vfdIHoCLEiwDCIXh2VSWPPBqhPlaibBTIyLBy4ZskJ48mSKdKQ4dUF2YB3lgzsjCdlsZCr595vpLlT1VMy7An9TFh2coKUPjySLJkRIzRsSyAWoqOngBNSyMAdG8f59qVdMkM969bsCjE2s5qHn4swokjydwczV+HyOUcgJololq0117Yr/2aLn+0FNo7qzEGPto2kYlEg5Q7lhq2ye9yOaCai4BvvyheWDKk3lP/70Kvs1fh567eO4bCAF2rtWKQ+OKgghIEADBrjkEt3BqwWej92yfy5vjXBkC7AGGAWw0jD4o1leU88+6j9ULL+mrmL8iUkD9/t3x+aJLrVzMgtbXC0hWR7LraOmH8by3Sa6y64BUia5aUKyieROuFto0Z4yNDyvCgcvc9hrVbqlm5uoKGRuHVzVUsfy4H8MrmKurqpUhvWsy5LICQ2f9SHQ8gGs0Yv2ueof8PS3fXOLs7xzhzPJPlj6+MsHFnDXOnxj2ZO8/Q3DEFQbZ0T4TGan7KAag4ElCC/dLWkTP+ya4JRkeUdBJO9CXo2TbOwA2LmSrsv13OAdy8bpkz3/DShip8+XWxq08SAGZT22BMYFGp2u9JoXF/SK9fteztGuer40mshaZlucN1aOcEN65Z0kl/vcjsP0A4kapoMqjxwg7kNZ34sBKNCX/154wH1f50Ek71Jbj0fYoX26touFOIDymjI/Bx10ReM1PhfBbAoA+Uax4XzqZ44ukIs+Ya3tpTw0zkx7OpoiqZ+Ya/9eYYI1IZlP1elz91LMGZ40lGhjTIRqDEh5Svv0hy5mgy6FQNbj1UeTUbAVV7RdTfqfK7lk3Cyb4EJ/sSJWu/92xabVpxxZddxjB5WmAo57XPSJmXk3zj+W9FpV5Opu6zCQhgdh6YHTfIe0EKCFAQvFUzgNYCAIAdvbV7BNlXrDzYa3+OzBBaJWKLAUB0e29tO+gLwDkBLawH+SH1eT0zaHdLwX+DwoIHQGdzfLZY44SMdbDiCDgCjaWSzO9tqTEQjOqqTYdrj90WIEi2vjyxKKIpR8ERFUfgISC/gwZ47UGr8GHnpzXrCvVOG6BQ9rdppD8xtljSOIBjRBxU75MpnT7jt4zybsfh6m7JK+7/ESBIdqwejGl1RRPW3C+GCtArIa053fGZjJZa8w8h1uV7/5hnFAAAAABJRU5ErkJggg=='; 19 | 20 | export const faviconHandler = (c: Context) => { 21 | const path = c.req.path.toLowerCase(); 22 | 23 | // Return SVG for .svg requests 24 | if (path.endsWith('.svg')) { 25 | return new Response(FAVICON_SVG, { 26 | headers: { 27 | 'Content-Type': 'image/svg+xml', 28 | 'Cache-Control': 'public, max-age=31536000, immutable', 29 | 'Access-Control-Allow-Origin': '*', 30 | }, 31 | }); 32 | } 33 | 34 | // Return PNG for .ico requests (browsers accept PNG as favicon) 35 | const pngBuffer = Uint8Array.from(atob(FAVICON_PNG_BASE64), c => c.charCodeAt(0)); 36 | return new Response(pngBuffer, { 37 | headers: { 38 | 'Content-Type': 'image/png', 39 | 'Cache-Control': 'public, max-age=31536000, immutable', 40 | 'Access-Control-Allow-Origin': '*', 41 | }, 42 | }); 43 | }; -------------------------------------------------------------------------------- /app/components/upload/ZipPreview.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ZipAnalysisResult } from '../../utils/zipProcessor' 4 | import { ImageIcon, ExclamationTriangleIcon, CheckIcon } from '../ui/icons' 5 | 6 | interface ZipPreviewProps { 7 | analysis: ZipAnalysisResult 8 | zipFileName: string 9 | onConfirm: () => void 10 | onCancel: () => void 11 | isProcessing?: boolean 12 | } 13 | 14 | export default function ZipPreview({ 15 | analysis, 16 | zipFileName, 17 | onConfirm, 18 | onCancel, 19 | isProcessing = false, 20 | }: ZipPreviewProps) { 21 | const hasSkippedFiles = analysis.skippedFiles.length > 0 22 | 23 | return ( 24 |
25 |
26 |
27 | 28 |
29 |
30 |

ZIP 文件分析完成

31 |

32 | {zipFileName} 33 |

34 |
35 |
36 | 37 |
38 |
39 |
40 | 41 | 42 | 可上传图片 43 | 44 |
45 |

46 | {analysis.totalImages.toLocaleString()} 张 47 |

48 |
49 | 50 | {hasSkippedFiles && ( 51 |
52 |
53 | 54 | 55 | 跳过文件 56 | 57 |
58 |

59 | {analysis.skippedFiles.length.toLocaleString()} 60 |

61 |

62 | 非图片或超过大小限制 63 |

64 |
65 | )} 66 |
67 | 68 | {analysis.totalImages > 100 && ( 69 |
70 |

71 | 提示: 72 | 大量图片上传可能需要较长时间,请保持页面打开。 73 | {analysis.totalImages > 500 && ' 预计需要30分钟以上。'} 74 |

75 |
76 | )} 77 | 78 |
79 | 86 | 93 |
94 |
95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /worker/src/utils/validation.ts: -------------------------------------------------------------------------------- 1 | // Validation Utilities 2 | 3 | export function isValidUUID(str: string): boolean { 4 | // Support both standard UUID and image ID format (YYYYMMDD-XXXXXXXX) 5 | const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; 6 | const imageIdRegex = /^\d{8}-[0-9a-f]{8}$/i; 7 | return uuidRegex.test(str) || imageIdRegex.test(str); 8 | } 9 | 10 | export function generateUUID(): string { 11 | return crypto.randomUUID(); 12 | } 13 | 14 | // Generate readable image ID: YYYYMMDD-XXXXXXXX (date + 8 random chars) 15 | export function generateImageId(): string { 16 | const now = new Date(); 17 | const date = now.toISOString().slice(0, 10).replace(/-/g, ''); // YYYYMMDD 18 | const random = crypto.randomUUID().slice(0, 8); // 8位随机字符 19 | return `${date}-${random}`; 20 | } 21 | 22 | export function sanitizeTagName(tag: string): string { 23 | return tag 24 | .toLowerCase() 25 | .trim() 26 | .replace(/[^a-z0-9\u4e00-\u9fa5_-]/g, '') // Allow alphanumeric, Chinese, hyphens, underscores 27 | .substring(0, 50); 28 | } 29 | 30 | export function parseTags(tagsString: string | null): string[] { 31 | if (!tagsString) return []; 32 | return tagsString 33 | .split(',') 34 | .map(t => sanitizeTagName(t)) 35 | .filter(t => t.length > 0); 36 | } 37 | 38 | export function parseNumber(value: string | null, defaultValue: number): number { 39 | if (!value) return defaultValue; 40 | const num = parseInt(value, 10); 41 | return isNaN(num) ? defaultValue : num; 42 | } 43 | 44 | export function parseBoolean(value: string | null): boolean { 45 | return value === 'true' || value === '1'; 46 | } 47 | 48 | export function validateOrientation(value: string | null): 'landscape' | 'portrait' | undefined { 49 | if (value === 'landscape' || value === 'portrait') { 50 | return value; 51 | } 52 | return undefined; 53 | } 54 | 55 | export function validateFormat(value: string | null): 'original' | 'webp' | 'avif' | undefined { 56 | if (value === 'original' || value === 'webp' || value === 'avif') { 57 | return value; 58 | } 59 | return undefined; 60 | } 61 | 62 | export function validateImageListFormat( 63 | value: string | null 64 | ): 'all' | 'gif' | 'webp' | 'avif' | 'original' | undefined { 65 | if (!value) return undefined; 66 | const normalized = value.toLowerCase(); 67 | if ( 68 | normalized === 'all' 69 | || normalized === 'gif' 70 | || normalized === 'webp' 71 | || normalized === 'avif' 72 | || normalized === 'original' 73 | ) { 74 | return normalized; 75 | } 76 | return undefined; 77 | } 78 | 79 | // Detect if request is from mobile device 80 | export function isMobileDevice(userAgent: string | null | undefined): boolean { 81 | if (!userAgent) return false; 82 | const mobileKeywords = [ 83 | 'mobile', 'android', 'iphone', 'ipad', 'ipod', 'blackberry', 84 | 'windows phone', 'opera mini', 'opera mobi' 85 | ]; 86 | const ua = userAgent.toLowerCase(); 87 | return mobileKeywords.some(keyword => ua.includes(keyword)); 88 | } 89 | 90 | // Get best format based on Accept header 91 | export function getBestFormat(acceptHeader: string | null | undefined): 'avif' | 'webp' | 'original' { 92 | if (!acceptHeader) return 'original'; 93 | 94 | if (acceptHeader.includes('image/avif')) { 95 | return 'avif'; 96 | } 97 | if (acceptHeader.includes('image/webp')) { 98 | return 'webp'; 99 | } 100 | return 'original'; 101 | } 102 | -------------------------------------------------------------------------------- /app/utils/request.ts: -------------------------------------------------------------------------------- 1 | import { getApiKey } from "./auth"; 2 | 3 | interface RequestOptions extends RequestInit { 4 | params?: Record; 5 | } 6 | 7 | interface ConfigResponse { 8 | apiUrl: string; 9 | remotePatterns: string; 10 | } 11 | 12 | let BASE_URL = process.env.NEXT_PUBLIC_API_URL || ""; 13 | let initPromise: Promise | null = null; 14 | 15 | async function initializeBaseUrl() { 16 | try { 17 | const response = await fetch("/api/config"); 18 | const config: ConfigResponse = await response.json(); 19 | if (config.apiUrl) { 20 | BASE_URL = config.apiUrl; 21 | } 22 | } catch (error) { 23 | console.error("Failed to fetch API config:", error); 24 | } 25 | } 26 | 27 | // Ensure initialization only runs once, even with concurrent requests 28 | async function ensureInitialized() { 29 | if (!initPromise) { 30 | initPromise = initializeBaseUrl(); 31 | } 32 | await initPromise; 33 | } 34 | 35 | export async function request( 36 | endpoint: string, 37 | options: RequestOptions = {} 38 | ): Promise { 39 | await ensureInitialized(); 40 | 41 | const apiKey = getApiKey(); 42 | 43 | const { params, ...restOptions } = options; 44 | 45 | // 构建URL 46 | const url: URL = new URL(endpoint, BASE_URL || window.location.origin); 47 | if (params) { 48 | for (const [key, value] of Object.entries(params)) { 49 | url.searchParams.append(key, value); 50 | } 51 | } 52 | 53 | // 添加认证头 54 | const headers = { 55 | ...options.headers, 56 | ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), 57 | }; 58 | 59 | const response = await fetch(url.toString(), { 60 | ...restOptions, 61 | headers, 62 | }); 63 | 64 | if (!response.ok) { 65 | let errorMessage = "请求失败"; 66 | try { 67 | const error = await response.json(); 68 | errorMessage = error.message || error.error || errorMessage; 69 | } catch { 70 | // Response is not JSON, try to get text 71 | try { 72 | const text = await response.text(); 73 | if (text) errorMessage = text; 74 | } catch { 75 | // Cannot parse response body 76 | } 77 | } 78 | throw new Error(errorMessage); 79 | } 80 | 81 | try { 82 | return await response.json(); 83 | } catch { 84 | throw new Error("响应数据格式无效"); 85 | } 86 | } 87 | 88 | // 封装常用请求方法 89 | export const api = { 90 | request, 91 | get: (endpoint: string, params?: Record) => 92 | request(endpoint, { method: "GET", params }), 93 | 94 | post: (endpoint: string, data?: Record) => 95 | request(endpoint, { 96 | method: "POST", 97 | headers: { 98 | "Content-Type": "application/json", 99 | }, 100 | body: JSON.stringify(data), 101 | }), 102 | 103 | put: (endpoint: string, data?: Record) => 104 | request(endpoint, { 105 | method: "PUT", 106 | headers: { 107 | "Content-Type": "application/json", 108 | }, 109 | body: JSON.stringify(data), 110 | }), 111 | 112 | delete: (endpoint: string) => request(endpoint, { method: "DELETE" }), 113 | 114 | upload: (endpoint: string, files: File[]) => { 115 | const formData = new FormData(); 116 | for (const file of files) { 117 | formData.append("images[]", file); 118 | } 119 | return request(endpoint, { 120 | method: "POST", 121 | body: formData, 122 | }); 123 | }, 124 | }; 125 | -------------------------------------------------------------------------------- /worker/src/handlers/random.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from 'hono'; 2 | import type { Env } from '../types'; 3 | import { MetadataService } from '../services/metadata'; 4 | import { errorResponse } from '../utils/response'; 5 | import { parseTags, isMobileDevice, getBestFormat } from '../utils/validation'; 6 | import { buildImageUrls } from '../utils/imageTransform'; 7 | 8 | // GET /api/random - Get random image (PUBLIC - no auth required) 9 | export async function randomHandler(c: Context<{ Bindings: Env }>): Promise { 10 | try { 11 | const url = new URL(c.req.url); 12 | 13 | // Parse query parameters 14 | const tagsParam = url.searchParams.get('tags'); 15 | const excludeParam = url.searchParams.get('exclude'); 16 | const orientationParam = url.searchParams.get('orientation'); 17 | const formatParam = url.searchParams.get('format'); 18 | 19 | const tags = parseTags(tagsParam); 20 | const exclude = parseTags(excludeParam); 21 | 22 | // Determine orientation (default to auto-detect based on device) 23 | let orientation: string | undefined; 24 | if (orientationParam === 'landscape' || orientationParam === 'portrait') { 25 | orientation = orientationParam; 26 | } else { 27 | // Default: auto-detect based on user agent 28 | const userAgent = c.req.header('User-Agent'); 29 | orientation = isMobileDevice(userAgent) ? 'portrait' : 'landscape'; 30 | } 31 | 32 | // Get random image metadata 33 | const metadata = new MetadataService(c.env.DB); 34 | const image = await metadata.getRandomImage({ 35 | tags: tags.length > 0 ? tags : undefined, 36 | exclude: exclude.length > 0 ? exclude : undefined, 37 | orientation 38 | }); 39 | 40 | if (!image) { 41 | return errorResponse('No images found matching criteria', 404); 42 | } 43 | 44 | const baseUrl = c.env.R2_PUBLIC_URL; 45 | const urls = buildImageUrls({ 46 | baseUrl, 47 | image, 48 | options: { 49 | generateWebp: !!image.paths.webp, 50 | generateAvif: !!image.paths.avif, 51 | }, 52 | }); 53 | 54 | let targetUrl: string; 55 | 56 | if (image.format === 'gif') { 57 | // Always serve original for GIF 58 | targetUrl = urls.original; 59 | } else { 60 | // Determine best format based on Accept header or explicit format param 61 | if (formatParam === 'original') { 62 | targetUrl = urls.original; 63 | } else if (formatParam === 'webp') { 64 | targetUrl = urls.webp || urls.original; 65 | } else if (formatParam === 'avif') { 66 | targetUrl = urls.avif || urls.original; 67 | } else { 68 | const acceptHeader = c.req.header('Accept'); 69 | const best = getBestFormat(acceptHeader); 70 | if (best === 'avif' && urls.avif) { 71 | targetUrl = urls.avif; 72 | } else if (best === 'webp' && urls.webp) { 73 | targetUrl = urls.webp; 74 | } else { 75 | targetUrl = urls.original; 76 | } 77 | } 78 | } 79 | 80 | if (!targetUrl) { 81 | return errorResponse('Image file not found', 404); 82 | } 83 | 84 | // Redirect instead of proxying: avoids fetching transformed URLs from within the Worker. 85 | return new Response(null, { 86 | status: 302, 87 | headers: { 88 | Location: targetUrl, 89 | 'Cache-Control': 'no-cache, no-store, must-revalidate', 90 | 'Access-Control-Allow-Origin': '*', 91 | }, 92 | }); 93 | 94 | } catch (err) { 95 | console.error('Random handler error:', err); 96 | return errorResponse('Failed to get random image'); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /app/components/upload/UploadStatusIndicator.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { motion } from 'motion/react' 4 | import { FileUploadStatus } from '../../types/upload' 5 | import { 6 | ImageIcon, 7 | UploadIcon, 8 | CheckIcon, 9 | Cross1Icon, 10 | Spinner, 11 | } from '../ui/icons' 12 | 13 | interface UploadStatusIndicatorProps { 14 | status: FileUploadStatus 15 | size?: 'sm' | 'md' | 'lg' 16 | } 17 | 18 | const sizeClasses = { 19 | sm: 'w-6 h-6', 20 | md: 'w-8 h-8', 21 | lg: 'w-10 h-10', 22 | } 23 | 24 | export default function UploadStatusIndicator({ 25 | status, 26 | size = 'md', 27 | }: UploadStatusIndicatorProps) { 28 | const sizeClass = sizeClasses[size] 29 | 30 | switch (status) { 31 | case 'pending': 32 | return ( 33 |
34 | 35 |
36 | ) 37 | 38 | case 'uploading': 39 | return ( 40 | 52 | 53 | 54 | ) 55 | 56 | case 'processing': 57 | return ( 58 |
59 | 60 |
61 | ) 62 | 63 | case 'success': 64 | return ( 65 | 75 | 76 | 77 | ) 78 | 79 | case 'error': 80 | return ( 81 | 90 | 91 | 92 | ) 93 | 94 | default: 95 | return null 96 | } 97 | } 98 | 99 | // 状态文字 100 | export function getStatusText(status: FileUploadStatus): string { 101 | switch (status) { 102 | case 'pending': 103 | return '等待上传' 104 | case 'uploading': 105 | return '上传中...' 106 | case 'processing': 107 | return '处理中...' 108 | case 'success': 109 | return '已完成' 110 | case 'error': 111 | return '上传失败' 112 | default: 113 | return '' 114 | } 115 | } 116 | 117 | // 状态颜色类 118 | export function getStatusColorClass(status: FileUploadStatus): string { 119 | switch (status) { 120 | case 'pending': 121 | return 'text-slate-500' 122 | case 'uploading': 123 | return 'text-indigo-500' 124 | case 'processing': 125 | return 'text-amber-500' 126 | case 'success': 127 | return 'text-green-500' 128 | case 'error': 129 | return 'text-red-500' 130 | default: 131 | return 'text-slate-500' 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /worker/src/handlers/system.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from 'hono'; 2 | import type { Env, Config } from '../types'; 3 | import { StorageService } from '../services/storage'; 4 | import { MetadataService } from '../services/metadata'; 5 | import { CacheService, CacheKeys, CACHE_TTL } from '../services/cache'; 6 | import { successResponse, errorResponse } from '../utils/response'; 7 | 8 | // Default configuration 9 | const DEFAULT_CONFIG: Config = { 10 | maxUploadCount: 50, 11 | maxFileSize: 70 * 1024 * 1024, // 70MB 12 | supportedFormats: ['jpeg', 'jpg', 'png', 'gif', 'webp', 'avif'], 13 | imageQuality: 80 14 | }; 15 | 16 | // POST /api/validate-api-key - Validate API key 17 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 18 | export async function validateApiKeyHandler(_c: Context<{ Bindings: Env }>): Promise { 19 | // If we reach here, the API key is already validated by middleware 20 | return successResponse({ valid: true }); 21 | } 22 | 23 | // GET /api/config - Get configuration 24 | export async function configHandler(c: Context<{ Bindings: Env }>): Promise { 25 | try { 26 | const cache = new CacheService(c.env.CACHE_KV); 27 | const cacheKey = CacheKeys.config(); 28 | 29 | // Try to get from cache 30 | const cached = await cache.get<{ config: Config }>(cacheKey); 31 | if (cached) { 32 | return successResponse(cached); 33 | } 34 | 35 | // Try to get custom config from D1 36 | const configResult = await c.env.DB.prepare(` 37 | SELECT key, value FROM config 38 | `).all<{ key: string; value: string }>(); 39 | 40 | let responseData: { config: Config }; 41 | 42 | if (configResult.results && configResult.results.length > 0) { 43 | const config: Record = { ...DEFAULT_CONFIG }; 44 | for (const row of configResult.results) { 45 | try { 46 | config[row.key] = JSON.parse(row.value); 47 | } catch { 48 | config[row.key] = row.value; 49 | } 50 | } 51 | responseData = { config: config as unknown as Config }; 52 | } else { 53 | responseData = { config: DEFAULT_CONFIG }; 54 | } 55 | 56 | // Store in cache 57 | await cache.set(cacheKey, responseData, CACHE_TTL.CONFIG); 58 | 59 | return successResponse(responseData); 60 | 61 | } catch (err) { 62 | console.error('Config handler error:', err); 63 | return successResponse({ config: DEFAULT_CONFIG }); 64 | } 65 | } 66 | 67 | // POST /api/cleanup - Clean up expired images 68 | export async function cleanupHandler(c: Context<{ Bindings: Env }>): Promise { 69 | try { 70 | const metadata = new MetadataService(c.env.DB); 71 | const storage = new StorageService(c.env.R2_BUCKET); 72 | 73 | // Get expired images 74 | const expiredImages = await metadata.getExpiredImages(); 75 | 76 | let deletedCount = 0; 77 | 78 | for (const image of expiredImages) { 79 | try { 80 | // Delete files from R2 81 | const keysToDelete = [image.paths.original]; 82 | if (image.paths.webp) keysToDelete.push(image.paths.webp); 83 | if (image.paths.avif) keysToDelete.push(image.paths.avif); 84 | 85 | await storage.deleteMany(keysToDelete); 86 | 87 | // Delete metadata 88 | await metadata.deleteImage(image.id); 89 | 90 | deletedCount++; 91 | } catch (err) { 92 | console.error('Failed to delete expired image:', image.id, err); 93 | } 94 | } 95 | 96 | return successResponse({ deletedCount }); 97 | 98 | } catch (err) { 99 | console.error('Cleanup handler error:', err); 100 | return errorResponse('Cleanup failed'); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /app/components/upload/ImageSidebar.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { FileIcon, Cross1Icon, TrashIcon, PlusIcon } from '../ui/icons' 4 | 5 | interface ImageFile { 6 | id: string 7 | file: File 8 | } 9 | 10 | interface ImageSidebarProps { 11 | files: ImageFile[] 12 | onRemoveFile: (id: string) => void 13 | onRemoveAll: () => void 14 | isOpen: boolean 15 | onClose: () => void 16 | } 17 | 18 | // 将文件大小转换为可读格式 19 | const formatFileSize = (bytes: number): string => { 20 | if (bytes === 0) return '0 B' 21 | const k = 1024 22 | const sizes = ['B', 'KB', 'MB', 'GB'] 23 | const i = Math.floor(Math.log(bytes) / Math.log(k)) 24 | return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] 25 | } 26 | 27 | export default function ImageSidebar({ files, onRemoveFile, onRemoveAll, isOpen, onClose }: ImageSidebarProps) { 28 | if (!isOpen) return null 29 | 30 | return ( 31 |
32 | {/* 背景遮罩 */} 33 |
37 | 38 | {/* 侧边栏主体 */} 39 |
40 |
41 |

42 | 43 | 已选择文件 ({files.length}) 44 |

45 | 51 |
52 | 53 |
54 | {files.length > 0 ? ( 55 |
56 | {files.map(file => ( 57 |
58 |
59 |
{file.file.name}
60 |
{formatFileSize(file.file.size)}
61 |
62 | 68 |
69 | ))} 70 |
71 | ) : ( 72 |
73 | 74 |

没有选择文件

75 |
76 | )} 77 |
78 | 79 | {files.length > 0 && ( 80 |
81 | 88 |
89 | )} 90 |
91 |
92 | ) 93 | } -------------------------------------------------------------------------------- /app/components/upload/UploadDropzone.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useRef, useEffect, useState } from 'react' 4 | import { UploadIcon } from '../ui/icons' 5 | 6 | interface UploadDropzoneProps { 7 | onFilesSelected: (files: File[]) => void 8 | maxUploadCount: number 9 | } 10 | 11 | export default function UploadDropzone({ onFilesSelected, maxUploadCount }: UploadDropzoneProps) { 12 | const fileInputRef = useRef(null) 13 | const [isPasteActive, setIsPasteActive] = useState(false) 14 | 15 | // 监听粘贴事件 16 | useEffect(() => { 17 | const handlePaste = (e: ClipboardEvent) => { 18 | const items = e.clipboardData?.items 19 | if (!items) return 20 | 21 | const imageFiles: File[] = [] 22 | 23 | for (let i = 0; i < items.length; i++) { 24 | const item = items[i] 25 | if (item.type.startsWith('image/')) { 26 | const file = item.getAsFile() 27 | if (file) { 28 | // 为粘贴的图片生成一个有意义的文件名 29 | const extension = file.type.split('/')[1] || 'png' 30 | const timestamp = new Date().toISOString().replace(/[:.]/g, '-') 31 | const newFile = new File([file], `pasted-image-${timestamp}.${extension}`, { 32 | type: file.type, 33 | }) 34 | imageFiles.push(newFile) 35 | } 36 | } 37 | } 38 | 39 | if (imageFiles.length > 0) { 40 | e.preventDefault() 41 | onFilesSelected(imageFiles) 42 | 43 | // 显示粘贴成功的视觉反馈 44 | setIsPasteActive(true) 45 | setTimeout(() => setIsPasteActive(false), 500) 46 | } 47 | } 48 | 49 | // 添加全局粘贴监听 50 | document.addEventListener('paste', handlePaste) 51 | 52 | return () => { 53 | document.removeEventListener('paste', handlePaste) 54 | } 55 | }, [onFilesSelected]) 56 | 57 | const handleFileSelect = (e: React.ChangeEvent) => { 58 | const files = e.target.files 59 | if (files) { 60 | onFilesSelected(Array.from(files)) 61 | } 62 | } 63 | 64 | const handleDrop = (e: React.DragEvent) => { 65 | e.preventDefault() 66 | onFilesSelected(Array.from(e.dataTransfer.files)) 67 | } 68 | 69 | const handleDragOver = (e: React.DragEvent) => { 70 | e.preventDefault() 71 | e.currentTarget.classList.add('active') 72 | } 73 | 74 | const handleDragLeave = (e: React.DragEvent) => { 75 | e.preventDefault() 76 | e.currentTarget.classList.remove('active') 77 | } 78 | 79 | return ( 80 |
88 |
89 | 90 |
91 |

拖放多张图片到这里

92 |

点击选择文件或 Ctrl+V 粘贴图片

93 |

最多可选择 {maxUploadCount} 张图片

94 | 102 | 109 |
110 | ) 111 | } -------------------------------------------------------------------------------- /app/utils/imageQueue.ts: -------------------------------------------------------------------------------- 1 | class ImageQueue { 2 | private queue: string[] = []; 3 | private loading: Set = new Set(); 4 | private maxConcurrent: number = 10; 5 | private preloadCache: Map = new Map(); 6 | private maxCacheSize: number = 50; 7 | private lowPriorityQueue: string[] = []; 8 | 9 | constructor(maxConcurrent: number = 10, maxCacheSize: number = 50) { 10 | this.maxConcurrent = maxConcurrent; 11 | this.maxCacheSize = maxCacheSize; 12 | } 13 | 14 | add(imageUrl: string, highPriority: boolean = false) { 15 | if (this.preloadCache.has(imageUrl) || this.loading.has(imageUrl)) { 16 | return; 17 | } 18 | 19 | if (highPriority) { 20 | if (!this.queue.includes(imageUrl)) { 21 | this.queue.unshift(imageUrl); 22 | } 23 | } else { 24 | if (!this.lowPriorityQueue.includes(imageUrl)) { 25 | this.lowPriorityQueue.push(imageUrl); 26 | } 27 | } 28 | 29 | this.processQueue(); 30 | } 31 | 32 | private async processQueue() { 33 | if (this.loading.size >= this.maxConcurrent) { 34 | return; 35 | } 36 | 37 | // Process high priority queue first 38 | let url = this.queue.shift(); 39 | if (!url && this.lowPriorityQueue.length > 0) { 40 | url = this.lowPriorityQueue.shift(); 41 | } 42 | 43 | if (!url) return; 44 | 45 | this.loading.add(url); 46 | 47 | try { 48 | const img = await this.preloadImage(url); 49 | this.addToCache(url, img); 50 | } catch (error) { 51 | console.error(`Failed to preload image: ${url}`, error); 52 | } finally { 53 | this.loading.delete(url); 54 | this.processQueue(); 55 | } 56 | } 57 | 58 | private preloadImage(url: string): Promise { 59 | return new Promise((resolve, reject) => { 60 | const img = new Image(); 61 | 62 | // Add timeout to prevent hanging 63 | const timeout = setTimeout(() => { 64 | reject(new Error('Image load timeout')); 65 | }, 20000); 66 | 67 | img.onload = () => { 68 | clearTimeout(timeout); 69 | resolve(img); 70 | }; 71 | 72 | img.onerror = () => { 73 | clearTimeout(timeout); 74 | reject(new Error('Image load failed')); 75 | }; 76 | 77 | // Enable browser caching 78 | img.setAttribute('crossOrigin', 'anonymous'); 79 | img.src = url; 80 | }); 81 | } 82 | 83 | private addToCache(url: string, img: HTMLImageElement) { 84 | // Implement LRU cache 85 | if (this.preloadCache.size >= this.maxCacheSize) { 86 | const oldestEntry = Array.from(this.preloadCache.entries())[0]; 87 | if (oldestEntry) { 88 | this.preloadCache.delete(oldestEntry[0]); 89 | } 90 | } 91 | this.preloadCache.set(url, img); 92 | } 93 | 94 | getPreloadedImage(url: string): HTMLImageElement | undefined { 95 | const img = this.preloadCache.get(url); 96 | if (img) { 97 | img.dataset.lastUsed = Date.now().toString(); 98 | } 99 | return img; 100 | } 101 | 102 | isPreloaded(url: string): boolean { 103 | return this.preloadCache.has(url); 104 | } 105 | 106 | clear() { 107 | this.queue = []; 108 | this.lowPriorityQueue = []; 109 | this.loading.clear(); 110 | this.preloadCache.clear(); 111 | } 112 | 113 | // 清理长时间未使用的缓存 114 | cleanupUnusedCache(maxAgeMs: number = 5 * 60 * 1000) { 115 | const now = Date.now(); 116 | for (const [url, img] of Array.from(this.preloadCache.entries())) { 117 | const lastUsed = parseInt(img.dataset.lastUsed || '0'); 118 | if (now - lastUsed > maxAgeMs) { 119 | this.preloadCache.delete(url); 120 | } 121 | } 122 | } 123 | } 124 | 125 | export const imageQueue = new ImageQueue(10, 50); -------------------------------------------------------------------------------- /app/components/ImageInfo.tsx: -------------------------------------------------------------------------------- 1 | import { ImageFile } from "../types"; 2 | import { ImageData } from "../types/image"; 3 | import { getFormatLabel, getOrientationLabel, formatFileSize } from "../utils/imageUtils"; 4 | 5 | type ImageType = ImageFile | (ImageData & { status: 'success' }); 6 | 7 | interface ImageInfoProps { 8 | image: ImageType; 9 | } 10 | 11 | export const ImageInfo = ({ image }: ImageInfoProps) => { 12 | // 判断图片类型 13 | const isImageFile = 'urls' in image && 'sizes' in image; 14 | 15 | // 获取展示信息 16 | const format = (image.format || '').toLowerCase(); 17 | const orientation = image.orientation || ''; 18 | const size = isImageFile ? (image as ImageFile).sizes?.original || 0 : 0; 19 | const width = 'width' in image ? image.width : undefined; 20 | const height = 'height' in image ? image.height : undefined; 21 | const expiryTime = 'expiryTime' in image ? image.expiryTime : undefined; 22 | 23 | // 构建紧凑的标签数据 24 | const tags = [ 25 | format && { 26 | label: getFormatLabel(format), 27 | bg: 'bg-blue-100 dark:bg-blue-900/40', 28 | text: 'text-blue-700 dark:text-blue-300', 29 | border: 'border-blue-200 dark:border-blue-800' 30 | }, 31 | orientation && { 32 | label: getOrientationLabel(orientation), 33 | bg: 'bg-violet-100 dark:bg-violet-900/40', 34 | text: 'text-violet-700 dark:text-violet-300', 35 | border: 'border-violet-200 dark:border-violet-800' 36 | }, 37 | isImageFile && size > 0 && { 38 | label: formatFileSize(size), 39 | bg: 'bg-emerald-100 dark:bg-emerald-900/40', 40 | text: 'text-emerald-700 dark:text-emerald-300', 41 | border: 'border-emerald-200 dark:border-emerald-800' 42 | }, 43 | width && height && { 44 | label: `${width} × ${height}`, 45 | bg: 'bg-amber-100 dark:bg-amber-900/40', 46 | text: 'text-amber-700 dark:text-amber-300', 47 | border: 'border-amber-200 dark:border-amber-800' 48 | }, 49 | ].filter(Boolean) as Array<{ label: string; bg: string; text: string; border: string }>; 50 | 51 | return ( 52 |
53 | {/* 紧凑标签行 */} 54 | {tags.length > 0 && ( 55 |
56 | {tags.map((tag, index) => ( 57 | 61 | {tag.label} 62 | 63 | ))} 64 |
65 | )} 66 | 67 | {/* 过期时间单独显示(如果有) */} 68 | {expiryTime && ( 69 |
70 |
71 | 72 | 73 | 74 |
75 |
76 | 77 | 过期时间 78 | 79 | · 80 | 81 | {new Date(expiryTime).toLocaleString('zh-CN', { 82 | month: 'short', 83 | day: 'numeric', 84 | hour: '2-digit', 85 | minute: '2-digit' 86 | })} 87 | 88 |
89 |
90 | )} 91 |
92 | ); 93 | }; 94 | -------------------------------------------------------------------------------- /app/components/TagManagement/TagDeleteConfirm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { motion, AnimatePresence } from 'motion/react'; 4 | import { TrashIcon, Spinner, ExclamationTriangleIcon } from '../ui/icons'; 5 | 6 | interface TagDeleteConfirmProps { 7 | isOpen: boolean; 8 | tagName: string; 9 | tagCount?: number; 10 | isBatch?: boolean; 11 | isProcessing: boolean; 12 | onCancel: () => void; 13 | onConfirm: () => void; 14 | } 15 | 16 | export default function TagDeleteConfirm({ 17 | isOpen, 18 | tagName, 19 | tagCount = 0, 20 | isBatch = false, 21 | isProcessing, 22 | onCancel, 23 | onConfirm, 24 | }: TagDeleteConfirmProps) { 25 | return ( 26 | 27 | {isOpen && ( 28 | 35 | e.stopPropagation()} 42 | > 43 | {/* 警告图标 */} 44 |
45 |
46 | 47 |
48 |
49 | 50 | {/* 标题和描述 */} 51 |
52 |

53 | 确认删除 54 |

55 |

56 | {isBatch ? ( 57 | <>确定要删除选中的 {tagName} 吗? 58 | ) : ( 59 | <> 60 | 确定要删除标签 "{tagName}" 吗? 61 | {tagCount > 0 && ( 62 | 63 | 将同时删除 {tagCount} 张关联图片! 64 | 65 | )} 66 | 67 | )} 68 |

69 |

70 | 此操作不可撤销,图片和文件将被永久删除 71 |

72 |
73 | 74 | {/* 按钮 */} 75 |
76 | 83 | 90 | {isProcessing ? ( 91 | <> 92 | 93 | 删除中... 94 | 95 | ) : ( 96 | <> 97 | 98 | 确认删除 99 | 100 | )} 101 | 102 |
103 |
104 |
105 | )} 106 |
107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /app/utils/zipProcessor.ts: -------------------------------------------------------------------------------- 1 | import JSZip from 'jszip' 2 | 3 | // 支持的图片格式 4 | const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif'] 5 | 6 | export interface ZipImageEntry { 7 | path: string 8 | name: string 9 | size: number 10 | } 11 | 12 | export interface ZipAnalysisResult { 13 | totalImages: number 14 | totalSize: number 15 | images: ZipImageEntry[] 16 | skippedFiles: { 17 | path: string 18 | reason: 'not_image' | 'too_large' | 'directory' 19 | }[] 20 | } 21 | 22 | export interface ExtractedImage { 23 | id: string 24 | file: File 25 | originalPath: string 26 | } 27 | 28 | export interface ExtractionProgress { 29 | current: number 30 | total: number 31 | currentFileName: string 32 | } 33 | 34 | /** 35 | * 检查文件是否是支持的图片格式 36 | */ 37 | function isImageFile(filename: string): boolean { 38 | const ext = filename.toLowerCase().slice(filename.lastIndexOf('.')) 39 | return IMAGE_EXTENSIONS.includes(ext) 40 | } 41 | 42 | /** 43 | * 根据文件名获取MIME类型 44 | */ 45 | function getMimeType(filename: string): string { 46 | const ext = filename.toLowerCase().slice(filename.lastIndexOf('.')) 47 | const mimeTypes: Record = { 48 | '.jpg': 'image/jpeg', 49 | '.jpeg': 'image/jpeg', 50 | '.png': 'image/png', 51 | '.gif': 'image/gif', 52 | '.webp': 'image/webp', 53 | '.avif': 'image/avif', 54 | } 55 | return mimeTypes[ext] || 'application/octet-stream' 56 | } 57 | 58 | /** 59 | * 生成唯一ID 60 | */ 61 | function generateId(): string { 62 | return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}` 63 | } 64 | 65 | /** 66 | * 分析ZIP文件内容,返回图片列表和统计信息 67 | */ 68 | export async function analyzeZipFile(zipFile: File): Promise { 69 | const zip = await JSZip.loadAsync(zipFile) 70 | 71 | const images: ZipImageEntry[] = [] 72 | const skippedFiles: ZipAnalysisResult['skippedFiles'] = [] 73 | 74 | for (const [path, entry] of Object.entries(zip.files)) { 75 | // 跳过目录 76 | if (entry.dir) { 77 | continue 78 | } 79 | 80 | // 获取文件名(不含路径) 81 | const name = path.split('/').pop() || path 82 | 83 | // 跳过隐藏文件和macOS元数据 84 | if (name.startsWith('.') || name.startsWith('__MACOSX')) { 85 | continue 86 | } 87 | 88 | // 检查是否是图片 89 | if (!isImageFile(name)) { 90 | skippedFiles.push({ path, reason: 'not_image' }) 91 | continue 92 | } 93 | 94 | images.push({ path, name, size: 0 }) 95 | } 96 | 97 | return { 98 | totalImages: images.length, 99 | totalSize: 0, // 无法在分析阶段获取准确大小 100 | images, 101 | skippedFiles, 102 | } 103 | } 104 | 105 | /** 106 | * 分批解压图片的生成器函数 107 | * 每批返回指定数量的图片,避免内存溢出 108 | */ 109 | export async function* extractImagesBatch( 110 | zipFile: File, 111 | imageEntries: ZipImageEntry[], 112 | batchSize: number = 50, 113 | onProgress?: (progress: ExtractionProgress) => void 114 | ): AsyncGenerator { 115 | const zip = await JSZip.loadAsync(zipFile) 116 | 117 | for (let i = 0; i < imageEntries.length; i += batchSize) { 118 | const batch = imageEntries.slice(i, i + batchSize) 119 | const extractedBatch: ExtractedImage[] = [] 120 | 121 | for (const entry of batch) { 122 | const zipEntry = zip.files[entry.path] 123 | if (!zipEntry) continue 124 | 125 | try { 126 | // 通知进度 127 | if (onProgress) { 128 | onProgress({ 129 | current: i + extractedBatch.length + 1, 130 | total: imageEntries.length, 131 | currentFileName: entry.name, 132 | }) 133 | } 134 | 135 | // 提取文件内容 136 | const blob = await zipEntry.async('blob') 137 | 138 | // 创建File对象 139 | const file = new File([blob], entry.name, { 140 | type: getMimeType(entry.name), 141 | }) 142 | 143 | extractedBatch.push({ 144 | id: generateId(), 145 | file, 146 | originalPath: entry.path, 147 | }) 148 | } catch (error) { 149 | console.error(`Failed to extract ${entry.path}:`, error) 150 | // 继续处理其他文件 151 | } 152 | } 153 | 154 | yield extractedBatch 155 | } 156 | } 157 | 158 | /** 159 | * 格式化文件大小 160 | */ 161 | export function formatFileSize(bytes: number): string { 162 | if (bytes === 0) return '0 B' 163 | const k = 1024 164 | const sizes = ['B', 'KB', 'MB', 'GB'] 165 | const i = Math.floor(Math.log(bytes) / Math.log(k)) 166 | return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] 167 | } 168 | -------------------------------------------------------------------------------- /app/components/upload/TagSelector.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react' 4 | import { TagIcon, PlusIcon, Cross1Icon } from '../ui/icons' 5 | 6 | interface TagSelectorProps { 7 | selectedTags: string[] 8 | availableTags: string[] 9 | onTagsChange: (tags: string[]) => void 10 | onNewTagCreated?: () => void 11 | } 12 | 13 | export default function TagSelector({ selectedTags, availableTags, onTagsChange, onNewTagCreated }: TagSelectorProps) { 14 | const [inputTag, setInputTag] = useState('') 15 | 16 | // 处理标签选择变更 17 | const handleTagChange = (e: React.ChangeEvent) => { 18 | const tag = e.target.value 19 | if (tag && !selectedTags.includes(tag)) { 20 | onTagsChange([...selectedTags, tag]) 21 | } 22 | // 重置选择框 23 | e.target.value = '' 24 | } 25 | 26 | // 处理标签移除 27 | const handleRemoveTag = (tag: string) => { 28 | onTagsChange(selectedTags.filter(t => t !== tag)) 29 | } 30 | 31 | // 处理自定义标签输入 32 | const handleTagInput = (e: React.ChangeEvent) => { 33 | setInputTag(e.target.value) 34 | } 35 | 36 | // 添加自定义标签 37 | const handleAddTag = () => { 38 | if (inputTag.trim() && !selectedTags.includes(inputTag.trim())) { 39 | onTagsChange([...selectedTags, inputTag.trim()]) 40 | setInputTag('') 41 | // 通知父组件有新标签被创建 42 | if (onNewTagCreated) { 43 | onNewTagCreated() 44 | } 45 | } 46 | } 47 | 48 | // 处理回车键添加标签 49 | const handleKeyDown = (e: React.KeyboardEvent) => { 50 | if (e.key === 'Enter') { 51 | e.preventDefault() 52 | handleAddTag() 53 | } 54 | } 55 | 56 | return ( 57 |
58 |
59 |
60 | 61 | 标签: 62 |
63 | 64 |
65 | 77 | 78 |
79 | 87 | 94 |
95 |
96 |
97 | 98 | {selectedTags.length > 0 && ( 99 |
100 | {selectedTags.map(tag => ( 101 |
105 | {tag} 106 | 113 |
114 | ))} 115 |
116 | )} 117 |
118 | ) 119 | } -------------------------------------------------------------------------------- /worker/src/types.ts: -------------------------------------------------------------------------------- 1 | // Cloudflare Images binding types 2 | export interface ImagesBinding { 3 | input(source: ReadableStream | ArrayBuffer | Blob): ImageTransformer; 4 | } 5 | 6 | export interface ImageTransformer { 7 | transform(options: ImageTransformOptions): ImageTransformer; 8 | output(options: ImageOutputOptions): Promise; 9 | } 10 | 11 | export interface ImageTransformOptions { 12 | width?: number; 13 | height?: number; 14 | fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad'; 15 | gravity?: 'auto' | 'left' | 'right' | 'top' | 'bottom' | 'center'; 16 | quality?: number; 17 | rotate?: 0 | 90 | 180 | 270; 18 | } 19 | 20 | export interface ImageOutputOptions { 21 | format: 22 | | 'webp' 23 | | 'avif' 24 | | 'jpeg' 25 | | 'png' 26 | | 'image/webp' 27 | | 'image/avif' 28 | | 'image/jpeg' 29 | | 'image/png'; 30 | quality?: number; 31 | } 32 | 33 | export interface ImageOutputResult { 34 | image(): ReadableStream; 35 | response(): Response; 36 | contentType(): string; 37 | } 38 | 39 | // Compression options 40 | export interface CompressionOptions { 41 | quality?: number; 42 | maxWidth?: number; 43 | maxHeight?: number; 44 | preserveAnimation?: boolean; 45 | generateWebp?: boolean; 46 | generateAvif?: boolean; 47 | } 48 | 49 | export interface CompressedImage { 50 | data: ArrayBuffer; 51 | contentType: string; 52 | size: number; 53 | } 54 | 55 | export interface CompressionResult { 56 | original: ArrayBuffer; 57 | webp?: CompressedImage; 58 | avif?: CompressedImage; 59 | isAnimated: boolean; 60 | } 61 | 62 | // Import Queue message types 63 | import type { QueueMessage } from './types/queue'; 64 | 65 | // Cloudflare Worker bindings 66 | export interface Env { 67 | R2_BUCKET: R2Bucket; 68 | DB: D1Database; 69 | CACHE_KV: KVNamespace; 70 | ENVIRONMENT: string; 71 | R2_PUBLIC_URL: string; 72 | IMAGES?: ImagesBinding; 73 | DELETE_QUEUE: Queue; 74 | } 75 | 76 | // D1 row type for images table 77 | export interface ImageRow { 78 | id: string; 79 | original_name: string; 80 | upload_time: string; 81 | expiry_time: string | null; 82 | orientation: string; 83 | format: string; 84 | width: number; 85 | height: number; 86 | path_original: string; 87 | path_webp: string | null; 88 | path_avif: string | null; 89 | size_original: number; 90 | size_webp: number; 91 | size_avif: number; 92 | } 93 | 94 | // Image metadata 95 | export interface ImageMetadata { 96 | id: string; 97 | originalName: string; 98 | uploadTime: string; 99 | expiryTime?: string; 100 | orientation: 'landscape' | 'portrait'; 101 | tags: string[]; 102 | format: string; 103 | width: number; 104 | height: number; 105 | paths: { 106 | original: string; 107 | webp: string; 108 | avif: string; 109 | }; 110 | sizes: { 111 | original: number; 112 | webp: number; 113 | avif: number; 114 | }; 115 | } 116 | 117 | // API response types 118 | export interface ApiResponse { 119 | success: boolean; 120 | data?: T; 121 | error?: string; 122 | } 123 | 124 | export interface PaginatedResponse { 125 | success: boolean; 126 | data: T[]; 127 | page: number; 128 | limit: number; 129 | total: number; 130 | totalPages: number; 131 | } 132 | 133 | // Upload types 134 | export interface UploadResult { 135 | id: string; 136 | status: 'success' | 'error'; 137 | urls?: { 138 | original: string; 139 | webp: string; 140 | avif: string; 141 | }; 142 | orientation?: 'landscape' | 'portrait'; 143 | tags?: string[]; 144 | sizes?: { 145 | original: number; 146 | webp: number; 147 | avif: number; 148 | }; 149 | expiryTime?: string; 150 | format?: string; 151 | error?: string; 152 | } 153 | 154 | // Tag types 155 | export interface Tag { 156 | name: string; 157 | count: number; 158 | } 159 | 160 | // Config types 161 | export interface Config { 162 | maxUploadCount: number; 163 | maxFileSize: number; 164 | supportedFormats: string[]; 165 | imageQuality: number; 166 | } 167 | 168 | // Filter types 169 | export interface ImageFilters { 170 | page?: number; 171 | limit?: number; 172 | tag?: string; 173 | orientation?: 'landscape' | 'portrait'; 174 | format?: 'all' | 'gif' | 'webp' | 'avif' | 'original'; 175 | } 176 | 177 | export interface RandomFilters { 178 | tags?: string[]; 179 | exclude?: string[]; 180 | orientation?: 'landscape' | 'portrait' | 'auto'; 181 | format?: 'original' | 'webp' | 'avif'; 182 | } 183 | -------------------------------------------------------------------------------- /app/components/ExpirySelector.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState, useEffect } from 'react' 4 | import { ClockIcon } from './ui/icons' 5 | 6 | interface ExpirySelectorProps { 7 | onChange: (minutes: number) => void 8 | } 9 | 10 | export default function ExpirySelector({ onChange }: ExpirySelectorProps) { 11 | const [selectedOption, setSelectedOption] = useState('never') 12 | const [customValue, setCustomValue] = useState(1) 13 | const [timeUnit, setTimeUnit] = useState<'hours' | 'days'>('hours') 14 | 15 | useEffect(() => { 16 | onChange(0) 17 | }, [onChange]) 18 | 19 | // 处理选项变更 20 | const handleOptionChange = (e: React.ChangeEvent) => { 21 | const option = e.target.value 22 | setSelectedOption(option) 23 | 24 | let minutes = 0 25 | switch (option) { 26 | case 'never': 27 | minutes = 0 28 | break 29 | case '1h': 30 | minutes = 60 31 | break 32 | case '24h': 33 | minutes = 24 * 60 34 | break 35 | case '7d': 36 | minutes = 7 * 24 * 60 37 | break 38 | case '30d': 39 | minutes = 30 * 24 * 60 40 | break 41 | case 'custom': 42 | // 根据当前选择的时间单位计算分钟数 43 | minutes = timeUnit === 'hours' ? customValue * 60 : customValue * 60 * 24 44 | break 45 | } 46 | onChange(minutes) 47 | } 48 | 49 | // 处理自定义值变更 50 | const handleCustomValueChange = (e: React.ChangeEvent) => { 51 | const value = parseInt(e.target.value) 52 | if (!isNaN(value) && value > 0) { 53 | setCustomValue(value) 54 | if (selectedOption === 'custom') { 55 | // 根据当前选择的时间单位计算分钟数 56 | const minutes = timeUnit === 'hours' ? value * 60 : value * 60 * 24 57 | onChange(minutes) 58 | } 59 | } 60 | } 61 | 62 | // 处理时间单位变更 63 | const handleTimeUnitChange = (e: React.ChangeEvent) => { 64 | const unit = e.target.value as 'hours' | 'days' 65 | setTimeUnit(unit) 66 | if (selectedOption === 'custom') { 67 | // 根据新的时间单位计算分钟数 68 | const minutes = unit === 'hours' ? customValue * 60 : customValue * 60 * 24 69 | onChange(minutes) 70 | } 71 | } 72 | 73 | return ( 74 |
75 |
76 | 77 | 过期时间: 78 |
79 | 80 |
81 | 93 |
94 | 95 | {selectedOption === 'custom' && ( 96 | <> 97 |
98 | 106 |
107 |
108 | 116 |
117 | 118 | )} 119 |
120 | ) 121 | } 122 | -------------------------------------------------------------------------------- /app/hooks/useUploadState.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState, useCallback } from 'react' 4 | import { 5 | UploadState, 6 | UploadPhase, 7 | FileUploadStatus, 8 | UploadFileItem, 9 | UploadStateActions, 10 | } from '../types/upload' 11 | import { UploadResult } from '../types' 12 | 13 | const initialState: UploadState = { 14 | phase: 'idle', 15 | files: [], 16 | completedCount: 0, 17 | errorCount: 0, 18 | abortController: null, 19 | } 20 | 21 | export function useUploadState(): UploadState & UploadStateActions { 22 | const [state, setState] = useState(initialState) 23 | 24 | // 初始化上传,返回 AbortController 用于取消 25 | const initializeUpload = useCallback((files: { id: string; file: File }[]): AbortController => { 26 | const controller = new AbortController() 27 | const uploadFiles: UploadFileItem[] = files.map((f) => ({ 28 | id: f.id, 29 | file: f.file, 30 | status: 'uploading' as FileUploadStatus, 31 | })) 32 | 33 | setState({ 34 | phase: 'uploading', 35 | files: uploadFiles, 36 | completedCount: 0, 37 | errorCount: 0, 38 | abortController: controller, 39 | }) 40 | 41 | return controller 42 | }, []) 43 | 44 | // 设置上传阶段 45 | const setPhase = useCallback((phase: UploadPhase) => { 46 | setState((prev) => ({ ...prev, phase })) 47 | }, []) 48 | 49 | // 设置所有文件的状态 50 | const setAllFilesStatus = useCallback((status: FileUploadStatus) => { 51 | setState((prev) => ({ 52 | ...prev, 53 | files: prev.files.map((f) => ({ 54 | ...f, 55 | status: f.status === 'success' || f.status === 'error' ? f.status : status, 56 | })), 57 | })) 58 | }, []) 59 | 60 | // 设置上传结果 61 | const setResults = useCallback((results: UploadResult[]) => { 62 | setState((prev) => { 63 | // 匹配结果到文件,按顺序匹配 64 | const updatedFiles = prev.files.map((file, index) => { 65 | const result = results[index] 66 | if (!result) return file 67 | 68 | return { 69 | ...file, 70 | status: result.status === 'success' ? 'success' : 'error' as FileUploadStatus, 71 | result, 72 | error: result.error, 73 | } 74 | }) 75 | 76 | const successCount = updatedFiles.filter((f) => f.status === 'success').length 77 | const errorCount = updatedFiles.filter((f) => f.status === 'error').length 78 | 79 | return { 80 | ...prev, 81 | phase: 'completed', 82 | files: updatedFiles, 83 | completedCount: successCount, 84 | errorCount, 85 | abortController: null, 86 | } 87 | }) 88 | }, []) 89 | 90 | // 更新单个文件的状态(用于并发上传) 91 | const updateFileStatus = useCallback(( 92 | fileId: string, 93 | status: FileUploadStatus, 94 | result?: UploadResult 95 | ) => { 96 | setState((prev) => { 97 | const updatedFiles = prev.files.map((f) => 98 | f.id === fileId 99 | ? { ...f, status, result, error: result?.error } 100 | : f 101 | ) 102 | 103 | const completedCount = updatedFiles.filter((f) => f.status === 'success').length 104 | const errorCount = updatedFiles.filter((f) => f.status === 'error').length 105 | const totalDone = completedCount + errorCount 106 | 107 | // 自动判断整体阶段 108 | let phase: UploadPhase = prev.phase 109 | if (totalDone === updatedFiles.length && updatedFiles.length > 0) { 110 | phase = 'completed' 111 | } 112 | 113 | return { 114 | ...prev, 115 | phase, 116 | files: updatedFiles, 117 | completedCount, 118 | errorCount, 119 | } 120 | }) 121 | }, []) 122 | 123 | // 取消上传 124 | const cancelUpload = useCallback(() => { 125 | setState((prev) => { 126 | // 中断请求 127 | if (prev.abortController) { 128 | prev.abortController.abort() 129 | } 130 | 131 | // 重置所有文件状态为 pending 132 | return { 133 | ...prev, 134 | phase: 'idle', 135 | files: prev.files.map((f) => ({ 136 | ...f, 137 | status: 'pending' as FileUploadStatus, 138 | error: undefined, 139 | result: undefined, 140 | })), 141 | completedCount: 0, 142 | errorCount: 0, 143 | abortController: null, 144 | } 145 | }) 146 | }, []) 147 | 148 | // 重置状态 149 | const reset = useCallback(() => { 150 | setState(initialState) 151 | }, []) 152 | 153 | return { 154 | ...state, 155 | initializeUpload, 156 | setPhase, 157 | setAllFilesStatus, 158 | setResults, 159 | updateFileStatus, 160 | cancelUpload, 161 | reset, 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /app/components/RandomApiModal/LinkOutput.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useMemo } from 'react'; 4 | import { motion, AnimatePresence } from 'motion/react'; 5 | import { ClipboardCopyIcon, CheckIcon } from '../ui/icons'; 6 | 7 | interface LinkOutputProps { 8 | url: string; 9 | } 10 | 11 | type OutputFormat = 'url' | 'html' | 'markdown'; 12 | 13 | const formatOptions: { value: OutputFormat; label: string }[] = [ 14 | { value: 'url', label: 'URL' }, 15 | { value: 'html', label: 'HTML' }, 16 | { value: 'markdown', label: 'Markdown' }, 17 | ]; 18 | 19 | export default function LinkOutput({ url }: LinkOutputProps) { 20 | const [outputFormat, setOutputFormat] = useState('url'); 21 | const [copied, setCopied] = useState(false); 22 | 23 | const formattedOutput = useMemo(() => { 24 | switch (outputFormat) { 25 | case 'html': 26 | return `Random Image`; 27 | case 'markdown': 28 | return `![Random Image](${url})`; 29 | default: 30 | return url; 31 | } 32 | }, [url, outputFormat]); 33 | 34 | const handleCopy = async () => { 35 | try { 36 | await navigator.clipboard.writeText(formattedOutput); 37 | setCopied(true); 38 | setTimeout(() => setCopied(false), 2000); 39 | } catch (err) { 40 | console.error('Failed to copy:', err); 41 | } 42 | }; 43 | 44 | return ( 45 |
46 |
47 |

生成的链接

48 |
49 | {formatOptions.map((option) => { 50 | const isSelected = outputFormat === option.value; 51 | return ( 52 | setOutputFormat(option.value)} 55 | className={` 56 | relative px-3 py-1 rounded-md text-xs font-medium transition-colors duration-200 57 | ${isSelected 58 | ? 'text-white' 59 | : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200' 60 | } 61 | `} 62 | whileTap={{ scale: 0.95 }} 63 | > 64 | {isSelected && ( 65 | 70 | )} 71 | {option.label} 72 | 73 | ); 74 | })} 75 |
76 |
77 | 78 |
79 |
80 | {formattedOutput} 81 |
82 | 83 | 95 | 96 | {copied ? ( 97 | 104 | 105 | 106 | ) : ( 107 | 114 | 115 | 116 | )} 117 | 118 | 119 |
120 | 121 |

122 | 点击右上角按钮复制链接到剪贴板 123 |

124 |
125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /app/components/ContextMenu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useEffect, useLayoutEffect, useRef, ReactNode } from "react"; 4 | import { motion, AnimatePresence } from 'motion/react'; 5 | 6 | export interface ContextMenuItem { 7 | id: string; 8 | label: string; 9 | icon?: ReactNode; 10 | onClick: (e: React.MouseEvent) => void; 11 | danger?: boolean; 12 | disabled?: boolean; 13 | } 14 | 15 | export interface ContextMenuGroup { 16 | id: string; 17 | items: ContextMenuItem[]; 18 | } 19 | 20 | interface ContextMenuProps { 21 | items: ContextMenuGroup[]; 22 | isOpen: boolean; 23 | x: number; 24 | y: number; 25 | onClose: () => void; 26 | } 27 | 28 | export default function ContextMenu({ items, isOpen, x, y, onClose }: ContextMenuProps) { 29 | const menuRef = useRef(null); 30 | const [position, setPosition] = useState({ x, y }); 31 | 32 | // 调整菜单位置以避免超出视窗 (useLayoutEffect 用于 DOM 测量) 33 | useLayoutEffect(() => { 34 | if (isOpen && menuRef.current) { 35 | const menuRect = menuRef.current.getBoundingClientRect(); 36 | const viewportWidth = window.innerWidth; 37 | const viewportHeight = window.innerHeight; 38 | 39 | let adjustedX = x; 40 | let adjustedY = y; 41 | 42 | // 水平方向调整 43 | if (x + menuRect.width > viewportWidth) { 44 | adjustedX = viewportWidth - menuRect.width - 10; 45 | } 46 | 47 | // 垂直方向调整 48 | if (y + menuRect.height > viewportHeight) { 49 | adjustedY = viewportHeight - menuRect.height - 10; 50 | } 51 | 52 | // eslint-disable-next-line react-hooks/set-state-in-effect 53 | setPosition({ x: adjustedX, y: adjustedY }); 54 | } 55 | }, [isOpen, x, y]); 56 | 57 | // 点击外部关闭菜单 58 | useEffect(() => { 59 | const handleClickOutside = (event: MouseEvent) => { 60 | if (menuRef.current && !menuRef.current.contains(event.target as Node)) { 61 | onClose(); 62 | } 63 | }; 64 | 65 | const handleEscape = (event: KeyboardEvent) => { 66 | if (event.key === "Escape") { 67 | onClose(); 68 | } 69 | }; 70 | 71 | if (isOpen) { 72 | document.addEventListener("mousedown", handleClickOutside); 73 | document.addEventListener("keydown", handleEscape); 74 | } 75 | 76 | return () => { 77 | document.removeEventListener("mousedown", handleClickOutside); 78 | document.removeEventListener("keydown", handleEscape); 79 | }; 80 | }, [isOpen, onClose]); 81 | 82 | if (!isOpen) return null; 83 | 84 | return ( 85 |
89 | 90 | {isOpen && ( 91 | 105 | {items.map((group, groupIndex) => ( 106 |
107 | {groupIndex > 0 && ( 108 |
109 | )} 110 | {group.items.map((item) => ( 111 | 129 | ))} 130 |
131 | ))} 132 | 133 | )} 134 | 135 |
136 | ); 137 | } -------------------------------------------------------------------------------- /app/utils/concurrentUpload.ts: -------------------------------------------------------------------------------- 1 | import { request } from './request' 2 | import { UploadResult } from '../types' 3 | import { FileUploadStatus } from '../types/upload' 4 | 5 | interface SingleUploadResponse { 6 | success: boolean 7 | result: UploadResult 8 | error?: string 9 | } 10 | 11 | export interface ConcurrentUploadOptions { 12 | files: { id: string; file: File }[] 13 | concurrency?: number 14 | tags: string[] 15 | expiryMinutes: number 16 | quality: number 17 | maxWidth: number 18 | preserveAnimation: boolean 19 | outputFormat: 'webp' | 'avif' | 'both' 20 | onFileStatusChange: (fileId: string, status: FileUploadStatus, result?: UploadResult) => void 21 | signal?: AbortSignal 22 | } 23 | 24 | /** 25 | * Upload files concurrently with controlled parallelism 26 | * Each file is uploaded as a separate request for individual progress tracking 27 | */ 28 | export async function concurrentUpload(options: ConcurrentUploadOptions): Promise { 29 | const { 30 | files, 31 | concurrency = 5, 32 | tags, 33 | expiryMinutes, 34 | quality, 35 | maxWidth, 36 | preserveAnimation, 37 | outputFormat, 38 | onFileStatusChange, 39 | signal, 40 | } = options 41 | 42 | const results: UploadResult[] = [] 43 | const queue = [...files] 44 | const active: Promise[] = [] 45 | 46 | async function uploadOne(item: { id: string; file: File }): Promise { 47 | // Check if cancelled 48 | if (signal?.aborted) { 49 | return 50 | } 51 | 52 | // Update status to uploading 53 | onFileStatusChange(item.id, 'uploading') 54 | 55 | try { 56 | // Build FormData for single file 57 | const formData = new FormData() 58 | formData.append('image', item.file) 59 | formData.append('tags', tags.join(',')) 60 | formData.append('expiryMinutes', expiryMinutes.toString()) 61 | formData.append('quality', quality.toString()) 62 | formData.append('maxWidth', maxWidth.toString()) 63 | formData.append('maxHeight', maxWidth.toString()) 64 | formData.append('preserveAnimation', preserveAnimation.toString()) 65 | formData.append('generateWebp', (outputFormat === 'webp' || outputFormat === 'both').toString()) 66 | formData.append('generateAvif', (outputFormat === 'avif' || outputFormat === 'both').toString()) 67 | 68 | // Update to processing (after upload starts, before compression completes) 69 | onFileStatusChange(item.id, 'processing') 70 | 71 | const response = await request('/api/upload/single', { 72 | method: 'POST', 73 | body: formData, 74 | signal, 75 | }) 76 | 77 | if (response.success && response.result) { 78 | const decoratedResult: UploadResult = { 79 | ...response.result, 80 | originalName: item.file.name, 81 | clientFileId: item.id, 82 | } 83 | onFileStatusChange(item.id, 'success', decoratedResult) 84 | results.push(decoratedResult) 85 | } else { 86 | const errorResult: UploadResult = { 87 | id: '', 88 | status: 'error', 89 | error: response.error || 'Upload failed', 90 | originalName: item.file.name, 91 | clientFileId: item.id, 92 | } 93 | onFileStatusChange(item.id, 'error', errorResult) 94 | results.push(errorResult) 95 | } 96 | } catch (error) { 97 | if (error instanceof Error && error.name === 'AbortError') { 98 | // Upload was cancelled 99 | return 100 | } 101 | 102 | const errorMessage = error instanceof Error ? error.message : 'Upload failed' 103 | const errorResult: UploadResult = { 104 | id: '', 105 | status: 'error', 106 | error: errorMessage, 107 | originalName: item.file.name, 108 | clientFileId: item.id, 109 | } 110 | onFileStatusChange(item.id, 'error', errorResult) 111 | results.push(errorResult) 112 | } 113 | } 114 | 115 | // Concurrent upload with controlled parallelism 116 | while (queue.length > 0 || active.length > 0) { 117 | // Check if cancelled 118 | if (signal?.aborted) { 119 | break 120 | } 121 | 122 | // Fill up to concurrency limit 123 | while (active.length < concurrency && queue.length > 0) { 124 | const item = queue.shift()! 125 | const promise = uploadOne(item).finally(() => { 126 | const index = active.indexOf(promise) 127 | if (index > -1) { 128 | active.splice(index, 1) 129 | } 130 | }) 131 | active.push(promise) 132 | } 133 | 134 | // Wait for any one to complete 135 | if (active.length > 0) { 136 | await Promise.race(active) 137 | } 138 | } 139 | 140 | return results 141 | } 142 | -------------------------------------------------------------------------------- /app/components/RandomApiModal/TagSelector.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { motion } from 'motion/react'; 4 | import { PlusIcon, MinusIcon } from '../ui/icons'; 5 | 6 | interface TagSelectorProps { 7 | availableTags: string[]; 8 | includeTags: string[]; 9 | excludeTags: string[]; 10 | onToggleInclude: (tag: string) => void; 11 | onToggleExclude: (tag: string) => void; 12 | } 13 | 14 | export default function TagSelector({ 15 | availableTags, 16 | includeTags, 17 | excludeTags, 18 | onToggleInclude, 19 | onToggleExclude, 20 | }: TagSelectorProps) { 21 | const getTagState = (tag: string): 'include' | 'exclude' | 'none' => { 22 | if (includeTags.includes(tag)) return 'include'; 23 | if (excludeTags.includes(tag)) return 'exclude'; 24 | return 'none'; 25 | }; 26 | 27 | return ( 28 |
29 | {/* 包含标签 */} 30 |
31 |
32 |
33 | 34 |
35 |

36 | 包含标签 37 | {includeTags.length > 0 && ( 38 | 39 | 已选 {includeTags.length} 个 40 | 41 | )} 42 |

43 |
44 |
45 | {availableTags.length === 0 ? ( 46 |

暂无可用标签

47 | ) : ( 48 | availableTags.map((tag) => { 49 | const state = getTagState(tag); 50 | const isIncluded = state === 'include'; 51 | 52 | return ( 53 | onToggleInclude(tag)} 56 | whileHover={{ scale: 1.02 }} 57 | whileTap={{ scale: 0.98 }} 58 | className={` 59 | px-3 py-1.5 rounded-full text-sm font-medium transition-all duration-200 60 | ${isIncluded 61 | ? 'bg-gradient-to-r from-emerald-500 to-teal-500 text-white shadow-md' 62 | : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600' 63 | } 64 | ${state === 'exclude' ? 'opacity-40' : ''} 65 | `} 66 | > 67 | {tag} 68 | 69 | ); 70 | }) 71 | )} 72 |
73 |
74 | 75 | {/* 分割线 */} 76 |
77 | 78 | {/* 排除标签 */} 79 |
80 |
81 |
82 | 83 |
84 |

85 | 排除标签 86 | {excludeTags.length > 0 && ( 87 | 88 | 已排除 {excludeTags.length} 个 89 | 90 | )} 91 |

92 |
93 |
94 | {availableTags.length === 0 ? ( 95 |

暂无可用标签

96 | ) : ( 97 | availableTags.map((tag) => { 98 | const state = getTagState(tag); 99 | const isExcluded = state === 'exclude'; 100 | 101 | return ( 102 | onToggleExclude(tag)} 105 | whileHover={{ scale: 1.02 }} 106 | whileTap={{ scale: 0.98 }} 107 | className={` 108 | px-3 py-1.5 rounded-full text-sm font-medium transition-all duration-200 109 | ${isExcluded 110 | ? 'bg-gradient-to-r from-red-500 to-rose-500 text-white shadow-md' 111 | : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600' 112 | } 113 | ${state === 'include' ? 'opacity-40' : ''} 114 | `} 115 | > 116 | {tag} 117 | 118 | ); 119 | }) 120 | )} 121 |
122 |
123 |
124 | ); 125 | } 126 | -------------------------------------------------------------------------------- /app/components/ImagePreview.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { ImageFile } from "../types"; 3 | import { ImageData } from "../types/image"; 4 | import { getFullUrl } from "../utils/baseUrl"; 5 | import { useState, useEffect, useCallback } from "react"; 6 | import { imageQueue } from "../utils/imageQueue"; 7 | import { LoadingSpinner } from "./LoadingSpinner"; 8 | import { DownloadIcon } from "./ui/icons"; 9 | 10 | type ImageType = ImageFile | (ImageData & { status: 'success' }); 11 | 12 | interface ImagePreviewProps { 13 | image: ImageType; 14 | priority?: boolean; 15 | onLoad?: () => void; 16 | quality?: number; 17 | } 18 | 19 | export const ImagePreview = ({ 20 | image, 21 | priority = false, 22 | onLoad, 23 | quality = 20 24 | }: ImagePreviewProps) => { 25 | const [isLoading, setIsLoading] = useState(true); 26 | const [blurDataUrl, setBlurDataUrl] = useState(null); 27 | const [error, setError] = useState(null); 28 | 29 | // 判断图片类型并获取适当的URL 30 | const isImageFile = 'urls' in image && 'sizes' in image; 31 | const imageUrl = getFullUrl( 32 | image.urls?.webp || image.urls?.original || '' 33 | ); 34 | 35 | // 获取格式 36 | const format = isImageFile 37 | ? (image as ImageFile).format?.toLowerCase() 38 | : (image as ImageData).format?.toLowerCase() || ''; 39 | 40 | const handleLoadComplete = useCallback(() => { 41 | setIsLoading(false); 42 | onLoad?.(); 43 | }, [onLoad]); 44 | 45 | // 生成模糊占位图 46 | const generateBlurPlaceholder = useCallback(() => { 47 | return `data:image/svg+xml;base64,${Buffer.from( 48 | ` 49 | 50 | 51 | 52 | 53 | 54 | ` 55 | ).toString('base64')}`; 56 | }, []); 57 | 58 | useEffect(() => { 59 | // eslint-disable-next-line react-hooks/set-state-in-effect 60 | setIsLoading(true); 61 | 62 | setError(null); 63 | 64 | const loadImage = async () => { 65 | try { 66 | // Generate placeholder for non-GIF images 67 | if (format !== "gif") { 68 | const placeholder = generateBlurPlaceholder(); 69 | setBlurDataUrl(placeholder); 70 | } 71 | 72 | // Add to queue with priority flag 73 | if (!imageQueue.isPreloaded(imageUrl)) { 74 | imageQueue.add(imageUrl, priority); 75 | } 76 | } catch (err) { 77 | setError(err instanceof Error ? err.message : "Failed to load image"); 78 | setIsLoading(false); 79 | } 80 | }; 81 | 82 | loadImage(); 83 | return () => { 84 | setBlurDataUrl(null); 85 | }; 86 | }, [imageUrl, priority, format, generateBlurPlaceholder]); 87 | 88 | if (error) { 89 | return ( 90 |
91 | Failed to load image 92 |
93 | ); 94 | } 95 | 96 | if (format === "gif") { 97 | return ( 98 |
99 | {/* eslint-disable-next-line @next/next/no-img-element */} 100 | {image.originalName} setError("Failed to load GIF")} 109 | /> 110 | e.stopPropagation()} 115 | title="下载GIF" 116 | > 117 | 118 | 119 |
120 | ); 121 | } 122 | 123 | return ( 124 |
125 | {image.originalName setError("Failed to load image")} 140 | /> 141 | {isLoading && } 142 |
143 | ); 144 | }; 145 | -------------------------------------------------------------------------------- /app/components/TagManagement/TagEditModal.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState } from 'react'; 4 | import { motion, AnimatePresence } from 'motion/react'; 5 | import { Tag } from '../../types'; 6 | import { Cross1Icon, Spinner, CheckIcon } from '../ui/icons'; 7 | import { Pencil } from 'lucide-react'; 8 | 9 | interface TagEditModalProps { 10 | tag: Tag | null; 11 | isOpen: boolean; 12 | isProcessing: boolean; 13 | onClose: () => void; 14 | onSubmit: (oldName: string, newName: string) => void; 15 | } 16 | 17 | function TagEditModalContent({ 18 | tag, 19 | isProcessing, 20 | onClose, 21 | onSubmit, 22 | }: { 23 | tag: Tag; 24 | isProcessing: boolean; 25 | onClose: () => void; 26 | onSubmit: (oldName: string, newName: string) => void; 27 | }) { 28 | const [newName, setNewName] = useState(tag.name); 29 | 30 | const handleSubmit = (e: React.FormEvent) => { 31 | e.preventDefault(); 32 | if (!newName.trim() || newName.trim() === tag.name) return; 33 | onSubmit(tag.name, newName.trim()); 34 | }; 35 | 36 | return ( 37 | 44 | e.stopPropagation()} 51 | > 52 | {/* 标题 */} 53 |
54 |
55 |
56 | 57 |
58 |

59 | 编辑标签 60 |

61 |
62 | 68 |
69 | 70 | {/* 表单 */} 71 |
72 |
73 | 76 | setNewName(e.target.value)} 80 | className="w-full px-4 py-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 transition-all" 81 | autoFocus 82 | disabled={isProcessing} 83 | /> 84 |
85 | 86 | {/* 按钮 */} 87 |
88 | 96 | 103 | {isProcessing ? ( 104 | <> 105 | 106 | 保存中... 107 | 108 | ) : ( 109 | <> 110 | 111 | 保存 112 | 113 | )} 114 | 115 |
116 |
117 |
118 |
119 | ); 120 | } 121 | 122 | export default function TagEditModal({ tag, isOpen, isProcessing, onClose, onSubmit }: TagEditModalProps) { 123 | return ( 124 | 125 | {isOpen && tag && ( 126 | 133 | )} 134 | 135 | ); 136 | } 137 | -------------------------------------------------------------------------------- /app/components/Header.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Link from 'next/link' 4 | import { useTheme } from '../hooks/useTheme' 5 | import { usePathname } from 'next/navigation' 6 | import { motion } from 'motion/react' 7 | import { ImageIcon, HamburgerMenuIcon, LockClosedIcon, SunIcon, MoonIcon, TagIcon, Link2Icon } from './ui/icons' 8 | import { useQueryClient } from '@tanstack/react-query' 9 | import { queryKeys } from '../lib/queryKeys' 10 | import type { ImageListResponse } from '../types' 11 | import { api } from '../utils/request' 12 | 13 | interface HeaderProps { 14 | onApiKeyClick: () => void 15 | onTagManageClick?: () => void 16 | onRandomApiClick?: () => void 17 | title?: string 18 | isKeyVerified?: boolean 19 | } 20 | 21 | export default function Header({ onApiKeyClick, onTagManageClick, onRandomApiClick, title, isKeyVerified = false }: HeaderProps) { 22 | const { isDarkMode, toggleTheme } = useTheme() 23 | const pathname = usePathname() 24 | const queryClient = useQueryClient() 25 | 26 | const getTitle = () => { 27 | if (title) return title 28 | if (pathname === '/manage') return '图片管理' 29 | return 'CattoPic' 30 | } 31 | 32 | return ( 33 |
34 |
35 | 36 |
37 | 38 |
39 | 40 |

41 | {getTitle()} 42 |

43 |
44 | 45 |
46 | {!pathname?.startsWith('/manage') && ( 47 | { 51 | // Warm up Manage page list without clearing existing cache (avoid loading spinner). 52 | void queryClient.prefetchInfiniteQuery({ 53 | queryKey: queryKeys.images.list({ tag: '', orientation: '', format: 'all', limit: 60 }), 54 | initialPageParam: 1, 55 | queryFn: async ({ pageParam = 1 }) => { 56 | const params: Record = { 57 | page: String(pageParam), 58 | limit: '60', 59 | } 60 | const response = await api.get('/api/images', params) 61 | return response 62 | }, 63 | getNextPageParam: (lastPage: ImageListResponse) => { 64 | if (lastPage.page < lastPage.totalPages) return lastPage.page + 1 65 | return undefined 66 | }, 67 | }) 68 | }} 69 | > 70 | 71 | 72 | )} 73 | 74 | {pathname?.startsWith('/manage') && onTagManageClick && ( 75 | 78 | )} 79 | 80 | {pathname?.startsWith('/manage') && onRandomApiClick && ( 81 | 84 | )} 85 | 86 | 117 | 118 | 125 |
126 |
127 | ) 128 | } 129 | -------------------------------------------------------------------------------- /app/components/ui/icons.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Image as ImageIcon, 3 | PlusCircle as PlusCircledIcon, 4 | X as Cross1Icon, 5 | MoreHorizontal as DotsHorizontalIcon, 6 | Trash2 as TrashIcon, 7 | Clipboard as ClipboardCopyIcon, 8 | Check as CheckIcon, 9 | ExternalLink as ExternalLinkIcon, 10 | Eye as EyeOpenIcon, 11 | Search as MagnifyingGlassIcon, 12 | KeyRound as LockClosedIcon, 13 | Unlink as LinkBreak2Icon, 14 | ArrowRight as ArrowRightIcon, 15 | ArrowDown as ArrowDownIcon, 16 | Clock as ClockIcon, 17 | Tags as TagIcon, 18 | Plus as PlusIcon, 19 | Minus as MinusIcon, 20 | Info as InfoCircledIcon, 21 | Share as Share1Icon, 22 | ChevronDown as CaretDownIcon, 23 | Download as DownloadIcon, 24 | CornerDownRight as EnterIcon, 25 | Maximize2 as SizeIcon, 26 | AlertTriangle as ExclamationTriangleIcon, 27 | Upload as UploadIcon, 28 | X as Cross2Icon, 29 | File as FileIcon, 30 | Settings as GearIcon, 31 | SlidersHorizontal as MixerHorizontalIcon, 32 | Calendar as CalendarIcon, 33 | ClipboardList as ClipboardIcon, 34 | Copy as CopyIcon, 35 | Move as TransformIcon, 36 | RotateCw as ReloadIcon, 37 | Moon as MoonIcon, 38 | Sun as SunIcon, 39 | Menu as HamburgerMenuIcon, 40 | ChevronRight, 41 | ChevronLeft, 42 | ChevronDown, 43 | ChevronUp, 44 | MoreVertical as DotsVerticalIcon, 45 | Mail as EnvelopeClosedIcon, 46 | User as PersonIcon, 47 | Heart as HeartIcon, 48 | Heart as HeartFilledIcon, 49 | Star as StarIcon, 50 | Star as StarFilledIcon, 51 | HelpCircle as QuestionMarkIcon, 52 | Link as Link1Icon, 53 | Link2 as Link2Icon, 54 | CreditCard as IdCardIcon, 55 | // 新增图标用于不同格式 56 | Camera as CameraIcon, 57 | Sparkles as SparklesIcon, 58 | Zap as ZapIcon, 59 | Shield as ShieldIcon, 60 | Layers as LayersIcon, 61 | Disc as DiscIcon, 62 | Hash as HashIcon, 63 | Code as CodeIcon, 64 | Archive as ArchiveIcon, 65 | Package as PackageIcon 66 | } from 'lucide-react'; 67 | 68 | export { 69 | ImageIcon, // 图片图标 70 | PlusCircledIcon, // 添加图标(带圆圈) 71 | PlusIcon, // 添加图标 72 | MinusIcon, // 减号图标 73 | Cross1Icon, // 关闭/删除图标 74 | Cross2Icon, // 替代关闭图标 75 | DotsHorizontalIcon, // 更多操作图标 76 | TrashIcon, // 删除图标 77 | ClipboardCopyIcon, // 复制图标 78 | CheckIcon, // 确认/成功图标 79 | ExternalLinkIcon, // 外部链接图标 80 | EyeOpenIcon, // 查看图标 81 | MagnifyingGlassIcon, // 搜索图标 82 | LockClosedIcon, // 锁定图标 83 | LinkBreak2Icon, // 链接断开图标 84 | ArrowRightIcon, // 右箭头图标 85 | ArrowDownIcon, // 下箭头图标 86 | ClockIcon, // 时钟/计时图标 87 | TagIcon, // 标签图标 88 | InfoCircledIcon, // 信息图标 89 | Share1Icon, // 分享图标 90 | CaretDownIcon, // 下拉箭头图标 91 | DownloadIcon, // 下载图标 92 | EnterIcon, // 确认/进入图标 93 | SizeIcon, // 尺寸图标 94 | ExclamationTriangleIcon, // 警告/错误图标 95 | UploadIcon, // 上传图标 96 | FileIcon, // 文件图标 97 | GearIcon, // 设置图标 98 | MixerHorizontalIcon, // 过滤/筛选图标 99 | CalendarIcon, // 日历图标 100 | ClipboardIcon, // 剪贴板图标 101 | CopyIcon, // 复制图标 102 | TransformIcon, // 变换图标 103 | ReloadIcon, // 重新加载图标 104 | MoonIcon, // 月亮/夜间模式图标 105 | SunIcon, // 太阳/日间模式图标 106 | HamburgerMenuIcon, // 菜单图标 107 | ChevronRight as ChevronRightIcon, // 右箭头 108 | ChevronLeft as ChevronLeftIcon, // 左箭头 109 | ChevronDown as ChevronDownIcon, // 下箭头 110 | ChevronUp as ChevronUpIcon, // 上箭头 111 | DotsVerticalIcon, // 垂直更多操作图标 112 | EnvelopeClosedIcon, // 邮件图标 113 | PersonIcon, // 人物/用户图标 114 | HeartIcon, // 心形/喜欢图标 115 | HeartFilledIcon, // 实心心形图标 116 | StarIcon, // 星形/收藏图标 117 | StarFilledIcon, // 实心星形图标 118 | QuestionMarkIcon, // 问号/帮助图标 119 | Link1Icon, // 链接图标 120 | Link2Icon, // 链接图标(变体) 121 | IdCardIcon, // ID卡/身份图标 122 | // 新增的格式相关图标 123 | CameraIcon, // 相机图标 - 原始图片 124 | SparklesIcon, // 闪亮图标 - 优化格式 125 | ZapIcon, // 闪电图标 - 快速格式 126 | ShieldIcon, // 盾牌图标 - 安全格式 127 | LayersIcon, // 层级图标 - 堆叠格式 128 | DiscIcon, // 圆盘图标 - 存储格式 129 | HashIcon, // 哈希图标 - 编码格式 130 | CodeIcon, // 代码图标 - Markdown格式 131 | ArchiveIcon, // 归档图标 - 压缩格式 132 | PackageIcon // 包装图标 - 打包格式 133 | }; 134 | 135 | // 状态图标 - 为不同类型的状态消息提供图标 136 | export const StatusIcon = { 137 | success: ({ className = "" }: { className?: string }) => ( 138 | 139 | ), 140 | error: ({ className = "" }: { className?: string }) => ( 141 | 142 | ), 143 | warning: ({ className = "" }: { className?: string }) => ( 144 | 145 | ), 146 | info: ({ className = "" }: { className?: string }) => ( 147 | 148 | ) 149 | }; 150 | 151 | // 封装通用 Spinner 组件 152 | export const Spinner = ({ className = "" }: { className?: string }) => ( 153 | 159 | 167 | 172 | 173 | ); -------------------------------------------------------------------------------- /app/components/ImageDetail/ImageUrls.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState, type JSX } from 'react'; 4 | import { CheckIcon, CopyIcon, ImageIcon, FileIcon, Link1Icon, EyeOpenIcon } from '../ui/icons' 5 | import { ImageData, CopyStatus } from '../../types/image' 6 | import { getFullUrl } from '../../utils/baseUrl' 7 | import { copyToClipboard } from '../../utils/clipboard' 8 | 9 | interface ImageUrlsProps { 10 | image: ImageData 11 | } 12 | 13 | interface UrlItemProps { 14 | title: string 15 | url: string 16 | icon: JSX.Element 17 | iconColor: string 18 | copyType: string 19 | copyStatus: CopyStatus | null 20 | onCopy: (text: string, type: string) => void 21 | } 22 | 23 | function UrlItem({ title, url, icon, iconColor, copyType, copyStatus, onCopy }: UrlItemProps) { 24 | const [showFullUrl, setShowFullUrl] = useState(false); 25 | const isLongUrl = url.length > 60; 26 | const displayUrl = showFullUrl || !isLongUrl ? url : `${url.substring(0, 45)}...${url.substring(url.length - 15)}`; 27 | 28 | return ( 29 |
30 | {/* 标题行 */} 31 |
32 |
33 | {icon} 34 |
35 | {title} 36 |
37 | 38 | {/* URL显示和操作 */} 39 |
40 | {/* URL显示区域 */} 41 |
42 | {displayUrl} 43 |
44 | 45 | {/* 操作按钮区域 */} 46 |
47 |
48 | {isLongUrl && ( 49 | 56 | )} 57 |
58 | 59 | 76 |
77 |
78 |
79 | ) 80 | } 81 | 82 | export function ImageUrls({ image }: ImageUrlsProps) { 83 | const [copyStatus, setCopyStatus] = useState(null) 84 | 85 | const handleCopy = (text: string, type: string) => { 86 | copyToClipboard(text) 87 | .then(success => { 88 | if (success) { 89 | setCopyStatus({ type }) 90 | setTimeout(() => { 91 | setCopyStatus(null) 92 | }, 2000) 93 | } else { 94 | console.error("复制失败") 95 | } 96 | }) 97 | .catch(err => { 98 | console.error("复制失败:", err) 99 | }) 100 | } 101 | 102 | const originalUrl = getFullUrl(image.urls?.original || '') 103 | const webpUrl = getFullUrl(image.urls?.webp || '') 104 | const avifUrl = getFullUrl(image.urls?.avif || '') 105 | const recommendedUrl = webpUrl || avifUrl || originalUrl 106 | 107 | return ( 108 |
109 | } 113 | iconColor="text-blue-500" 114 | copyType="original" 115 | copyStatus={copyStatus} 116 | onCopy={handleCopy} 117 | /> 118 | 119 | {webpUrl && ( 120 | } 124 | iconColor="text-purple-500" 125 | copyType="webp" 126 | copyStatus={copyStatus} 127 | onCopy={handleCopy} 128 | /> 129 | )} 130 | 131 | {avifUrl && ( 132 | } 136 | iconColor="text-green-500" 137 | copyType="avif" 138 | copyStatus={copyStatus} 139 | onCopy={handleCopy} 140 | /> 141 | )} 142 | 143 | } 147 | iconColor="text-amber-500" 148 | copyType="markdown" 149 | copyStatus={copyStatus} 150 | onCopy={handleCopy} 151 | /> 152 |
153 | ) 154 | } 155 | -------------------------------------------------------------------------------- /worker/src/utils/imageTransform.ts: -------------------------------------------------------------------------------- 1 | import type { CompressionOptions, ImageMetadata } from '../types'; 2 | 3 | const DEFAULT_OPTIONS: Required = { 4 | quality: 90, 5 | maxWidth: 0, 6 | maxHeight: 0, 7 | preserveAnimation: true, 8 | generateWebp: true, 9 | generateAvif: true, 10 | }; 11 | 12 | function clampInt(value: number, min: number, max: number): number { 13 | if (!Number.isFinite(value)) return min; 14 | return Math.max(min, Math.min(max, Math.trunc(value))); 15 | } 16 | 17 | function toPositiveInt(value: unknown, fallback: number): number { 18 | const num = typeof value === 'number' ? value : Number(value); 19 | if (!Number.isFinite(num)) return fallback; 20 | return Math.max(0, Math.trunc(num)); 21 | } 22 | 23 | function buildPublicUrl(baseUrl: string, key: string): string { 24 | const base = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; 25 | const normalizedKey = key.startsWith('/') ? key.slice(1) : key; 26 | return new URL(normalizedKey, base).toString(); 27 | } 28 | 29 | function calculateDimensions( 30 | width: number, 31 | height: number, 32 | maxWidth: number, 33 | maxHeight: number 34 | ): { width: number; height: number } { 35 | if (width <= maxWidth && height <= maxHeight) { 36 | return { width, height }; 37 | } 38 | 39 | const scale = Math.min(maxWidth / width, maxHeight / height); 40 | return { 41 | width: Math.round(width * scale), 42 | height: Math.round(height * scale), 43 | }; 44 | } 45 | 46 | function buildCdnCgiOptionsString(options: { 47 | format: 'webp' | 'avif'; 48 | quality: number; 49 | width?: number; 50 | height?: number; 51 | fit?: 'scale-down'; 52 | }): string { 53 | const parts: string[] = [ 54 | `format=${options.format}`, 55 | `quality=${clampInt(options.quality, 1, 100)}`, 56 | ]; 57 | 58 | if (options.width && options.height) { 59 | parts.push(`width=${options.width}`); 60 | parts.push(`height=${options.height}`); 61 | parts.push(`fit=${options.fit || 'scale-down'}`); 62 | } 63 | 64 | return parts.join(','); 65 | } 66 | 67 | function buildTransformedUrl(originalUrl: string, optionsString: string): string { 68 | const url = new URL(originalUrl); 69 | return `${url.origin}/cdn-cgi/image/${optionsString}${url.pathname}${url.search}`; 70 | } 71 | 72 | export function buildImageUrls(params: { 73 | baseUrl: string; 74 | image: Pick; 75 | options?: CompressionOptions; 76 | preferStoredVariants?: boolean; 77 | }): { original: string; webp: string; avif: string } { 78 | const { baseUrl, image, options, preferStoredVariants = true } = params; 79 | const opts: Required = { ...DEFAULT_OPTIONS, ...(options || {}) }; 80 | 81 | const originalUrl = buildPublicUrl(baseUrl, image.paths.original); 82 | 83 | const formatLower = (image.format || '').toLowerCase(); 84 | const isGif = formatLower === 'gif'; 85 | if (isGif) { 86 | return { original: originalUrl, webp: '', avif: '' }; 87 | } 88 | 89 | const canTransformFromOriginal = 90 | formatLower === 'jpeg' || formatLower === 'jpg' || formatLower === 'png'; 91 | 92 | const generateWebp = opts.generateWebp !== false; 93 | const generateAvif = opts.generateAvif !== false; 94 | 95 | const isWebpMarker = !!image.paths.webp 96 | && image.paths.webp === image.paths.original 97 | && formatLower !== 'webp'; 98 | const isAvifMarker = !!image.paths.avif 99 | && image.paths.avif === image.paths.original 100 | && formatLower !== 'avif'; 101 | 102 | const webpStored = preferStoredVariants && image.paths.webp && !isWebpMarker 103 | ? buildPublicUrl(baseUrl, image.paths.webp) 104 | : ''; 105 | const avifStored = preferStoredVariants && image.paths.avif && !isAvifMarker 106 | ? buildPublicUrl(baseUrl, image.paths.avif) 107 | : ''; 108 | 109 | const quality = clampInt(toPositiveInt(opts.quality, DEFAULT_OPTIONS.quality), 1, 100); 110 | const maxWidth = toPositiveInt(opts.maxWidth, DEFAULT_OPTIONS.maxWidth); 111 | const maxHeight = toPositiveInt(opts.maxHeight, DEFAULT_OPTIONS.maxHeight); 112 | const hasResizeLimit = maxWidth > 0 && maxHeight > 0; 113 | 114 | const webpUrl = (() => { 115 | if (!generateWebp) return ''; 116 | if (webpStored) return webpStored; 117 | if (formatLower === 'webp') return originalUrl; 118 | if (!canTransformFromOriginal) return ''; 119 | 120 | const dims = hasResizeLimit 121 | ? calculateDimensions(image.width, image.height, maxWidth, maxHeight) 122 | : undefined; 123 | 124 | const optionsString = buildCdnCgiOptionsString({ 125 | format: 'webp', 126 | quality, 127 | width: dims?.width, 128 | height: dims?.height, 129 | fit: dims ? 'scale-down' : undefined, 130 | }); 131 | 132 | return buildTransformedUrl(originalUrl, optionsString); 133 | })(); 134 | 135 | const avifUrl = (() => { 136 | if (!generateAvif) return ''; 137 | if (avifStored) return avifStored; 138 | if (formatLower === 'avif') return originalUrl; 139 | if (!canTransformFromOriginal) return ''; 140 | 141 | const dims = hasResizeLimit 142 | ? calculateDimensions(image.width, image.height, maxWidth, maxHeight) 143 | : undefined; 144 | 145 | const optionsString = buildCdnCgiOptionsString({ 146 | format: 'avif', 147 | quality, 148 | width: dims?.width, 149 | height: dims?.height, 150 | fit: dims ? 'scale-down' : undefined, 151 | }); 152 | 153 | return buildTransformedUrl(originalUrl, optionsString); 154 | })(); 155 | 156 | return { original: originalUrl, webp: webpUrl, avif: avifUrl }; 157 | } 158 | -------------------------------------------------------------------------------- /app/components/upload/ZipUploadProgress.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ZipUploadPhase } from '../../hooks/useZipUpload' 4 | import { ExtractionProgress } from '../../utils/zipProcessor' 5 | import { Spinner, CheckIcon, Cross1Icon, ArchiveIcon, UploadIcon } from '../ui/icons' 6 | 7 | interface ZipUploadProgressProps { 8 | phase: ZipUploadPhase 9 | extractProgress: ExtractionProgress | null 10 | uploadProgress: { 11 | completed: number 12 | failed: number 13 | total: number 14 | } 15 | onCancel: () => void 16 | } 17 | 18 | export default function ZipUploadProgress({ 19 | phase, 20 | extractProgress, 21 | uploadProgress, 22 | onCancel, 23 | }: ZipUploadProgressProps) { 24 | const isExtracting = phase === 'extracting' 25 | const isUploading = phase === 'uploading' 26 | const isCompleted = phase === 'completed' 27 | 28 | // 计算总进度 29 | const totalProgress = isExtracting 30 | ? extractProgress 31 | ? Math.round((extractProgress.current / extractProgress.total) * 50) 32 | : 0 33 | : isUploading 34 | ? 50 + Math.round(((uploadProgress.completed + uploadProgress.failed) / uploadProgress.total) * 50) 35 | : isCompleted 36 | ? 100 37 | : 0 38 | 39 | return ( 40 |
41 | {/* 标题和取消按钮 */} 42 |
43 |
44 | {isCompleted ? ( 45 |
46 | 47 |
48 | ) : ( 49 |
50 | 51 |
52 | )} 53 |
54 |

55 | {isExtracting && '正在解压...'} 56 | {isUploading && '正在上传...'} 57 | {isCompleted && '上传完成'} 58 |

59 |

60 | {extractProgress && isExtracting && extractProgress.currentFileName} 61 | {isUploading && 62 | `${uploadProgress.completed + uploadProgress.failed} / ${uploadProgress.total}`} 63 | {isCompleted && 64 | `成功 ${uploadProgress.completed},失败 ${uploadProgress.failed}`} 65 |

66 |
67 |
68 | {!isCompleted && ( 69 | 76 | )} 77 |
78 | 79 | {/* 总进度条 */} 80 |
81 |
82 | 83 | 总进度 84 | 85 | {totalProgress}% 86 |
87 |
88 |
92 |
93 |
94 | 95 | {/* 分阶段进度 */} 96 |
97 | {/* 解压进度 */} 98 |
107 |
108 | 117 | 解压 118 | {(isUploading || isCompleted) && ( 119 | 120 | )} 121 |
122 | {extractProgress && ( 123 |

124 | {extractProgress.current} / {extractProgress.total} 125 |

126 | )} 127 |
128 | 129 | {/* 上传进度 */} 130 |
139 |
140 | 149 | 上传 150 | {isCompleted && } 151 |
152 |
153 | 154 | {uploadProgress.completed} 成功 155 | 156 | {uploadProgress.failed > 0 && ( 157 | 158 | {uploadProgress.failed} 失败 159 | 160 | )} 161 |
162 |
163 |
164 |
165 | ) 166 | } 167 | --------------------------------------------------------------------------------