feat: 实现图片压缩功能
This commit is contained in:
78
package-lock.json
generated
78
package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ffmpeg/ffmpeg": "^0.12.10",
|
"@ffmpeg/ffmpeg": "^0.12.10",
|
||||||
"@ffmpeg/util": "^0.12.1",
|
"@ffmpeg/util": "^0.12.1",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@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": {
|
"node_modules/@radix-ui/react-direction": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ffmpeg/ffmpeg": "^0.12.10",
|
"@ffmpeg/ffmpeg": "^0.12.10",
|
||||||
"@ffmpeg/util": "^0.12.1",
|
"@ffmpeg/util": "^0.12.1",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { useTranslation, getServerTranslations } from "@/lib/i18n";
|
|||||||
import type { UploadedFile, ProcessedFile, ImageCompressConfig } from "@/types";
|
import type { UploadedFile, ProcessedFile, ImageCompressConfig } from "@/types";
|
||||||
|
|
||||||
const imageAccept = {
|
const imageAccept = {
|
||||||
"image/*": [".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"],
|
"image/*": [".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultConfig: ImageCompressConfig = {
|
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() {
|
export default function ImageCompressPage() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
useEffect(() => setMounted(true), []);
|
useEffect(() => setMounted(true), []);
|
||||||
@@ -101,54 +146,99 @@ export default function ImageCompressPage() {
|
|||||||
message: getT("processing.uploadingImages"),
|
message: getT("processing.uploadingImages"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const results: ProcessedFile[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Simulate upload
|
// Process each file
|
||||||
for (let i = 0; i <= 100; i += 10) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
const file = files[i];
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
const uploadProgress = Math.round(((i + 0.5) / files.length) * 50);
|
||||||
setProcessingStatus({
|
setProcessingStatus({
|
||||||
status: "uploading",
|
status: "uploading",
|
||||||
progress: i,
|
progress: uploadProgress,
|
||||||
message: getT("processing.uploadProgress", { progress: i }),
|
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({
|
setProcessingStatus({
|
||||||
status: "processing",
|
status: "processing",
|
||||||
progress: 0,
|
progress: processProgress,
|
||||||
message: getT("processing.compressingImages"),
|
message: getT("processing.compressProgress", { progress: processProgress }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate processing
|
// Process image
|
||||||
for (let i = 0; i <= 100; i += 5) {
|
try {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
const result = await processImageCompression(fileId, config);
|
||||||
setProcessingStatus({
|
|
||||||
status: "processing",
|
|
||||||
progress: i,
|
|
||||||
message: getT("processing.compressProgress", { progress: i }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulate completion
|
if (result.success && result.data) {
|
||||||
const results: ProcessedFile[] = files.map((file) => ({
|
results.push({
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
originalFile: file,
|
originalFile: file,
|
||||||
processedUrl: "#",
|
processedUrl: result.data.fileUrl,
|
||||||
metadata: {
|
metadata: {
|
||||||
format: config.format === "original" ? file.file.type.split("/")[1] : config.format,
|
format: result.data.metadata.format,
|
||||||
quality: config.quality,
|
quality: result.data.metadata.quality,
|
||||||
compressionRatio: Math.floor(Math.random() * 30) + 40, // Simulated 40-70%
|
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(),
|
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();
|
clearFiles();
|
||||||
|
|
||||||
|
// Set final status
|
||||||
|
if (results.length > 0) {
|
||||||
|
setProcessedFiles(results);
|
||||||
setProcessingStatus({
|
setProcessingStatus({
|
||||||
status: "completed",
|
status: "completed",
|
||||||
progress: 100,
|
progress: 100,
|
||||||
message: getT("processing.compressionComplete"),
|
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) {
|
} catch (error) {
|
||||||
setProcessingStatus({
|
setProcessingStatus({
|
||||||
status: "failed",
|
status: "failed",
|
||||||
@@ -160,7 +250,16 @@ export default function ImageCompressPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = (fileId: string) => {
|
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";
|
const canProcess = files.length > 0 && processingStatus.status !== "processing";
|
||||||
@@ -168,10 +267,7 @@ export default function ImageCompressPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<motion.div
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}>
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
>
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
<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">
|
<div className="rounded-lg border border-border/40 bg-card/50 p-6">
|
||||||
<h3 className="mb-3 font-semibold">{getT("tools.imageCompression.features")}</h3>
|
<h3 className="mb-3 font-semibold">{getT("tools.imageCompression.features")}</h3>
|
||||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
<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>
|
<li key={index}>• {feature}</li>
|
||||||
))}
|
)
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</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 { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { readFile, readdir } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
import type { ImageCompressConfig } from "@/types";
|
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";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const UPLOAD_DIR = process.env.TEMP_DIR || path.join(process.cwd(), ".temp", "uploads");
|
||||||
|
|
||||||
interface ProcessRequest {
|
interface ProcessRequest {
|
||||||
fileId: string;
|
fileId: string;
|
||||||
config: ImageCompressConfig;
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body: ProcessRequest = await request.json();
|
const body: ProcessRequest = await request.json();
|
||||||
const { fileId, config } = body;
|
const { fileId, config } = body;
|
||||||
|
|
||||||
if (!fileId) {
|
// Validate request
|
||||||
|
if (!fileId || typeof fileId !== "string") {
|
||||||
return NextResponse.json(
|
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 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate config
|
// Validate config
|
||||||
if (!config.quality || config.quality < 1 || config.quality > 100) {
|
const configValidation = validateCompressConfig(config);
|
||||||
|
if (!configValidation.valid) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: "Invalid quality value" },
|
{ success: false, error: configValidation.error },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// In production, you would:
|
// Find uploaded file
|
||||||
// 1. Retrieve the file from storage
|
const uploadedFile = await findUploadedFile(fileId);
|
||||||
// 2. Use Sharp to compress the image
|
if (!uploadedFile) {
|
||||||
// 3. Apply format conversion if needed
|
return NextResponse.json(
|
||||||
// 4. Upload to R2/S3
|
{ success: false, error: "File not found or expired" },
|
||||||
// 5. Return download URL
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Mock processing for now
|
// Get original metadata
|
||||||
const resultFileId = `processed-${Date.now()}`;
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
fileUrl: `/api/download/${resultFileId}`,
|
fileUrl: downloadInfo.fileUrl,
|
||||||
filename: `compressed-${resultFileId}`,
|
filename: downloadInfo.filename,
|
||||||
metadata: {
|
metadata: {
|
||||||
format: config.format,
|
format: result.format,
|
||||||
quality: config.quality,
|
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) {
|
} catch (error) {
|
||||||
console.error("Processing error:", error);
|
console.error("Processing error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: "Processing failed" },
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Processing failed",
|
||||||
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
saveUploadedFile,
|
||||||
|
validateImageFile,
|
||||||
|
getAllowedImageTypes,
|
||||||
|
getMaxFileSize,
|
||||||
|
} from "@/lib/file-storage";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@@ -14,31 +20,26 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check file size
|
// Validate file
|
||||||
const maxSize = parseInt(process.env.MAX_FILE_SIZE || "52428800"); // 50MB default
|
const validation = validateImageFile(file);
|
||||||
if (file.size > maxSize) {
|
if (!validation.valid) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: `File size exceeds ${maxSize / 1024 / 1024}MB limit` },
|
{ success: false, error: validation.error },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate file ID
|
// Save file to temp storage
|
||||||
const fileId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
const result = await saveUploadedFile(file);
|
||||||
|
|
||||||
// In production, you would:
|
|
||||||
// 1. Save to Cloudflare R2 or S3
|
|
||||||
// 2. Return the actual URL
|
|
||||||
// For now, we'll return a mock response
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
fileId,
|
fileId: result.fileId,
|
||||||
fileUrl: `/uploads/${fileId}`,
|
fileUrl: `/api/file/${result.fileId}`,
|
||||||
metadata: {
|
metadata: {
|
||||||
name: file.name,
|
name: result.originalName,
|
||||||
size: file.size,
|
size: result.size,
|
||||||
type: file.type,
|
type: result.type,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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 { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useSafeTranslation } from "@/lib/i18n";
|
||||||
|
|
||||||
export interface ConfigOption {
|
export interface ConfigOption {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -39,7 +39,7 @@ export function ConfigPanel({
|
|||||||
onReset,
|
onReset,
|
||||||
className,
|
className,
|
||||||
}: ConfigPanelProps) {
|
}: ConfigPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useSafeTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { formatFileSize, getFileExtension } from "@/lib/utils";
|
import { formatFileSize, getFileExtension } from "@/lib/utils";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useSafeTranslation } from "@/lib/i18n";
|
||||||
import type { UploadedFile } from "@/types";
|
import type { UploadedFile } from "@/types";
|
||||||
|
|
||||||
interface FileUploaderProps {
|
interface FileUploaderProps {
|
||||||
@@ -36,7 +36,7 @@ export function FileUploader({
|
|||||||
maxFiles = 10,
|
maxFiles = 10,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: FileUploaderProps) {
|
}: FileUploaderProps) {
|
||||||
const { t, plural } = useTranslation();
|
const { t, plural } = useSafeTranslation();
|
||||||
|
|
||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
(acceptedFiles: File[]) => {
|
(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 { Progress } from "@/components/ui/progress";
|
||||||
import { CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
import { CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useSafeTranslation } from "@/lib/i18n";
|
||||||
import type { ProcessingProgress } from "@/types";
|
import type { ProcessingProgress } from "@/types";
|
||||||
|
|
||||||
interface ProgressBarProps {
|
interface ProgressBarProps {
|
||||||
@@ -30,7 +30,7 @@ const statusColors = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ProgressBar({ progress, className }: ProgressBarProps) {
|
export function ProgressBar({ progress, className }: ProgressBarProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useSafeTranslation();
|
||||||
const { status, progress: value, message, error } = progress;
|
const { status, progress: value, message, error } = progress;
|
||||||
const showProgress = status === "uploading" || status === "processing";
|
const showProgress = status === "uploading" || status === "processing";
|
||||||
const Icon = statusIcons[status];
|
const Icon = statusIcons[status];
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
import { Download, Share2, File, Image as ImageIcon, Video, Music } from "lucide-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 { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 { formatFileSize } from "@/lib/utils";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useSafeTranslation } from "@/lib/i18n";
|
||||||
import type { ProcessedFile } from "@/types";
|
import type { ProcessedFile } from "@/types";
|
||||||
|
|
||||||
interface ResultPreviewProps {
|
interface ResultPreviewProps {
|
||||||
@@ -22,7 +30,42 @@ export function ResultPreview({
|
|||||||
onShare,
|
onShare,
|
||||||
className,
|
className,
|
||||||
}: ResultPreviewProps) {
|
}: 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;
|
if (results.length === 0) return null;
|
||||||
|
|
||||||
@@ -35,26 +78,38 @@ export function ResultPreview({
|
|||||||
|
|
||||||
const getMetadataBadge = (file: ProcessedFile) => {
|
const getMetadataBadge = (file: ProcessedFile) => {
|
||||||
const metadata = file.metadata;
|
const metadata = file.metadata;
|
||||||
const badges = [];
|
const badges: Array<{ label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = [];
|
||||||
|
|
||||||
if (metadata.compressionRatio) {
|
if (metadata.compressionRatio) {
|
||||||
badges.push({
|
badges.push({
|
||||||
label: t("results.saved", { ratio: metadata.compressionRatio }),
|
label: t("results.saved", { ratio: metadata.compressionRatio }),
|
||||||
variant: "default" as const,
|
variant: "default",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (metadata.format) {
|
if (metadata.format) {
|
||||||
badges.push({
|
badges.push({
|
||||||
label: metadata.format.toUpperCase(),
|
label: metadata.format.toUpperCase(),
|
||||||
variant: "secondary" as const,
|
variant: "secondary",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return badges;
|
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 (
|
return (
|
||||||
|
<>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
@@ -74,6 +129,7 @@ export function ResultPreview({
|
|||||||
{results.map((result, index) => {
|
{results.map((result, index) => {
|
||||||
const Icon = getFileIcon(result.originalFile.file.type);
|
const Icon = getFileIcon(result.originalFile.file.type);
|
||||||
const badges = getMetadataBadge(result);
|
const badges = getMetadataBadge(result);
|
||||||
|
const showPreview = isImageFile(result);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -82,20 +138,51 @@ export function ResultPreview({
|
|||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ delay: index * 0.1 }}
|
transition={{ delay: index * 0.1 }}
|
||||||
>
|
>
|
||||||
<Card>
|
<Card className="group overflow-hidden">
|
||||||
<CardContent className="flex items-center gap-4 p-4">
|
<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">
|
<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" />
|
<Icon className="h-6 w-6 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium">
|
<p className="truncate text-sm font-medium">
|
||||||
{result.originalFile.name}
|
{result.originalFile.name}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-1 flex items-center gap-2">
|
<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">
|
<span className="text-xs text-muted-foreground">
|
||||||
{formatFileSize(result.originalFile.size)}
|
{formatFileSize(result.originalFile.size)}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
{result.metadata.resolution && (
|
{result.metadata.resolution && (
|
||||||
<>
|
<>
|
||||||
<span className="text-xs text-muted-foreground">•</span>
|
<span className="text-xs text-muted-foreground">•</span>
|
||||||
@@ -104,6 +191,14 @@ export function ResultPreview({
|
|||||||
</span>
|
</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>
|
</div>
|
||||||
{badges.length > 0 && (
|
{badges.length > 0 && (
|
||||||
<div className="mt-2 flex gap-2">
|
<div className="mt-2 flex gap-2">
|
||||||
@@ -117,6 +212,17 @@ export function ResultPreview({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -143,5 +249,38 @@ export function ResultPreview({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</motion.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 { create } from "zustand";
|
||||||
import { persist, createJSONStorage } from "zustand/middleware";
|
import { persist, createJSONStorage } from "zustand/middleware";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import en from "@/locales/en.json";
|
import en from "@/locales/en.json";
|
||||||
import zh from "@/locales/zh.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
|
// Helper for SSR
|
||||||
export function getServerTranslations(locale: Locale = "en") {
|
export function getServerTranslations(locale: Locale = "en") {
|
||||||
return {
|
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",
|
"file_other": "files",
|
||||||
"saved": "Saved {{ratio}}%"
|
"saved": "Saved {{ratio}}%"
|
||||||
},
|
},
|
||||||
|
"preview": {
|
||||||
|
"title": "Image Comparison",
|
||||||
|
"original": "Original",
|
||||||
|
"compressed": "Compressed",
|
||||||
|
"dragHint": "Drag slider or click to compare",
|
||||||
|
"filename": "Filename"
|
||||||
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"tagline": "Media processing tools for game developers. Extract frames, compress images, optimize audio.",
|
"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.",
|
"note": "Inspired by modern product storytelling—centered on your workflow, not UI noise.",
|
||||||
|
|||||||
@@ -268,6 +268,13 @@
|
|||||||
"file_other": "文件",
|
"file_other": "文件",
|
||||||
"saved": "节省 {{ratio}}%"
|
"saved": "节省 {{ratio}}%"
|
||||||
},
|
},
|
||||||
|
"preview": {
|
||||||
|
"title": "图片对比",
|
||||||
|
"original": "原图",
|
||||||
|
"compressed": "压缩后",
|
||||||
|
"dragHint": "拖动滑块或点击来对比",
|
||||||
|
"filename": "文件名"
|
||||||
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"tagline": "面向游戏开发者的媒体处理工具。视频抽帧、图片压缩、音频优化。",
|
"tagline": "面向游戏开发者的媒体处理工具。视频抽帧、图片压缩、音频优化。",
|
||||||
"note": "灵感来自现代产品网站的信息密度与克制动效,但以你自己的产品为中心。",
|
"note": "灵感来自现代产品网站的信息密度与克制动效,但以你自己的产品为中心。",
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ export interface ProcessMetadata {
|
|||||||
duration?: number;
|
duration?: number;
|
||||||
frames?: number;
|
frames?: number;
|
||||||
compressionRatio?: number;
|
compressionRatio?: number;
|
||||||
|
originalSize?: number;
|
||||||
|
compressedSize?: number;
|
||||||
|
filename?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProcessingResult {
|
export interface ProcessingResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user