Files
plimi/src/tools/image-optimizer/ImageOptimizerUi.tsx
2026-06-02 19:32:51 +02:00

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>
);
}