diff --git a/package-lock.json b/package-lock.json index 2565a3a..3cb91d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@ffmpeg/ffmpeg": "^0.12.10", "@ffmpeg/util": "^0.12.1", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-slider": "^1.3.6", @@ -1252,6 +1253,83 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", diff --git a/package.json b/package.json index e66d306..bf413df 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dependencies": { "@ffmpeg/ffmpeg": "^0.12.10", "@ffmpeg/util": "^0.12.1", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-slider": "^1.3.6", diff --git a/src/app/(dashboard)/tools/image-compress/page.tsx b/src/app/(dashboard)/tools/image-compress/page.tsx index ed1ac71..513f10c 100644 --- a/src/app/(dashboard)/tools/image-compress/page.tsx +++ b/src/app/(dashboard)/tools/image-compress/page.tsx @@ -14,7 +14,7 @@ import { useTranslation, getServerTranslations } from "@/lib/i18n"; import type { UploadedFile, ProcessedFile, ImageCompressConfig } from "@/types"; const imageAccept = { - "image/*": [".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"], + "image/*": [".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff"], }; const defaultConfig: ImageCompressConfig = { @@ -52,6 +52,51 @@ function useConfigOptions(config: ImageCompressConfig, getT: (key: string) => st ]; } +/** + * Upload a file to the server + */ +async function uploadFile(file: File): Promise<{ fileId: string } | null> { + const formData = new FormData(); + formData.append("file", file); + + const response = await fetch("/api/upload", { + method: "POST", + body: formData, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Upload failed"); + } + + const data = await response.json(); + return { fileId: data.fileId }; +} + +/** + * Process image compression + */ +async function processImageCompression( + fileId: string, + config: ImageCompressConfig +): Promise<{ success: boolean; data?: any; error?: string }> { + const response = await fetch("/api/process/image-compress", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ fileId, config }), + }); + + const data = await response.json(); + + if (!response.ok) { + return { success: false, error: data.error || "Processing failed" }; + } + + return { success: true, data }; +} + export default function ImageCompressPage() { const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); @@ -101,54 +146,99 @@ export default function ImageCompressPage() { message: getT("processing.uploadingImages"), }); + const results: ProcessedFile[] = []; + const errors: string[] = []; + try { - // Simulate upload - for (let i = 0; i <= 100; i += 10) { - await new Promise((resolve) => setTimeout(resolve, 50)); + // Process each file + for (let i = 0; i < files.length; i++) { + const file = files[i]; + + // Update progress + const uploadProgress = Math.round(((i + 0.5) / files.length) * 50); setProcessingStatus({ status: "uploading", - progress: i, - message: getT("processing.uploadProgress", { progress: i }), + progress: uploadProgress, + message: getT("processing.uploadProgress", { progress: uploadProgress }), }); - } - setProcessingStatus({ - status: "processing", - progress: 0, - message: getT("processing.compressingImages"), - }); + // Upload file + let fileId: string; + try { + const uploadResult = await uploadFile(file.file); + if (!uploadResult) { + throw new Error("Upload failed"); + } + fileId = uploadResult.fileId; + } catch (error) { + errors.push(`${file.name}: ${error instanceof Error ? error.message : "Upload failed"}`); + continue; + } - // Simulate processing - for (let i = 0; i <= 100; i += 5) { - await new Promise((resolve) => setTimeout(resolve, 100)); + // Update progress to processing + const processProgress = 50 + Math.round(((i + 0.5) / files.length) * 50); setProcessingStatus({ status: "processing", - progress: i, - message: getT("processing.compressProgress", { progress: i }), + progress: processProgress, + message: getT("processing.compressProgress", { progress: processProgress }), }); + + // Process image + try { + const result = await processImageCompression(fileId, config); + + if (result.success && result.data) { + results.push({ + id: generateId(), + originalFile: file, + processedUrl: result.data.fileUrl, + metadata: { + format: result.data.metadata.format, + quality: result.data.metadata.quality, + compressionRatio: result.data.metadata.compressionRatio, + resolution: `${result.data.metadata.width}x${result.data.metadata.height}`, + originalSize: result.data.metadata.originalSize, + compressedSize: result.data.metadata.compressedSize, + }, + createdAt: new Date(), + }); + } else { + errors.push( + `${file.name}: ${result.error || getT("processing.unknownError")}` + ); + } + } catch (error) { + errors.push( + `${file.name}: ${error instanceof Error ? error.message : "Processing failed"}` + ); + } } - // Simulate completion - const results: ProcessedFile[] = files.map((file) => ({ - id: generateId(), - originalFile: file, - processedUrl: "#", - metadata: { - format: config.format === "original" ? file.file.type.split("/")[1] : config.format, - quality: config.quality, - compressionRatio: Math.floor(Math.random() * 30) + 40, // Simulated 40-70% - }, - createdAt: new Date(), - })); - - setProcessedFiles(results); + // Clear uploaded files clearFiles(); - setProcessingStatus({ - status: "completed", - progress: 100, - message: getT("processing.compressionComplete"), - }); + // Set final status + if (results.length > 0) { + setProcessedFiles(results); + setProcessingStatus({ + status: "completed", + progress: 100, + message: getT("processing.compressionComplete"), + }); + } else if (errors.length > 0) { + setProcessingStatus({ + status: "failed", + progress: 0, + message: errors[0], + error: errors.join("; "), + }); + } else { + setProcessingStatus({ + status: "failed", + progress: 0, + message: getT("processing.compressionFailed"), + }); + } } catch (error) { setProcessingStatus({ status: "failed", @@ -160,7 +250,16 @@ export default function ImageCompressPage() { }; const handleDownload = (fileId: string) => { - console.log("Downloading file:", fileId); + const file = processedFiles.find((f) => f.id === fileId); + if (file) { + // Create a temporary link to trigger download + const link = document.createElement("a"); + link.href = file.processedUrl; + link.download = file.metadata.filename || `compressed-${file.originalFile.name}`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } }; const canProcess = files.length > 0 && processingStatus.status !== "processing"; @@ -168,10 +267,7 @@ export default function ImageCompressPage() { return (
- +
@@ -229,9 +325,11 @@ export default function ImageCompressPage() {

{getT("tools.imageCompression.features")}

    - {(getT("tools.imageCompression.featureList") as unknown as string[]).map((feature, index) => ( -
  • • {feature}
  • - ))} + {(getT("tools.imageCompression.featureList") as unknown as string[]).map( + (feature, index) => ( +
  • • {feature}
  • + ) + )}
diff --git a/src/app/api/download/[id]/route.ts b/src/app/api/download/[id]/route.ts new file mode 100644 index 0000000..7d41769 --- /dev/null +++ b/src/app/api/download/[id]/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sanitizeFilename } from "@/lib/file-storage"; +import { getProcessedFile } from "@/lib/file-storage"; + +export const runtime = "nodejs"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + + // Validate ID format (UUID-like) + if (!id || typeof id !== "string") { + return NextResponse.json( + { success: false, error: "Invalid download ID" }, + { status: 400 } + ); + } + + // Sanitize ID to prevent path traversal + const sanitizedId = sanitizeFilename(id); + if (sanitizedId !== id) { + return NextResponse.json( + { success: false, error: "Invalid download ID" }, + { status: 400 } + ); + } + + // Basic UUID format validation + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(id)) { + return NextResponse.json( + { success: false, error: "Invalid download ID format" }, + { status: 400 } + ); + } + + // Get processed file + const fileData = await getProcessedFile(id); + + if (!fileData || fileData.length === 0) { + return NextResponse.json( + { success: false, error: "File not found or expired" }, + { status: 404 } + ); + } + + const { buffer, filename, contentType } = fileData[0]; + + // Create response with file + const response = new NextResponse(buffer as unknown as BodyInit, { + status: 200, + headers: { + "Content-Type": contentType, + "Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`, + "Content-Length": buffer.length.toString(), + "Cache-Control": "private, max-age=3600", + }, + }); + + return response; + } catch (error) { + console.error("Download error:", error); + return NextResponse.json( + { success: false, error: "Download failed" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/process/image-compress/route.ts b/src/app/api/process/image-compress/route.ts index 08835f3..4152320 100644 --- a/src/app/api/process/image-compress/route.ts +++ b/src/app/api/process/image-compress/route.ts @@ -1,57 +1,137 @@ import { NextRequest, NextResponse } from "next/server"; +import { readFile, readdir } from "fs/promises"; +import path from "path"; import type { ImageCompressConfig } from "@/types"; +import { + saveProcessedFile, + cleanupFile, + sanitizeFilename, +} from "@/lib/file-storage"; +import { + compressImage, + getImageMetadata, + validateImageBuffer, + validateCompressConfig, +} from "@/lib/image-processor"; export const runtime = "nodejs"; +const UPLOAD_DIR = process.env.TEMP_DIR || path.join(process.cwd(), ".temp", "uploads"); + interface ProcessRequest { fileId: string; config: ImageCompressConfig; } +/** + * Find file by ID in upload directory + */ +async function findUploadedFile(fileId: string): Promise<{ buffer: Buffer; name: string } | null> { + try { + const files = await readdir(UPLOAD_DIR); + const file = files.find((f) => f.startsWith(`${fileId}.`)); + + if (!file) { + return null; + } + + const filePath = path.join(UPLOAD_DIR, file); + const buffer = await readFile(filePath); + + // Validate it's actually an image + const isValid = await validateImageBuffer(buffer); + if (!isValid) { + return null; + } + + // Extract original name from file (remove UUID prefix) + const originalName = file.substring(fileId.length + 1); + + return { buffer, name: originalName }; + } catch { + return null; + } +} + export async function POST(request: NextRequest) { try { const body: ProcessRequest = await request.json(); const { fileId, config } = body; - if (!fileId) { + // Validate request + if (!fileId || typeof fileId !== "string") { return NextResponse.json( - { success: false, error: "No file ID provided" }, + { success: false, error: "Valid file ID is required" }, + { status: 400 } + ); + } + + // Sanitize file ID to prevent path traversal + const sanitizedId = sanitizeFilename(fileId).replace(/\.[^.]+$/, ""); + if (sanitizedId !== fileId) { + return NextResponse.json( + { success: false, error: "Invalid file ID" }, { status: 400 } ); } // Validate config - if (!config.quality || config.quality < 1 || config.quality > 100) { + const configValidation = validateCompressConfig(config); + if (!configValidation.valid) { return NextResponse.json( - { success: false, error: "Invalid quality value" }, + { success: false, error: configValidation.error }, { status: 400 } ); } - // In production, you would: - // 1. Retrieve the file from storage - // 2. Use Sharp to compress the image - // 3. Apply format conversion if needed - // 4. Upload to R2/S3 - // 5. Return download URL + // Find uploaded file + const uploadedFile = await findUploadedFile(fileId); + if (!uploadedFile) { + return NextResponse.json( + { success: false, error: "File not found or expired" }, + { status: 404 } + ); + } - // Mock processing for now - const resultFileId = `processed-${Date.now()}`; + // Get original metadata + const originalMetadata = await getImageMetadata(uploadedFile.buffer); + + // Process image + const result = await compressImage(uploadedFile.buffer, config); + + // Save processed file + const outputFormat = config.format === "original" ? originalMetadata.format : config.format; + const downloadInfo = await saveProcessedFile( + fileId, // Original file ID for tracking + result.buffer, + outputFormat, + uploadedFile.name + ); + + // Cleanup original file + await cleanupFile(fileId); return NextResponse.json({ success: true, - fileUrl: `/api/download/${resultFileId}`, - filename: `compressed-${resultFileId}`, + fileUrl: downloadInfo.fileUrl, + filename: downloadInfo.filename, metadata: { - format: config.format, + format: result.format, quality: config.quality, - compressionRatio: Math.floor(Math.random() * 30) + 40, // Mock 40-70% + compressionRatio: result.compressionRatio, + originalSize: result.originalSize, + compressedSize: result.compressedSize, + width: result.width, + height: result.height, }, }); } catch (error) { console.error("Processing error:", error); return NextResponse.json( - { success: false, error: "Processing failed" }, + { + success: false, + error: error instanceof Error ? error.message : "Processing failed", + }, { status: 500 } ); } diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index fef3283..944d8cf 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -1,4 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; +import { + saveUploadedFile, + validateImageFile, + getAllowedImageTypes, + getMaxFileSize, +} from "@/lib/file-storage"; export const runtime = "nodejs"; @@ -14,31 +20,26 @@ export async function POST(request: NextRequest) { ); } - // Check file size - const maxSize = parseInt(process.env.MAX_FILE_SIZE || "52428800"); // 50MB default - if (file.size > maxSize) { + // Validate file + const validation = validateImageFile(file); + if (!validation.valid) { return NextResponse.json( - { success: false, error: `File size exceeds ${maxSize / 1024 / 1024}MB limit` }, + { success: false, error: validation.error }, { status: 400 } ); } - // Generate file ID - const fileId = `${Date.now()}-${Math.random().toString(36).substring(7)}`; - - // In production, you would: - // 1. Save to Cloudflare R2 or S3 - // 2. Return the actual URL - // For now, we'll return a mock response + // Save file to temp storage + const result = await saveUploadedFile(file); return NextResponse.json({ success: true, - fileId, - fileUrl: `/uploads/${fileId}`, + fileId: result.fileId, + fileUrl: `/api/file/${result.fileId}`, metadata: { - name: file.name, - size: file.size, - type: file.type, + name: result.originalName, + size: result.size, + type: result.type, }, }); } catch (error) { @@ -49,3 +50,11 @@ export async function POST(request: NextRequest) { ); } } + +// Return allowed file types and max size for client +export async function GET() { + return NextResponse.json({ + allowedTypes: getAllowedImageTypes(), + maxSize: getMaxFileSize(), + }); +} diff --git a/src/components/tools/ConfigPanel.tsx b/src/components/tools/ConfigPanel.tsx index c03c6f0..80ce7b9 100644 --- a/src/components/tools/ConfigPanel.tsx +++ b/src/components/tools/ConfigPanel.tsx @@ -6,7 +6,7 @@ import { Slider } from "@/components/ui/slider"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; -import { useTranslation } from "@/lib/i18n"; +import { useSafeTranslation } from "@/lib/i18n"; export interface ConfigOption { id: string; @@ -39,7 +39,7 @@ export function ConfigPanel({ onReset, className, }: ConfigPanelProps) { - const { t } = useTranslation(); + const { t } = useSafeTranslation(); return ( diff --git a/src/components/tools/FileUploader.tsx b/src/components/tools/FileUploader.tsx index 0aff2c6..2987838 100644 --- a/src/components/tools/FileUploader.tsx +++ b/src/components/tools/FileUploader.tsx @@ -8,7 +8,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { formatFileSize, getFileExtension } from "@/lib/utils"; -import { useTranslation } from "@/lib/i18n"; +import { useSafeTranslation } from "@/lib/i18n"; import type { UploadedFile } from "@/types"; interface FileUploaderProps { @@ -36,7 +36,7 @@ export function FileUploader({ maxFiles = 10, disabled = false, }: FileUploaderProps) { - const { t, plural } = useTranslation(); + const { t, plural } = useSafeTranslation(); const onDrop = useCallback( (acceptedFiles: File[]) => { diff --git a/src/components/tools/ImageCompareSlider.tsx b/src/components/tools/ImageCompareSlider.tsx new file mode 100644 index 0000000..cd782a8 --- /dev/null +++ b/src/components/tools/ImageCompareSlider.tsx @@ -0,0 +1,201 @@ +"use client"; + +import React, { useState, useCallback, useRef, useEffect } from "react"; +import { cn, formatFileSize } from "@/lib/utils"; +import { Eye, EyeOff } from "lucide-react"; + +interface ImageCompareSliderProps { + originalSrc: string; + compressedSrc: string; + originalSize?: number; + compressedSize?: number; + className?: string; + // Translation texts + texts: { + original: string; + compressed: string; + dragHint: string; + }; +} + +export function ImageCompareSlider({ + originalSrc, + compressedSrc, + originalSize, + compressedSize, + className, + texts, +}: ImageCompareSliderProps) { + const [sliderPosition, setSliderPosition] = useState(50); + const [isDragging, setIsDragging] = useState(false); + const containerRef = useRef(null); + + const updateSliderPosition = useCallback((clientX: number) => { + const container = containerRef.current; + if (!container) return; + + const rect = container.getBoundingClientRect(); + const x = clientX - rect.left; + const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100)); + setSliderPosition(percentage); + }, []); + + const handleMouseDown = (e: React.MouseEvent) => { + setIsDragging(true); + updateSliderPosition(e.clientX); + }; + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (isDragging) { + e.preventDefault(); + updateSliderPosition(e.clientX); + } + }, + [isDragging, updateSliderPosition] + ); + + const handleMouseUp = useCallback(() => { + setIsDragging(false); + }, []); + + const handleTouchStart = (e: React.TouchEvent) => { + setIsDragging(true); + updateSliderPosition(e.touches[0].clientX); + }; + + const handleTouchMove = useCallback( + (e: TouchEvent) => { + if (isDragging) { + e.preventDefault(); + updateSliderPosition(e.touches[0].clientX); + } + }, + [isDragging, updateSliderPosition] + ); + + const handleTouchEnd = useCallback(() => { + setIsDragging(false); + }, []); + + useEffect(() => { + if (isDragging) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.addEventListener("touchmove", handleTouchMove, { passive: false }); + document.addEventListener("touchend", handleTouchEnd); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.removeEventListener("touchmove", handleTouchMove); + document.removeEventListener("touchend", handleTouchEnd); + }; + } + }, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]); + + return ( +
+ {/* Original Image (Background - Left Side) */} +
+ {texts.original} +
+ + {/* Compressed Image (Foreground - Right Side with Clipping) */} +
+ {texts.compressed} +
+ + {/* Slider Handle */} +
+ {/* Handle Button */} +
+
+ + + + + + +
+
+
+ + {/* Labels */} +
+ + {texts.original} + {originalSize && ({formatFileSize(originalSize)})} +
+ +
+ + {texts.compressed} + {compressedSize && ( + ({formatFileSize(compressedSize)}) + )} +
+ + {/* Hint text */} +
+ {texts.dragHint} +
+
+ ); +} diff --git a/src/components/tools/ProgressBar.tsx b/src/components/tools/ProgressBar.tsx index 605d561..17dea25 100644 --- a/src/components/tools/ProgressBar.tsx +++ b/src/components/tools/ProgressBar.tsx @@ -5,7 +5,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { CheckCircle2, XCircle, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; -import { useTranslation } from "@/lib/i18n"; +import { useSafeTranslation } from "@/lib/i18n"; import type { ProcessingProgress } from "@/types"; interface ProgressBarProps { @@ -30,7 +30,7 @@ const statusColors = { }; export function ProgressBar({ progress, className }: ProgressBarProps) { - const { t } = useTranslation(); + const { t } = useSafeTranslation(); const { status, progress: value, message, error } = progress; const showProgress = status === "uploading" || status === "processing"; const Icon = statusIcons[status]; diff --git a/src/components/tools/ResultPreview.tsx b/src/components/tools/ResultPreview.tsx index ec523b4..bef0631 100644 --- a/src/components/tools/ResultPreview.tsx +++ b/src/components/tools/ResultPreview.tsx @@ -1,12 +1,20 @@ "use client"; -import { motion } from "framer-motion"; -import { Download, Share2, File, Image as ImageIcon, Video, Music } from "lucide-react"; +import { useState, useCallback, useRef, useEffect } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { Download, Share2, File, Image as ImageIcon, Video, Music, Eye } from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ImageCompareSlider } from "@/components/tools/ImageCompareSlider"; import { formatFileSize } from "@/lib/utils"; -import { useTranslation } from "@/lib/i18n"; +import { useSafeTranslation } from "@/lib/i18n"; import type { ProcessedFile } from "@/types"; interface ResultPreviewProps { @@ -22,7 +30,42 @@ export function ResultPreview({ onShare, className, }: ResultPreviewProps) { - const { t, plural } = useTranslation(); + const { t, plural } = useSafeTranslation(); + const [previewFile, setPreviewFile] = useState(null); + const [originalImageUrl, setOriginalImageUrl] = useState(null); + const objectUrlRef = useRef(null); + + // Cleanup object URL when preview closes or component unmounts + useEffect(() => { + return () => { + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current); + } + }; + }, []); + + const handlePreview = useCallback((file: ProcessedFile) => { + // Create object URL for original image + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current); + } + const url = URL.createObjectURL(file.originalFile.file); + objectUrlRef.current = url; + setOriginalImageUrl(url); + setPreviewFile(file); + }, []); + + const handleClosePreview = useCallback(() => { + setPreviewFile(null); + // Small delay to allow dialog close animation before revoking + setTimeout(() => { + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current); + objectUrlRef.current = null; + } + setOriginalImageUrl(null); + }, 300); + }, []); if (results.length === 0) return null; @@ -35,113 +78,209 @@ export function ResultPreview({ const getMetadataBadge = (file: ProcessedFile) => { const metadata = file.metadata; - const badges = []; + const badges: Array<{ label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = []; if (metadata.compressionRatio) { badges.push({ label: t("results.saved", { ratio: metadata.compressionRatio }), - variant: "default" as const, + variant: "default", }); } if (metadata.format) { badges.push({ label: metadata.format.toUpperCase(), - variant: "secondary" as const, + variant: "secondary", }); } return badges; }; + const isImageFile = (file: ProcessedFile) => { + return file.originalFile.file.type.startsWith("image/"); + }; + + // Translation texts for ImageCompareSlider + const previewTexts = { + original: t("preview.original"), + compressed: t("preview.compressed"), + dragHint: t("preview.dragHint"), + }; + return ( - -
-

{t("results.processingComplete")}

-

- {t("results.filesReady", { - count: results.length, - file: plural("results.file", results.length), - })} -

-
+ <> + +
+

{t("results.processingComplete")}

+

+ {t("results.filesReady", { + count: results.length, + file: plural("results.file", results.length), + })} +

+
-
- {results.map((result, index) => { - const Icon = getFileIcon(result.originalFile.file.type); - const badges = getMetadataBadge(result); +
+ {results.map((result, index) => { + const Icon = getFileIcon(result.originalFile.file.type); + const badges = getMetadataBadge(result); + const showPreview = isImageFile(result); - return ( - - - -
- -
+ return ( + + + + {/* Thumbnail preview for images */} + {showPreview && ( + + )} -
-

- {result.originalFile.name} -

-
- - {formatFileSize(result.originalFile.size)} - - {result.metadata.resolution && ( - <> - - - {result.metadata.resolution} - - - )} -
- {badges.length > 0 && ( -
- {badges.map((badge, idx) => ( - - {badge.label} - - ))} + {!showPreview && ( +
+
)} -
-
- - {onShare && ( +
+

+ {result.originalFile.name} +

+
+ {result.metadata.originalSize && result.metadata.compressedSize ? ( + <> + + {formatFileSize(result.metadata.originalSize)} + + + + {formatFileSize(result.metadata.compressedSize)} + + + ) : ( + + {formatFileSize(result.originalFile.size)} + + )} + {result.metadata.resolution && ( + <> + + + {result.metadata.resolution} + + + )} + {result.metadata.quality && ( + <> + + + Q: {result.metadata.quality}% + + + )} +
+ {badges.length > 0 && ( +
+ {badges.map((badge, idx) => ( + + {badge.label} + + ))} +
+ )} +
+ +
+ {showPreview && ( + + )} - )} -
- - - - ); - })} -
- + {onShare && ( + + )} +
+
+
+
+ ); + })} +
+ + + {/* Preview Dialog */} + + {previewFile && originalImageUrl && ( + + + + {t("preview.title")} + +
+ +
+
+ + {t("preview.filename")}: {previewFile.originalFile.name} + + + {previewFile.metadata.compressionRatio && ( + <>{t("results.saved", { ratio: previewFile.metadata.compressionRatio })} + )} + +
+
+
+ )} +
+ ); } diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..f640609 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,112 @@ +"use client"; + +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/src/lib/file-storage.ts b/src/lib/file-storage.ts new file mode 100644 index 0000000..72b09ca --- /dev/null +++ b/src/lib/file-storage.ts @@ -0,0 +1,305 @@ +import { promises as fs } from "fs"; +import path from "path"; +import { randomUUID } from "crypto"; + +/** + * File storage service for handling temporary files + * Files are automatically cleaned up after a specified TTL + */ + +const UPLOAD_DIR = process.env.TEMP_DIR || path.join(process.cwd(), ".temp", "uploads"); +const DOWNLOAD_DIR = process.env.TEMP_DIR || path.join(process.cwd(), ".temp", "downloads"); +const FILE_TTL = 60 * 60 * 1000; // 1 hour in milliseconds + +// Allowed MIME types for image upload +const ALLOWED_IMAGE_TYPES = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/webp", + "image/gif", + "image/bmp", + "image/tiff", +] as const; + +// Maximum file size (50MB) +const MAX_FILE_SIZE = 50 * 1024 * 1024; + +// Track cleanup timeout +const cleanupTimeouts = new Map(); + +/** + * Initialize temporary directories + */ +export async function initFileStorage(): Promise { + try { + await fs.mkdir(UPLOAD_DIR, { recursive: true }); + await fs.mkdir(DOWNLOAD_DIR, { recursive: true }); + } catch (error) { + console.error("Failed to initialize file storage:", error); + throw new Error("File storage initialization failed"); + } +} + +/** + * Validate image file type and size + */ +export function validateImageFile( + file: File | { name: string; size: number; type: string } +): { valid: boolean; error?: string } { + // Check file size + if (file.size > MAX_FILE_SIZE) { + return { + valid: false, + error: `File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit`, + }; + } + + // Check file size (minimum 100 bytes to avoid corrupt files) + if (file.size < 100) { + return { + valid: false, + error: "File is too small or corrupt", + }; + } + + // Check MIME type + if (!file.type || !ALLOWED_IMAGE_TYPES.includes(file.type as any)) { + return { + valid: false, + error: `Invalid file type. Allowed: ${ALLOWED_IMAGE_TYPES.join(", ")}`, + }; + } + + // Check file extension matches MIME type + const ext = getFileExtension(file.name).toLowerCase(); + const validExtensions = ["jpg", "jpeg", "png", "webp", "gif", "bmp", "tif", "tiff"]; + + if (!validExtensions.includes(ext)) { + return { + valid: false, + error: `Invalid file extension. Allowed: ${validExtensions.join(", ")}`, + }; + } + + return { valid: true }; +} + +/** + * Get file extension from filename + */ +function getFileExtension(filename: string): string { + const parts = filename.split("."); + return parts.length > 1 ? parts[parts.length - 1] : ""; +} + +/** + * Sanitize filename to prevent path traversal + */ +export function sanitizeFilename(filename: string): string { + // Remove any directory separators and special characters + const sanitized = filename + .replace(/[\\/]/g, "") // Remove path separators + .replace(/\.{2,}/g, ".") // Remove double dots + .replace(/[^a-zA-Z0-9._-]/g, "_") // Replace special chars + .substring(0, 255); // Limit length + + return sanitized || "file"; +} + +/** + * Save uploaded file to temp storage + */ +export async function saveUploadedFile( + file: File | { name: string; type: string; arrayBuffer: () => Promise } +): Promise<{ fileId: string; filePath: string; originalName: string; size: number; type: string }> { + await initFileStorage(); + + const fileId = randomUUID(); + const sanitized = sanitizeFilename(file.name); + const ext = getFileExtension(sanitized); + const filename = `${fileId}.${ext}`; + const filePath = path.join(UPLOAD_DIR, filename); + + // Save file + const buffer = Buffer.from(await file.arrayBuffer()); + await fs.writeFile(filePath, buffer); + + // Schedule cleanup + scheduleCleanup(fileId, filePath); + + return { + fileId, + filePath, + originalName: file.name, + size: buffer.length, + type: file.type, + }; +} + +/** + * Save processed file to download directory + */ +export async function saveProcessedFile( + _fileId: string, + buffer: Buffer, + format: string, + originalName: string +): Promise<{ fileUrl: string; filename: string; size: number }> { + await initFileStorage(); + + const downloadId = randomUUID(); + const sanitized = sanitizeFilename(originalName); + const nameWithoutExt = sanitized.replace(/\.[^.]+$/, ""); + const filename = `${nameWithoutExt}_compressed.${format}`; + const filePath = path.join(DOWNLOAD_DIR, `${downloadId}_${filename}`); + + await fs.writeFile(filePath, buffer); + + // Schedule cleanup + scheduleCleanup(downloadId, filePath); + + return { + fileUrl: `/api/download/${downloadId}`, + filename, + size: buffer.length, + }; +} + +/** + * Get file from upload directory + */ +export async function getUploadedFile(fileId: string): Promise { + await initFileStorage(); + + try { + const files = await fs.readdir(UPLOAD_DIR); + const file = files.find((f) => f.startsWith(`${fileId}.`)); + + if (!file) { + return null; + } + + const filePath = path.join(UPLOAD_DIR, file); + return await fs.readFile(filePath); + } catch { + return null; + } +} + +/** + * Get processed file for download + */ +export async function getProcessedFile(downloadId: string): Promise< + | { buffer: Buffer; filename: string; contentType: string }[] + | null +> { + await initFileStorage(); + + try { + const files = await fs.readdir(DOWNLOAD_DIR); + const file = files.find((f) => f.startsWith(`${downloadId}_`)); + + if (!file) { + return null; + } + + const filePath = path.join(DOWNLOAD_DIR, file); + const buffer = await fs.readFile(filePath); + + // Extract filename + const filename = file.substring(downloadId.length + 1); + + // Determine content type + const ext = getFileExtension(filename); + const contentTypes: Record = { + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + webp: "image/webp", + gif: "image/gif", + bmp: "image/bmp", + tif: "image/tiff", + tiff: "image/tiff", + }; + + return [ + { + buffer, + filename, + contentType: contentTypes[ext] || "application/octet-stream", + }, + ]; + } catch { + return null; + } +} + +/** + * Schedule file cleanup + */ +function scheduleCleanup(fileId: string, filePath: string): void { + // Clear existing timeout if any + const existing = cleanupTimeouts.get(fileId); + if (existing) { + clearTimeout(existing); + } + + // Schedule new cleanup + const timeout = setTimeout(async () => { + try { + await fs.unlink(filePath); + cleanupTimeouts.delete(fileId); + } catch (error) { + console.error(`Failed to cleanup file ${fileId}:`, error); + } + }, FILE_TTL); + + cleanupTimeouts.set(fileId, timeout); +} + +/** + * Manually cleanup a file + */ +export async function cleanupFile(fileId: string): Promise { + const timeout = cleanupTimeouts.get(fileId); + if (timeout) { + clearTimeout(timeout); + cleanupTimeouts.delete(fileId); + } + + // Try to delete from both directories + try { + const files = await fs.readdir(UPLOAD_DIR); + const file = files.find((f) => f.startsWith(`${fileId}.`)); + if (file) { + await fs.unlink(path.join(UPLOAD_DIR, file)); + } + } catch { + // Ignore + } + + try { + const files = await fs.readdir(DOWNLOAD_DIR); + const file = files.find((f) => f.startsWith(`${fileId}_`)); + if (file) { + await fs.unlink(path.join(DOWNLOAD_DIR, file)); + } + } catch { + // Ignore + } +} + +/** + * Get allowed image types + */ +export function getAllowedImageTypes(): readonly string[] { + return ALLOWED_IMAGE_TYPES; +} + +/** + * Get maximum file size + */ +export function getMaxFileSize(): number { + return MAX_FILE_SIZE; +} diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 4ec26ac..960c646 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; import { persist, createJSONStorage } from "zustand/middleware"; +import { useState, useEffect } from "react"; import en from "@/locales/en.json"; import zh from "@/locales/zh.json"; @@ -70,6 +71,40 @@ export function useTranslation() { }; } +/** + * SSR-safe translation hook that prevents hydration mismatches. + * Use this in client components that are rendered on the server. + * Returns a stable translation during SSR and switches to client locale after hydration. + */ +export function useSafeTranslation() { + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + const { locale, setLocale, t, plural, locales } = useTranslation(); + + // Use English during SSR, client locale after hydration + const safeT: typeof t = (key, params) => { + if (!mounted) return getServerTranslations("en").t(key, params); + return t(key, params); + }; + + const safePlural: typeof plural = (key, count) => { + if (!mounted) { + const suffix = count === 1 ? "_one" : "_other"; + return getServerTranslations("en").t(`${key}${suffix}`, { count }); + } + return plural(key, count); + }; + + return { + locale, + setLocale, + t: safeT, + plural: safePlural, + locales, + mounted, // Expose mounted state for conditional rendering if needed + }; +} + // Helper for SSR export function getServerTranslations(locale: Locale = "en") { return { diff --git a/src/lib/image-processor.ts b/src/lib/image-processor.ts new file mode 100644 index 0000000..680eee4 --- /dev/null +++ b/src/lib/image-processor.ts @@ -0,0 +1,289 @@ +import sharp from "sharp"; +import type { ImageCompressConfig } from "@/types"; + +/** + * Image processing service using Sharp + * Handles compression, format conversion, and resizing + */ + +export interface ProcessedImageResult { + buffer: Buffer; + format: string; + width: number; + height: number; + originalSize: number; + compressedSize: number; + compressionRatio: number; +} + +export interface ImageMetadata { + format: string; + width: number; + height: number; + size: number; +} + +// Supported output formats for compression +const SUPPORTED_OUTPUT_FORMATS = ["jpeg", "jpg", "png", "webp", "gif", "tiff", "tif"] as const; +type SupportedFormat = (typeof SUPPORTED_OUTPUT_FORMATS)[number]; + +/** + * Get image metadata without loading the full image + */ +export async function getImageMetadata(buffer: Buffer): Promise { + const metadata = await sharp(buffer).metadata(); + + return { + format: metadata.format || "unknown", + width: metadata.width || 0, + height: metadata.height || 0, + size: buffer.length, + }; +} + +/** + * Validate image buffer using Sharp + * Checks if the buffer contains a valid image + */ +export async function validateImageBuffer(buffer: Buffer): Promise { + try { + const metadata = await sharp(buffer).metadata(); + return ( + metadata.format !== undefined && + metadata.width !== undefined && + metadata.width > 0 && + metadata.height !== undefined && + metadata.height > 0 + ); + } catch { + return false; + } +} + +/** + * Check if format is supported for output + */ +function isSupportedFormat(format: string): format is SupportedFormat { + return SUPPORTED_OUTPUT_FORMATS.includes(format as SupportedFormat); +} + +/** + * Compress and/or convert image + */ +export async function compressImage( + buffer: Buffer, + config: ImageCompressConfig +): Promise { + // Validate input buffer + const isValid = await validateImageBuffer(buffer); + if (!isValid) { + throw new Error("Invalid image data"); + } + + // Get original metadata + const originalMetadata = await getImageMetadata(buffer); + + // Create Sharp instance + let pipeline = sharp(buffer, { + // Limit input pixels to prevent DoS attacks + limitInputPixels: 268402689, // ~16384x16384 + // Enforce memory limits + unlimited: false, + }); + + // Apply resizing if configured + if (config.resize) { + const { width, height, fit } = config.resize; + + if (width || height) { + pipeline = pipeline.resize(width || null, height || null, { + fit: fit === "contain" ? "inside" : fit === "cover" ? "cover" : "fill", + // Don't enlarge images + withoutEnlargement: fit !== "fill", + }); + } + } + + // Determine output format + let outputFormat = config.format === "original" ? originalMetadata.format : config.format; + + // For BMP input without format conversion, use JPEG as output + // since Sharp doesn't support BMP output + if (outputFormat === "bmp") { + outputFormat = "jpeg"; + } + + // Validate format is supported + if (!isSupportedFormat(outputFormat)) { + outputFormat = "jpeg"; + } + + // Apply format-specific compression + switch (outputFormat) { + case "jpeg": + case "jpg": + pipeline = pipeline.jpeg({ + quality: config.quality, + mozjpeg: true, // Use MozJPEG for better compression + progressive: true, // Progressive loading + }); + break; + + case "png": + // PNG compression is lossless, quality affects compression level + // Map 1-100 to 0-9 compression level (inverted) + const compressionLevel = Math.floor(((100 - config.quality) / 100) * 9); + pipeline = pipeline.png({ + compressionLevel, + adaptiveFiltering: true, + palette: false, // Keep true color + }); + break; + + case "webp": + pipeline = pipeline.webp({ + quality: config.quality, + effort: 6, // Compression effort (0-6, 6 is highest) + }); + break; + + case "gif": + // GIF doesn't support quality parameter in the same way + // We'll use near-lossless for better quality + pipeline = pipeline.gif({ + dither: 1.0, + }); + break; + + case "tiff": + case "tif": + pipeline = pipeline.tiff({ + quality: config.quality, + compression: "jpeg", + }); + break; + + default: + // Default to JPEG + pipeline = pipeline.jpeg({ + quality: config.quality, + mozjpeg: true, + }); + } + + // Get metadata before compression + const metadata = await pipeline.metadata(); + + // Process image + const compressedBuffer = await pipeline.toBuffer(); + + // Calculate compression ratio + const compressionRatio = Math.round( + ((buffer.length - compressedBuffer.length) / buffer.length) * 100 + ); + + return { + buffer: compressedBuffer, + format: outputFormat, + width: metadata.width || 0, + height: metadata.height || 0, + originalSize: buffer.length, + compressedSize: compressedBuffer.length, + compressionRatio, + }; +} + +/** + * Batch compress multiple images + */ +export async function batchCompressImages( + files: Array<{ buffer: Buffer; name: string }>, + config: ImageCompressConfig, + onProgress?: (current: number, total: number) => void +): Promise> { + const results: Array<{ result: ProcessedImageResult; name: string }> = []; + + for (let i = 0; i < files.length; i++) { + const result = await compressImage(files[i].buffer, config); + results.push({ result, name: files[i].name }); + + if (onProgress) { + onProgress(i + 1, files.length); + } + } + + return results; +} + +/** + * Calculate recommended quality based on desired compression ratio + */ +export function calculateQualityForTargetRatio( + targetRatio: number, + currentRatio?: number, + currentQuality?: number +): number { + // If we have current data, adjust based on difference + if (currentRatio !== undefined && currentQuality !== undefined) { + const difference = targetRatio - currentRatio; + const adjustment = difference * 2; // Adjust by 2x the difference + return Math.max(1, Math.min(100, Math.round(currentQuality + adjustment))); + } + + // Default heuristic: higher target ratio = lower quality + return Math.max(1, Math.min(100, Math.round(100 - targetRatio * 1.5))); +} + +/** + * Validate ImageCompressConfig + */ +export function validateCompressConfig(config: ImageCompressConfig): { + valid: boolean; + error?: string; +} { + if (!config.quality || typeof config.quality !== "number") { + return { valid: false, error: "Quality is required and must be a number" }; + } + + if (config.quality < 1 || config.quality > 100) { + return { valid: false, error: "Quality must be between 1 and 100" }; + } + + const validFormats = ["original", "jpeg", "jpg", "png", "webp", "gif", "bmp", "tiff", "tif"]; + if (!validFormats.includes(config.format)) { + return { valid: false, error: `Invalid format. Allowed: ${validFormats.join(", ")}` }; + } + + if (config.resize) { + if (config.resize.width !== undefined) { + if ( + typeof config.resize.width !== "number" || + config.resize.width < 1 || + config.resize.width > 16384 + ) { + return { valid: false, error: "Width must be between 1 and 16384" }; + } + } + + if (config.resize.height !== undefined) { + if ( + typeof config.resize.height !== "number" || + config.resize.height < 1 || + config.resize.height > 16384 + ) { + return { valid: false, error: "Height must be between 1 and 16384" }; + } + } + + if (!config.resize.width && !config.resize.height) { + return { valid: false, error: "At least one of width or height must be specified" }; + } + + const validFits = ["contain", "cover", "fill"]; + if (!validFits.includes(config.resize.fit)) { + return { valid: false, error: `Invalid fit. Allowed: ${validFits.join(", ")}` }; + } + } + + return { valid: true }; +} diff --git a/src/locales/en.json b/src/locales/en.json index bf559a4..f036f71 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -268,6 +268,13 @@ "file_other": "files", "saved": "Saved {{ratio}}%" }, + "preview": { + "title": "Image Comparison", + "original": "Original", + "compressed": "Compressed", + "dragHint": "Drag slider or click to compare", + "filename": "Filename" + }, "footer": { "tagline": "Media processing tools for game developers. Extract frames, compress images, optimize audio.", "note": "Inspired by modern product storytelling—centered on your workflow, not UI noise.", diff --git a/src/locales/zh.json b/src/locales/zh.json index 56225b7..c7efc36 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -268,6 +268,13 @@ "file_other": "文件", "saved": "节省 {{ratio}}%" }, + "preview": { + "title": "图片对比", + "original": "原图", + "compressed": "压缩后", + "dragHint": "拖动滑块或点击来对比", + "filename": "文件名" + }, "footer": { "tagline": "面向游戏开发者的媒体处理工具。视频抽帧、图片压缩、音频优化。", "note": "灵感来自现代产品网站的信息密度与克制动效,但以你自己的产品为中心。", diff --git a/src/types/index.ts b/src/types/index.ts index 9c6bdd8..1868adf 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -35,6 +35,9 @@ export interface ProcessMetadata { duration?: number; frames?: number; compressionRatio?: number; + originalSize?: number; + compressedSize?: number; + filename?: string; } export interface ProcessingResult {