mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
* 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
183 lines
6.0 KiB
TypeScript
183 lines
6.0 KiB
TypeScript
import { Loader2 } from "lucide-react";
|
|
import { useState } from "react";
|
|
import { useTranslation } from "@/contexts/i18n-context";
|
|
import { formatHeaders } from "@/lib/api";
|
|
import { format } from "@/lib/format";
|
|
import { useBase64Store } from "@/stores/base64-store";
|
|
import { useFileStore } from "@/stores/file-store";
|
|
|
|
const OUTPUT_FORMATS = [
|
|
{ value: "original", label: "Keep Original" },
|
|
{ value: "jpeg", label: "JPEG" },
|
|
{ value: "png", label: "PNG" },
|
|
{ value: "webp", label: "WebP" },
|
|
{ value: "avif", label: "AVIF" },
|
|
{ value: "jxl", label: "JXL" },
|
|
] as const;
|
|
|
|
export function ImageToBase64Settings() {
|
|
const { t } = useTranslation();
|
|
const { files } = useFileStore();
|
|
const { processing, setProcessing, setProgress, addResult, addError, reset } = useBase64Store();
|
|
|
|
const [outputFormat, setOutputFormat] = useState("original");
|
|
const [quality, setQuality] = useState(80);
|
|
const [maxWidth, setMaxWidth] = useState(0);
|
|
const [maxHeight, setMaxHeight] = useState(0);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const handleProcess = async () => {
|
|
if (files.length === 0) return;
|
|
|
|
setProcessing(true);
|
|
setError(null);
|
|
reset();
|
|
setProcessing(true);
|
|
|
|
const settings = JSON.stringify({ outputFormat, quality, maxWidth, maxHeight });
|
|
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i];
|
|
setProgress({ completed: i, total: files.length, currentFile: file.name });
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("files", file);
|
|
formData.append("settings", settings);
|
|
|
|
const res = await fetch("/api/v1/tools/image-to-base64", {
|
|
method: "POST",
|
|
headers: formatHeaders(),
|
|
body: formData,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
addError({ filename: file.name, error: body.error || `Failed: ${res.status}` });
|
|
continue;
|
|
}
|
|
|
|
const data = await res.json();
|
|
for (const r of data.results) addResult(r);
|
|
for (const e of data.errors) addError(e);
|
|
} catch (err) {
|
|
addError({
|
|
filename: file.name,
|
|
error: err instanceof Error ? err.message : "Failed to convert",
|
|
});
|
|
}
|
|
}
|
|
|
|
setProgress(null);
|
|
setProcessing(false);
|
|
};
|
|
|
|
const hasFiles = files.length > 0;
|
|
const showQuality =
|
|
outputFormat === "jpeg" ||
|
|
outputFormat === "webp" ||
|
|
outputFormat === "avif" ||
|
|
outputFormat === "jxl";
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Output Format */}
|
|
<div>
|
|
<span className="text-xs font-medium text-muted-foreground">Output Image Format</span>
|
|
<p className="text-[10px] text-muted-foreground mb-1.5">
|
|
Convert before encoding to control MIME type and size
|
|
</p>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{OUTPUT_FORMATS.map((fmt) => (
|
|
<button
|
|
key={fmt.value}
|
|
type="button"
|
|
onClick={() => setOutputFormat(fmt.value)}
|
|
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors ${
|
|
outputFormat === fmt.value
|
|
? "bg-primary text-primary-foreground"
|
|
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
|
}`}
|
|
>
|
|
{fmt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quality slider */}
|
|
{showQuality && (
|
|
<div>
|
|
<div className="flex items-center justify-between">
|
|
<label htmlFor="b64-quality" className="text-xs font-medium text-muted-foreground">
|
|
Quality
|
|
</label>
|
|
<span className="text-xs font-mono text-foreground">{quality}%</span>
|
|
</div>
|
|
<input
|
|
id="b64-quality"
|
|
type="range"
|
|
min={1}
|
|
max={100}
|
|
value={quality}
|
|
onChange={(e) => setQuality(Number(e.target.value))}
|
|
className="w-full mt-1 accent-primary"
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground">Lower quality = smaller base64 string</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Max Width */}
|
|
<div>
|
|
<label htmlFor="b64-max-width" className="text-xs font-medium text-muted-foreground">
|
|
Max Width (px)
|
|
</label>
|
|
<input
|
|
id="b64-max-width"
|
|
type="number"
|
|
min={0}
|
|
value={maxWidth}
|
|
onChange={(e) => setMaxWidth(Math.max(0, Number(e.target.value)))}
|
|
placeholder="0 = no limit"
|
|
className="mt-1 w-full rounded bg-muted px-3 py-1.5 text-xs text-foreground placeholder:text-muted-foreground outline-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* Max Height */}
|
|
<div>
|
|
<label htmlFor="b64-max-height" className="text-xs font-medium text-muted-foreground">
|
|
Max Height (px)
|
|
</label>
|
|
<input
|
|
id="b64-max-height"
|
|
type="number"
|
|
min={0}
|
|
value={maxHeight}
|
|
onChange={(e) => setMaxHeight(Math.max(0, Number(e.target.value)))}
|
|
placeholder="0 = no limit"
|
|
className="mt-1 w-full rounded bg-muted px-3 py-1.5 text-xs text-foreground placeholder:text-muted-foreground outline-none"
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground mt-0.5">
|
|
Resize before encoding. Aspect ratio is preserved. 0 = no limit.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Process button */}
|
|
<button
|
|
type="button"
|
|
data-testid="base64-submit"
|
|
onClick={handleProcess}
|
|
disabled={!hasFiles || 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"
|
|
>
|
|
{processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
|
{processing
|
|
? "Converting..."
|
|
: `Convert to Base64${files.length > 1 ? ` (${files.length})` : ""}`}
|
|
</button>
|
|
|
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
|
</div>
|
|
);
|
|
}
|