mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Remove hardcoded --platform=linux/amd64 from Dockerfile so buildx produces native arm64 images for Apple Silicon and Raspberry Pi. Add audit logging for auth events, harden file storage with extension whitelists and double-extension attack prevention, reject null-byte buffers in validation, add data-testid attributes to all tool settings components, update deployment docs with architecture notes and correct CI workflow references, and fix unit test mock to match throwWithMessage error extraction.
156 lines
4.6 KiB
TypeScript
156 lines
4.6 KiB
TypeScript
import { Download } from "lucide-react";
|
|
import { useState } from "react";
|
|
import { ProgressCard } from "@/components/common/progress-card";
|
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
|
import { useFileStore } from "@/stores/file-store";
|
|
|
|
const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif"] as const;
|
|
const LOSSY_FORMATS = new Set(["jpg", "webp", "avif"]);
|
|
|
|
export function ConvertSettings() {
|
|
const { files } = useFileStore();
|
|
const {
|
|
processFiles,
|
|
processAllFiles,
|
|
processing,
|
|
error,
|
|
downloadUrl,
|
|
originalSize,
|
|
processedSize,
|
|
progress,
|
|
} = useToolProcessor("convert");
|
|
|
|
const [format, setFormat] = useState<string>("png");
|
|
const [quality, setQuality] = useState(85);
|
|
|
|
// Detect source format from filename
|
|
const sourceFile = files[0];
|
|
const sourceExt = sourceFile
|
|
? sourceFile.name.split(".").pop()?.toLowerCase() || "unknown"
|
|
: "none";
|
|
|
|
const isLossy = LOSSY_FORMATS.has(format);
|
|
|
|
const handleProcess = () => {
|
|
const settings: Record<string, unknown> = { format };
|
|
if (isLossy) {
|
|
settings.quality = quality;
|
|
}
|
|
if (files.length > 1) {
|
|
processAllFiles(files, settings);
|
|
} else {
|
|
processFiles(files, settings);
|
|
}
|
|
};
|
|
|
|
const hasFile = files.length > 0;
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (hasFile && !processing) handleProcess();
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
{/* Source format */}
|
|
{hasFile && (
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Source Format</p>
|
|
<div className="mt-0.5 px-2 py-1.5 rounded bg-muted text-sm text-foreground uppercase font-mono">
|
|
{sourceExt}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Target format */}
|
|
<div>
|
|
<label htmlFor="convert-target-format" className="text-xs text-muted-foreground">
|
|
Target Format
|
|
</label>
|
|
<select
|
|
id="convert-target-format"
|
|
value={format}
|
|
onChange={(e) => setFormat(e.target.value)}
|
|
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
|
>
|
|
{OUTPUT_FORMATS.map((f) => (
|
|
<option key={f} value={f}>
|
|
{f.toUpperCase()}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Quality slider (lossy only) */}
|
|
{isLossy && (
|
|
<div>
|
|
<div className="flex justify-between items-center">
|
|
<label htmlFor="convert-quality" className="text-xs text-muted-foreground">
|
|
Quality
|
|
</label>
|
|
<span className="text-xs font-mono text-foreground">{quality}</span>
|
|
</div>
|
|
<input
|
|
id="convert-quality"
|
|
type="range"
|
|
min={1}
|
|
max={100}
|
|
value={quality}
|
|
onChange={(e) => setQuality(Number(e.target.value))}
|
|
className="w-full mt-1"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error */}
|
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
|
|
|
{/* Size info */}
|
|
{originalSize != null && processedSize != null && (
|
|
<div className="text-xs text-muted-foreground space-y-0.5">
|
|
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
|
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
|
|
<p>
|
|
Savings:{" "}
|
|
{originalSize > 0 ? ((1 - processedSize / originalSize) * 100).toFixed(1) : "0"}%
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Process */}
|
|
{processing ? (
|
|
<ProgressCard
|
|
active={processing}
|
|
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
|
label="Converting"
|
|
stage={progress.stage}
|
|
percent={progress.percent}
|
|
elapsed={progress.elapsed}
|
|
/>
|
|
) : (
|
|
<button
|
|
type="submit"
|
|
data-testid="convert-submit"
|
|
disabled={!hasFile || processing}
|
|
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
>
|
|
{files.length > 1 ? `Convert (${files.length} files)` : "Convert"}
|
|
</button>
|
|
)}
|
|
|
|
{/* Download */}
|
|
{downloadUrl && (
|
|
<a
|
|
href={downloadUrl}
|
|
download
|
|
data-testid="convert-download"
|
|
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
Download
|
|
</a>
|
|
)}
|
|
</form>
|
|
);
|
|
}
|