feat: 实现图片压缩功能
This commit is contained in:
78
package-lock.json
generated
78
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Update progress to processing
|
||||
const processProgress = 50 + Math.round(((i + 0.5) / files.length) * 50);
|
||||
setProcessingStatus({
|
||||
status: "processing",
|
||||
progress: 0,
|
||||
message: getT("processing.compressingImages"),
|
||||
progress: processProgress,
|
||||
message: getT("processing.compressProgress", { progress: processProgress }),
|
||||
});
|
||||
|
||||
// Simulate processing
|
||||
for (let i = 0; i <= 100; i += 5) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
setProcessingStatus({
|
||||
status: "processing",
|
||||
progress: i,
|
||||
message: getT("processing.compressProgress", { progress: i }),
|
||||
});
|
||||
}
|
||||
// Process image
|
||||
try {
|
||||
const result = await processImageCompression(fileId, config);
|
||||
|
||||
// Simulate completion
|
||||
const results: ProcessedFile[] = files.map((file) => ({
|
||||
if (result.success && result.data) {
|
||||
results.push({
|
||||
id: generateId(),
|
||||
originalFile: file,
|
||||
processedUrl: "#",
|
||||
processedUrl: result.data.fileUrl,
|
||||
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%
|
||||
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"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setProcessedFiles(results);
|
||||
// Clear uploaded files
|
||||
clearFiles();
|
||||
|
||||
// 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 (
|
||||
<div className="p-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
@@ -229,9 +325,11 @@ export default function ImageCompressPage() {
|
||||
<div className="rounded-lg border border-border/40 bg-card/50 p-6">
|
||||
<h3 className="mb-3 font-semibold">{getT("tools.imageCompression.features")}</h3>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{(getT("tools.imageCompression.featureList") as unknown as string[]).map((feature, index) => (
|
||||
{(getT("tools.imageCompression.featureList") as unknown as string[]).map(
|
||||
(feature, index) => (
|
||||
<li key={index}>• {feature}</li>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
72
src/app/api/download/[id]/route.ts
Normal file
72
src/app/api/download/[id]/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Card className={className}>
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
201
src/components/tools/ImageCompareSlider.tsx
Normal file
201
src/components/tools/ImageCompareSlider.tsx
Normal file
@@ -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<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-lg bg-muted/50 select-none",
|
||||
isDragging && "cursor-col-resize",
|
||||
className
|
||||
)}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
>
|
||||
{/* Original Image (Background - Left Side) */}
|
||||
<div className="relative w-full">
|
||||
<img
|
||||
src={originalSrc}
|
||||
alt={texts.original}
|
||||
className="w-full h-auto object-contain max-h-[60vh] block"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Compressed Image (Foreground - Right Side with Clipping) */}
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden pointer-events-none"
|
||||
style={{ clipPath: `inset(0 0 0 ${sliderPosition}%)` }}
|
||||
>
|
||||
<img
|
||||
src={compressedSrc}
|
||||
alt={texts.compressed}
|
||||
className="w-full h-full object-contain max-h-[60vh]"
|
||||
draggable={false}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Slider Handle */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-white cursor-col-resize pointer-events-none"
|
||||
style={{ left: `${sliderPosition}%` }}
|
||||
>
|
||||
{/* Handle Button */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-white shadow-lg flex items-center justify-center pointer-events-auto">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4 2L8 6L4 10"
|
||||
stroke="#1a1a1a"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 2L4 6L8 10"
|
||||
stroke="#1a1a1a"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
<div className="absolute top-3 left-3 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-black/60 backdrop-blur-sm text-white text-xs font-medium">
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
<span>{texts.original}</span>
|
||||
{originalSize && <span className="text-white/70">({formatFileSize(originalSize)})</span>}
|
||||
</div>
|
||||
|
||||
<div className="absolute top-3 right-3 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-primary/80 backdrop-blur-sm text-primary-foreground text-xs font-medium">
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
<span>{texts.compressed}</span>
|
||||
{compressedSize && (
|
||||
<span className="text-primary-foreground/80">({formatFileSize(compressedSize)})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hint text */}
|
||||
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full bg-black/50 backdrop-blur-sm text-white/70 text-xs">
|
||||
{texts.dragHint}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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<ProcessedFile | null>(null);
|
||||
const [originalImageUrl, setOriginalImageUrl] = useState<string | null>(null);
|
||||
const objectUrlRef = useRef<string | null>(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,26 +78,38 @@ 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 (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -74,6 +129,7 @@ export function ResultPreview({
|
||||
{results.map((result, index) => {
|
||||
const Icon = getFileIcon(result.originalFile.file.type);
|
||||
const badges = getMetadataBadge(result);
|
||||
const showPreview = isImageFile(result);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -82,20 +138,51 @@ export function ResultPreview({
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
<Card>
|
||||
<Card className="group overflow-hidden">
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
{/* Thumbnail preview for images */}
|
||||
{showPreview && (
|
||||
<button
|
||||
onClick={() => handlePreview(result)}
|
||||
className="relative shrink-0 h-12 w-12 rounded-lg overflow-hidden bg-muted/50 hover:ring-2 hover:ring-primary/50 transition-all cursor-zoom-in"
|
||||
>
|
||||
<img
|
||||
src={result.processedUrl}
|
||||
alt={result.originalFile.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all flex items-center justify-center opacity-0 group-hover:opacity-100">
|
||||
<Eye className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!showPreview && (
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{result.originalFile.name}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{result.metadata.originalSize && result.metadata.compressedSize ? (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground line-through">
|
||||
{formatFileSize(result.metadata.originalSize)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">→</span>
|
||||
<span className="text-xs text-foreground font-medium">
|
||||
{formatFileSize(result.metadata.compressedSize)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatFileSize(result.originalFile.size)}
|
||||
</span>
|
||||
)}
|
||||
{result.metadata.resolution && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">•</span>
|
||||
@@ -104,6 +191,14 @@ export function ResultPreview({
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{result.metadata.quality && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">•</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Q: {result.metadata.quality}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{badges.length > 0 && (
|
||||
<div className="mt-2 flex gap-2">
|
||||
@@ -117,6 +212,17 @@ export function ResultPreview({
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{showPreview && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handlePreview(result)}
|
||||
title={t("preview.title")}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@@ -143,5 +249,38 @@ export function ResultPreview({
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Preview Dialog */}
|
||||
<AnimatePresence>
|
||||
{previewFile && originalImageUrl && (
|
||||
<Dialog open={!!previewFile} onOpenChange={handleClosePreview}>
|
||||
<DialogContent className="p-0 gap-0 max-w-5xl w-[95vw] bg-background/95 backdrop-blur-xl border-border/40">
|
||||
<DialogHeader className="p-4 pb-0">
|
||||
<DialogTitle className="text-base">{t("preview.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="p-4 pt-2">
|
||||
<ImageCompareSlider
|
||||
originalSrc={originalImageUrl}
|
||||
compressedSrc={previewFile.processedUrl}
|
||||
originalSize={previewFile.metadata.originalSize}
|
||||
compressedSize={previewFile.metadata.compressedSize}
|
||||
texts={previewTexts}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 pt-0 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("preview.filename")}: {previewFile.originalFile.name}
|
||||
</span>
|
||||
<span>
|
||||
{previewFile.metadata.compressionRatio && (
|
||||
<>{t("results.saved", { ratio: previewFile.metadata.compressionRatio })}</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
112
src/components/ui/dialog.tsx
Normal file
112
src/components/ui/dialog.tsx
Normal file
@@ -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<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-4xl translate-x-[-50%] translate-y-[-50%] gap-4 border border-border/40 bg-background/95 p-6 shadow-lg backdrop-blur-md duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-5 w-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
305
src/lib/file-storage.ts
Normal file
305
src/lib/file-storage.ts
Normal file
@@ -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<string, NodeJS.Timeout>();
|
||||
|
||||
/**
|
||||
* Initialize temporary directories
|
||||
*/
|
||||
export async function initFileStorage(): Promise<void> {
|
||||
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<ArrayBuffer> }
|
||||
): 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<Buffer | null> {
|
||||
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<string, string> = {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
289
src/lib/image-processor.ts
Normal file
289
src/lib/image-processor.ts
Normal file
@@ -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<ImageMetadata> {
|
||||
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<boolean> {
|
||||
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<ProcessedImageResult> {
|
||||
// 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<Array<{ result: ProcessedImageResult; name: string }>> {
|
||||
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 };
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -268,6 +268,13 @@
|
||||
"file_other": "文件",
|
||||
"saved": "节省 {{ratio}}%"
|
||||
},
|
||||
"preview": {
|
||||
"title": "图片对比",
|
||||
"original": "原图",
|
||||
"compressed": "压缩后",
|
||||
"dragHint": "拖动滑块或点击来对比",
|
||||
"filename": "文件名"
|
||||
},
|
||||
"footer": {
|
||||
"tagline": "面向游戏开发者的媒体处理工具。视频抽帧、图片压缩、音频优化。",
|
||||
"note": "灵感来自现代产品网站的信息密度与克制动效,但以你自己的产品为中心。",
|
||||
|
||||
@@ -35,6 +35,9 @@ export interface ProcessMetadata {
|
||||
duration?: number;
|
||||
frames?: number;
|
||||
compressionRatio?: number;
|
||||
originalSize?: number;
|
||||
compressedSize?: number;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export interface ProcessingResult {
|
||||
|
||||
Reference in New Issue
Block a user