225 lines
8.0 KiB
TypeScript
225 lines
8.0 KiB
TypeScript
import { useState, useCallback, useEffect } from "react";
|
|
import type { ToolUiProps } from "../../core/plugins/plugin-types";
|
|
import type { ImageOptimizerOptions } from "./index";
|
|
import { Button } from "../../components/ui/Button";
|
|
import { Card } from "../../components/ui/Card";
|
|
import { Dropzone } from "../../components/ui/Dropzone";
|
|
import { Slider } from "../../components/ui/Slider";
|
|
import { Select } from "../../components/ui/Select";
|
|
import { useToolExecution } from "../../components/tool/useToolExecution";
|
|
|
|
const IMAGE_FORMATS = ["image/jpeg", "image/webp", "image/png"] as const;
|
|
type ImageFormat = (typeof IMAGE_FORMATS)[number];
|
|
|
|
function isImageFormat(value: string): value is ImageFormat {
|
|
return IMAGE_FORMATS.includes(value as ImageFormat);
|
|
}
|
|
|
|
export default function ImageOptimizerUi({
|
|
plugin,
|
|
}: ToolUiProps<ImageOptimizerOptions>) {
|
|
const [files, setFiles] = useState<File[]>([]);
|
|
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
|
|
|
|
const [quality, setQuality] = useState(80);
|
|
const [format, setFormat] = useState<
|
|
"image/jpeg" | "image/webp" | "image/png"
|
|
>("image/webp");
|
|
const { run, result, isExecuting, error, reset } = useToolExecution(plugin);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
previewUrls.forEach((url) => URL.revokeObjectURL(url));
|
|
};
|
|
}, [previewUrls]);
|
|
|
|
const handleFilesDrop = useCallback((nextFiles: File[]) => {
|
|
setFiles(nextFiles);
|
|
setPreviewUrls(nextFiles.map((file) => URL.createObjectURL(file)));
|
|
reset();
|
|
}, [reset]);
|
|
|
|
const handleRun = useCallback(async () => {
|
|
if (files.length === 0) return;
|
|
|
|
await run({ files }, { quality, format });
|
|
}, [files, format, quality, run]);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
|
e.preventDefault();
|
|
if (!isExecuting && files.length > 0) {
|
|
handleRun();
|
|
}
|
|
}
|
|
};
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => {
|
|
window.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, [handleRun, isExecuting, files]);
|
|
|
|
const downloadBlob = useCallback((blob: Blob, filename: string) => {
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="p-[18px] md:p-[28px] max-w-5xl mx-auto">
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
<div className="lg:col-span-2 space-y-6">
|
|
<Card title="Upload Images">
|
|
<Dropzone
|
|
accept="image/jpeg,image/png,image/webp"
|
|
multiple
|
|
maxSizeMb={20}
|
|
onFilesDrop={handleFilesDrop}
|
|
/>
|
|
|
|
{previewUrls.length > 0 && (
|
|
<div className="mt-6">
|
|
<h4 className="text-sm font-medium text-gray-700 mb-3">
|
|
Previews:
|
|
</h4>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
|
{previewUrls.map((url, i) => (
|
|
<div
|
|
key={i}
|
|
className="relative aspect-square rounded-lg overflow-hidden border border-gray-200"
|
|
>
|
|
<img
|
|
src={url}
|
|
alt="Preview"
|
|
className="object-cover w-full h-full"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{error && (
|
|
<Card className="border-red-200">
|
|
<div className="text-sm text-red-700">{error}</div>
|
|
</Card>
|
|
)}
|
|
|
|
{result && result.type === "files" && (
|
|
<Card
|
|
title="Optimized Results"
|
|
className="border-green-200 shadow-md"
|
|
>
|
|
{result.files.length > 1 && (
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 pb-4 border-b border-gray-100">
|
|
<span className="text-sm text-gray-500 font-medium">
|
|
Successfully optimized {result.files.length} images
|
|
</span>
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => {
|
|
result.files.forEach((file) => {
|
|
downloadBlob(file.blob, file.name);
|
|
});
|
|
}}
|
|
className="w-full sm:w-auto shrink-0"
|
|
>
|
|
Download All ({result.files.length})
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-lg">
|
|
{result.files.map((file, i) => {
|
|
const savedBytes =
|
|
(file.sizeBefore || 0) - (file.sizeAfter || 0);
|
|
const savedPercentage = file.sizeBefore
|
|
? (savedBytes / file.sizeBefore) * 100
|
|
: 0;
|
|
|
|
return (
|
|
<li
|
|
key={i}
|
|
className="p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-gray-50 transition-colors"
|
|
>
|
|
<div className="flex flex-col min-w-0 flex-1">
|
|
<span className="font-medium text-gray-900 truncate" title={file.name}>
|
|
{file.name}
|
|
</span>
|
|
<div className="text-sm text-gray-500 mt-1 flex flex-wrap items-center gap-2">
|
|
<span className="line-through">
|
|
{((file.sizeBefore || 0) / 1024).toFixed(1)} KB
|
|
</span>
|
|
<span className="text-green-600 font-medium">
|
|
{((file.sizeAfter || 0) / 1024).toFixed(1)} KB
|
|
</span>
|
|
<span className="text-xs bg-green-100 text-green-800 px-2 py-0.5 rounded-full">
|
|
-{savedPercentage.toFixed(0)}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => downloadBlob(file.blob, file.name)}
|
|
className="w-full sm:w-auto shrink-0 text-center"
|
|
>
|
|
Download
|
|
</Button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
<Card title="Settings">
|
|
<div className="space-y-6">
|
|
<Select
|
|
label="Output Format"
|
|
value={format}
|
|
onChange={(e) => {
|
|
if (isImageFormat(e.target.value)) {
|
|
setFormat(e.target.value);
|
|
}
|
|
}}
|
|
options={[
|
|
{ label: "WebP (Recommended)", value: "image/webp" },
|
|
{ label: "JPEG", value: "image/jpeg" },
|
|
{ label: "PNG", value: "image/png" },
|
|
]}
|
|
/>
|
|
|
|
<Slider
|
|
label="Quality"
|
|
value={quality}
|
|
onChange={(e) => setQuality(Number(e.target.value))}
|
|
min={1}
|
|
max={100}
|
|
/>
|
|
|
|
<div className="pt-4 border-t border-gray-100">
|
|
<Button
|
|
onClick={handleRun}
|
|
disabled={isExecuting || files.length === 0}
|
|
className="w-full"
|
|
>
|
|
{isExecuting ? "Optimizing..." : "Optimize Images"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|