Files
SnapOtter/apps/web/src/components/tools/svg-to-raster-settings.tsx
T
SnapOtterandGitHub 6f276b4ef0 feat(a11y): WCAG 2.2 AA accessibility compliance (#209)
* feat(a11y): add i18n keys for ARIA labels and screen reader text

* fix(security): harden API against pentest findings

- Default TRUST_PROXY=false to prevent XFF rate limit bypass (PT-01)
- Return 400 instead of 500 on malformed JSON input (PT-03)
- Default MAX_PIPELINE_STEPS=20 to prevent DoS (PT-04)
- Validate clientJobId length (max 128) across all routes (PT-06)
- Add security headers to all reply.hijack() streaming responses (PT-07)
- Sanitize usernames in audit log to prevent stored XSS (PT-08)
- Block TRACE method with 405 response (PT-10)
- Add 429 RateLimited response to OpenAPI spec (PT-12)
- Default MAX_SVG_SIZE_MB=50 to limit SVGZ decompression (PT-13)
- Pin Dockerfile base images by digest
- Sanitize OIDC IdP error and sub claim in audit log
- Sync Docker compose/Dockerfile defaults with env.ts

* feat(a11y): convert all hardcoded aria-labels to i18n keys

Replace 49 hardcoded aria-label="..." strings across 25 files with
their corresponding t.a11y.* and t.common.* i18n references. Add
useTranslation import and hook call to 15 components that lacked it.
Zero hardcoded aria-labels remain in the codebase.

* feat(a11y): add aria-labels to icon-only buttons, aria-hidden on decorative icons, sr-only status text

* feat(a11y): add aria-live regions for processing status announcements

* feat(a11y): add skip-nav link, route announcer, main content landmark, and page h1 elements

* feat(a11y): add prefers-reduced-motion support, preserve functional spinners

* feat(a11y): add useFocusTrap hook for modal focus management

* feat(a11y): add focus trapping and dialog roles to all modals

* feat(a11y): add toggle switch roles, form labels, and error association

* fix(a11y): fix contrast failures, touch targets, and add nav landmark to sidebar

* fix(a11y): add role=switch to remaining toggle buttons found in verification sweep
2026-06-07 23:32:41 +08:00

385 lines
12 KiB
TypeScript

import { Download } from "lucide-react";
import { useEffect, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
type OutputFormat = "png" | "jpg" | "webp" | "avif" | "tiff" | "gif" | "heif" | "jxl";
type SizingMode = "scale" | "custom";
type BgMode = "transparent" | "color";
const FORMATS: OutputFormat[] = ["png", "jpg", "webp", "avif", "tiff", "gif", "heif", "jxl"];
const LOSSY_FORMATS: OutputFormat[] = ["jpg", "webp", "avif", "heif", "jxl"];
const NO_TRANSPARENCY_FORMATS: OutputFormat[] = ["jpg", "tiff"];
const SCALE_PRESETS = [0.5, 1, 2, 3, 4];
const DPI_PRESETS = [72, 96, 150, 300];
interface SvgDims {
width: number;
height: number;
}
function parseSvgDimensions(file: File): Promise<SvgDims | null> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => {
const text = reader.result as string;
const vbMatch = text.match(
/viewBox=["'][\s]*([0-9.]+)[\s,]+([0-9.]+)[\s,]+([0-9.]+)[\s,]+([0-9.]+)[\s]*["']/,
);
if (vbMatch) {
const w = parseFloat(vbMatch[3]);
const h = parseFloat(vbMatch[4]);
if (w > 0 && h > 0) return resolve({ width: Math.round(w), height: Math.round(h) });
}
const wMatch = text.match(/\bwidth=["']([0-9.]+)/);
const hMatch = text.match(/\bheight=["']([0-9.]+)/);
if (wMatch && hMatch) {
const w = parseFloat(wMatch[1]);
const h = parseFloat(hMatch[1]);
if (w > 0 && h > 0) return resolve({ width: Math.round(w), height: Math.round(h) });
}
resolve(null);
};
reader.onerror = () => resolve(null);
reader.readAsText(file);
});
}
export function SvgToRasterSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("svg-to-raster");
// SVG intrinsic dimensions
const [svgDims, setSvgDims] = useState<SvgDims | null>(null);
// Sizing
const [sizingMode, setSizingMode] = useState<SizingMode>("scale");
const [scale, setScale] = useState(1);
const [customWidth, setCustomWidth] = useState(1024);
const [customHeight, setCustomHeight] = useState("");
// Render
const [dpi, setDpi] = useState(300);
const [format, setFormat] = useState<OutputFormat>("png");
const [quality, setQuality] = useState(90);
// Background
const [bgMode, setBgMode] = useState<BgMode>("transparent");
const [bgColor, setBgColor] = useState("#ffffff");
const isLossy = LOSSY_FORMATS.includes(format);
const hasFile = files.length > 0;
// Parse SVG dimensions when file changes
useEffect(() => {
if (files.length === 0) {
setSvgDims(null);
return;
}
parseSvgDimensions(files[0]).then((dims) => {
setSvgDims(dims);
if (!dims) setSizingMode("custom");
});
}, [files]);
// When format changes to one that does not support transparency, switch bgMode
useEffect(() => {
if (NO_TRANSPARENCY_FORMATS.includes(format) && bgMode === "transparent") {
setBgMode("color");
}
}, [format, bgMode]);
const computedWidth = svgDims ? Math.round(svgDims.width * scale) : null;
const computedHeight = svgDims ? Math.round(svgDims.height * scale) : null;
const handleProcess = () => {
const settings: Record<string, unknown> = {
dpi,
quality,
outputFormat: format,
backgroundColor: bgMode === "transparent" ? "#00000000" : bgColor,
};
if (sizingMode === "scale" && computedWidth && computedHeight) {
settings.width = computedWidth;
settings.height = computedHeight;
} else if (sizingMode === "custom") {
settings.width = customWidth;
if (customHeight) settings.height = Number(customHeight);
}
if (files.length > 1) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
const btnClass = (active: boolean) =>
`text-xs py-1.5 rounded ${active ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`;
return (
<div className="space-y-4">
{/* SVG Info */}
{hasFile && svgDims && (
<div>
<p className="text-xs text-muted-foreground">SVG Dimensions</p>
<div className="mt-1 px-2 py-1.5 rounded bg-muted text-sm text-foreground font-mono">
{svgDims.width} x {svgDims.height}
{sizingMode === "scale" && computedWidth && computedHeight && (
<span className="text-muted-foreground">
{" "}
&rarr; {computedWidth} x {computedHeight}
</span>
)}
</div>
</div>
)}
{hasFile && !svgDims && (
<div>
<p className="text-xs text-muted-foreground">SVG Dimensions</p>
<div className="mt-1 px-2 py-1.5 rounded bg-muted text-xs text-muted-foreground">
Could not detect dimensions. Using custom size.
</div>
</div>
)}
<div className="border-t border-border" />
{/* Sizing Mode */}
<div>
<p className="text-xs text-muted-foreground">Sizing</p>
<div className="grid grid-cols-2 gap-1 mt-1">
<button
type="button"
onClick={() => svgDims && setSizingMode("scale")}
disabled={!svgDims}
className={btnClass(sizingMode === "scale")}
>
Scale Factor
</button>
<button
type="button"
onClick={() => setSizingMode("custom")}
className={btnClass(sizingMode === "custom")}
>
Custom Size
</button>
</div>
</div>
{/* Scale controls */}
{sizingMode === "scale" && svgDims && (
<div className="space-y-3">
<div className="grid grid-cols-5 gap-1">
{SCALE_PRESETS.map((s) => (
<button
type="button"
key={s}
onClick={() => setScale(s)}
className={btnClass(scale === s)}
>
{s}x
</button>
))}
</div>
<div>
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Scale</span>
<span className="text-xs font-mono text-foreground">{scale}x</span>
</div>
<input
type="range"
min={0.25}
max={8}
step={0.25}
value={scale}
onChange={(e) => setScale(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
</div>
)}
{/* Custom size controls */}
{sizingMode === "custom" && (
<div className="flex gap-2">
<div className="flex-1">
<label htmlFor="svg-custom-width" className="text-xs text-muted-foreground">
Width (px)
</label>
<input
id="svg-custom-width"
type="number"
value={customWidth}
onChange={(e) => setCustomWidth(Number(e.target.value))}
min={1}
max={16384}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div className="flex-1">
<label htmlFor="svg-custom-height" className="text-xs text-muted-foreground">
Height (px)
</label>
<input
id="svg-custom-height"
type="number"
value={customHeight}
onChange={(e) => setCustomHeight(e.target.value)}
placeholder="Auto"
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
</div>
)}
<div className="border-t border-border" />
{/* Render DPI */}
<div>
<p className="text-xs text-muted-foreground">Render DPI</p>
<div className="grid grid-cols-4 gap-1 mt-1">
{DPI_PRESETS.map((d) => (
<button type="button" key={d} onClick={() => setDpi(d)} className={btnClass(dpi === d)}>
{d}
</button>
))}
</div>
</div>
<div className="border-t border-border" />
{/* Format */}
<div>
<p className="text-xs text-muted-foreground">Format</p>
<div className="grid grid-cols-4 gap-1 mt-1">
{FORMATS.map((f) => (
<button
type="button"
key={f}
onClick={() => setFormat(f)}
className={`uppercase ${btnClass(format === f)}`}
>
{f}
</button>
))}
</div>
</div>
{/* Quality (lossy only) */}
{isLossy && (
<>
<div className="border-t border-border" />
<div>
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Quality</span>
<span className="text-xs font-mono text-foreground">{quality}</span>
</div>
<input
type="range"
min={1}
max={100}
value={quality}
onChange={(e) => setQuality(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
</>
)}
<div className="border-t border-border" />
{/* Background */}
<div>
<p className="text-xs text-muted-foreground">Background</p>
<div className="grid grid-cols-2 gap-1 mt-1">
<button
type="button"
onClick={() => !NO_TRANSPARENCY_FORMATS.includes(format) && setBgMode("transparent")}
disabled={NO_TRANSPARENCY_FORMATS.includes(format)}
className={btnClass(bgMode === "transparent")}
>
Transparent
</button>
<button
type="button"
onClick={() => setBgMode("color")}
className={btnClass(bgMode === "color")}
>
Color
</button>
</div>
</div>
{bgMode === "color" && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setBgColor("#ffffff")}
className={`w-8 h-8 rounded border-2 bg-white ${bgColor === "#ffffff" ? "border-primary" : "border-border"}`}
aria-label={t.a11y.whiteBackground}
/>
<button
type="button"
onClick={() => setBgColor("#000000")}
className={`w-8 h-8 rounded border-2 bg-black ${bgColor === "#000000" ? "border-primary" : "border-border"}`}
aria-label={t.a11y.blackBackground}
/>
<input
type="color"
value={bgColor}
onChange={(e) => setBgColor(e.target.value)}
className="h-8 w-8 rounded border border-border cursor-pointer"
/>
<span className="text-xs font-mono text-muted-foreground">{bgColor}</span>
</div>
)}
{/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>}
{/* Process / Progress */}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={files.length > 1 ? `Converting ${files.length} files` : "Converting SVG"}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="svg-to-raster-submit"
onClick={handleProcess}
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 SVG"}
</button>
)}
{/* Download */}
{downloadUrl && (
<a
href={downloadUrl}
download
data-testid="svg-to-raster-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>
)}
</div>
);
}