mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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
This commit is contained in:
@@ -56,7 +56,7 @@ function getBadgeColor(type: string): string {
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pt-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground pt-1">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
@@ -260,7 +260,7 @@ export function BarcodeReadSettings() {
|
||||
<span className="text-sm text-muted-foreground">Thorough scan</span>
|
||||
<span
|
||||
title="Spends more time analyzing the image. Enable for small, damaged, or low-contrast barcodes."
|
||||
className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-muted-foreground/40 text-muted-foreground/60 text-[10px] cursor-help"
|
||||
className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-muted-foreground/40 text-muted-foreground text-[10px] cursor-help"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
@@ -335,7 +335,7 @@ export function BarcodeReadSettings() {
|
||||
)}
|
||||
|
||||
{fileResult.barcodes.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground/60 italic py-1">No barcodes found</p>
|
||||
<p className="text-xs text-muted-foreground italic py-1">No barcodes found</p>
|
||||
) : (
|
||||
fileResult.barcodes.map((barcode) => {
|
||||
const idx = globalIndex++;
|
||||
|
||||
@@ -128,7 +128,7 @@ export function BulkRenameSettings() {
|
||||
disabled={!hasFiles || processing || !pattern}
|
||||
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" />}
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
{processing
|
||||
? t.toolSettings["bulk-rename"].renaming
|
||||
: format(t.toolSettings["bulk-rename"].submit, { count: files.length })}
|
||||
|
||||
@@ -83,6 +83,7 @@ export function CollagePreview() {
|
||||
|
||||
/** Dropzone for initial image upload. */
|
||||
function UploadArea() {
|
||||
const { t } = useTranslation();
|
||||
const addImages = useCollageStore((s) => s.addImages);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
@@ -118,7 +119,7 @@ function UploadArea() {
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="File drop zone"
|
||||
aria-label={t.a11y.fileDropZone}
|
||||
onDragEnter={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
@@ -322,6 +323,7 @@ function CollageCell({
|
||||
gridRow: string;
|
||||
backgroundColor: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const store = useCollageStore();
|
||||
const cellRef = useRef<HTMLDivElement>(null);
|
||||
const [controlsVisible, setControlsVisible] = useState(false);
|
||||
@@ -507,7 +509,7 @@ function CollageCell({
|
||||
{isSelected && image && !isLoading && (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="Image controls"
|
||||
aria-label={t.a11y.imageControls}
|
||||
className="absolute top-1.5 right-1.5 flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
@@ -541,7 +543,7 @@ function CollageCell({
|
||||
ref={setDragRef}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
aria-label="Drag to reorder"
|
||||
aria-label={t.a11y.dragToReorder}
|
||||
className="bg-black/50 backdrop-blur-sm text-white rounded p-1 cursor-grab active:cursor-grabbing hover:bg-black/70 transition-colors"
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
@@ -553,7 +555,7 @@ function CollageCell({
|
||||
{isSelected && image && !isLoading && (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="Zoom controls"
|
||||
aria-label={t.a11y.zoomControls}
|
||||
className={cn(
|
||||
"absolute bottom-0 left-0 right-0 flex items-center gap-2 px-3 py-2 bg-black/50 backdrop-blur-sm transition-opacity duration-300",
|
||||
controlsVisible ? "opacity-100" : "opacity-0",
|
||||
|
||||
@@ -373,7 +373,7 @@ export function CollageSettings() {
|
||||
disabled={!hasImages || phase === "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"
|
||||
>
|
||||
{phase === "processing" && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{phase === "processing" && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
{phase === "processing" ? "Creating..." : `Create Collage (${imageCount} images)`}
|
||||
</button>
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export function ColorPaletteSettings() {
|
||||
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"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
{processing ? ts.extracting : ts.submit}
|
||||
</button>
|
||||
|
||||
|
||||
@@ -428,7 +428,7 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pt-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground pt-1">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
@@ -457,7 +457,7 @@ function SliderControl({
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor={id} className={`text-xs ${color || "text-muted-foreground"}`}>
|
||||
{label}
|
||||
{hint && <span className="text-[10px] text-muted-foreground/60 ms-1">({hint})</span>}
|
||||
{hint && <span className="text-[10px] text-muted-foreground ms-1">({hint})</span>}
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-end">{value}</span>
|
||||
</div>
|
||||
|
||||
@@ -99,7 +99,7 @@ export function ColorizeSettings() {
|
||||
onChange={(e) => setIntensity(Number(e.target.value))}
|
||||
className="w-full mt-0.5"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground/60 mt-0.5">
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
Lower values produce more muted, vintage-style colors.
|
||||
</p>
|
||||
</div>
|
||||
@@ -152,7 +152,7 @@ export function ColorizeSettings() {
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pt-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground pt-1">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -94,7 +94,7 @@ export function CompareSettings() {
|
||||
disabled={!hasFile || !secondFile || 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" />}
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
{processing ? "Comparing..." : "Compare"}
|
||||
</button>
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ export function ComposeSettings() {
|
||||
disabled={!hasFile || !overlayFile || 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" />}
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
{processing ? "Processing..." : t.toolSettings.compose.submit}
|
||||
</button>
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ export function EnhanceFacesControls({
|
||||
onChange={(e) => setStrength(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground/70 mt-0.5">
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
|
||||
<span>Subtle</span>
|
||||
<span>Maximum</span>
|
||||
</div>
|
||||
@@ -110,7 +110,7 @@ export function EnhanceFacesControls({
|
||||
/>
|
||||
<span className="text-sm text-foreground">Only enhance main face</span>
|
||||
</label>
|
||||
<p className="text-[11px] text-muted-foreground/70 ms-6 mt-0.5">
|
||||
<p className="text-[11px] text-muted-foreground ms-6 mt-0.5">
|
||||
For portraits - ignores background faces
|
||||
</p>
|
||||
</div>
|
||||
@@ -133,7 +133,7 @@ export function EnhanceFacesControls({
|
||||
onChange={(e) => setSensitivity(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground/70 mt-0.5">
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
|
||||
<span>Fewer faces</span>
|
||||
<span>More faces</span>
|
||||
</div>
|
||||
|
||||
@@ -266,7 +266,7 @@ export function FindDuplicatesResults() {
|
||||
if (!results) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-4">
|
||||
<Search className="h-10 w-10 text-muted-foreground/50" />
|
||||
<Search className="h-10 w-10 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose a detection mode and click "Scan" to find duplicates.
|
||||
</p>
|
||||
|
||||
@@ -309,7 +309,7 @@ export function FindDuplicatesSettings() {
|
||||
disabled={!hasFiles || scanning}
|
||||
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"
|
||||
>
|
||||
{scanning && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{scanning && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
{scanning
|
||||
? uploadProgress < 100
|
||||
? `Uploading... ${uploadProgress}%`
|
||||
|
||||
@@ -175,7 +175,7 @@ export function HtmlToImageSettings() {
|
||||
disabled={(store.mode === "url" ? !store.url : !store.htmlContent) || store.capturing}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
{store.capturing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{store.capturing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
{store.capturing ? ts.capturing : ts.submit}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -285,7 +285,7 @@ export function ImageEnhancementControls({
|
||||
)}
|
||||
|
||||
{/* Mode selector */}
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t.toolSettings.imageEnhancement.enhancementMode}
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
@@ -309,7 +309,7 @@ export function ImageEnhancementControls({
|
||||
{/* Intensity slider */}
|
||||
<div className="pt-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t.toolSettings.imageEnhancement.intensity}
|
||||
</p>
|
||||
<span className="text-xs font-mono text-foreground tabular-nums">{intensity}%</span>
|
||||
@@ -337,6 +337,8 @@ export function ImageEnhancementControls({
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={deepEnhance}
|
||||
onClick={() => setDeepEnhance(!deepEnhance)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
deepEnhance ? "bg-primary" : "bg-muted"
|
||||
@@ -360,7 +362,7 @@ export function ImageEnhancementControls({
|
||||
|
||||
{analysis && !analyzing && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t.toolSettings.imageEnhancement.detectedIssues}
|
||||
</p>
|
||||
{analysis.issues.length === 0 ? (
|
||||
@@ -412,7 +414,7 @@ export function ImageEnhancementControls({
|
||||
style={{ width: `${score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground/60 w-10 shrink-0">{label}</span>
|
||||
<span className="text-[10px] text-muted-foreground w-10 shrink-0">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -84,7 +84,7 @@ export function ImageToBase64Settings() {
|
||||
{/* Output Format */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-muted-foreground">Output Image Format</span>
|
||||
<p className="text-[10px] text-muted-foreground/70 mb-1.5">
|
||||
<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">
|
||||
@@ -123,9 +123,7 @@ export function ImageToBase64Settings() {
|
||||
onChange={(e) => setQuality(Number(e.target.value))}
|
||||
className="w-full mt-1 accent-primary"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground/70">
|
||||
Lower quality = smaller base64 string
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">Lower quality = smaller base64 string</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -141,7 +139,7 @@ export function ImageToBase64Settings() {
|
||||
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/50 outline-none"
|
||||
className="mt-1 w-full rounded bg-muted px-3 py-1.5 text-xs text-foreground placeholder:text-muted-foreground outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -157,9 +155,9 @@ export function ImageToBase64Settings() {
|
||||
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/50 outline-none"
|
||||
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/70 mt-0.5">
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
Resize before encoding. Aspect ratio is preserved. 0 = no limit.
|
||||
</p>
|
||||
</div>
|
||||
@@ -172,7 +170,7 @@ export function ImageToBase64Settings() {
|
||||
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" />}
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
{processing
|
||||
? "Converting..."
|
||||
: `Convert to Base64${files.length > 1 ? ` (${files.length})` : ""}`}
|
||||
|
||||
@@ -133,7 +133,7 @@ export function InfoSettings() {
|
||||
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"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
{processing ? ts.reading : ts.readInfo}
|
||||
</button>
|
||||
|
||||
|
||||
@@ -267,7 +267,7 @@ function EditorSettings() {
|
||||
>
|
||||
{generating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -34,7 +34,7 @@ const ENHANCE_DEFAULTS: Record<OcrQuality, boolean> = {
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pt-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground pt-1">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
@@ -253,7 +253,7 @@ export function OcrSettings() {
|
||||
</span>
|
||||
<span
|
||||
title="Automatically deskews, enhances contrast, removes noise, and upscales the image before scanning for better accuracy."
|
||||
className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-muted-foreground/40 text-muted-foreground/60 text-[10px] cursor-help"
|
||||
className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-muted-foreground/40 text-muted-foreground text-[10px] cursor-help"
|
||||
>
|
||||
<Info className="h-2.5 w-2.5" />
|
||||
</span>
|
||||
@@ -264,7 +264,7 @@ export function OcrSettings() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLangOpen(!langOpen)}
|
||||
className="flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 hover:text-foreground w-full pt-1"
|
||||
className="flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground w-full pt-1"
|
||||
>
|
||||
{langOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
Language
|
||||
|
||||
@@ -157,7 +157,7 @@ function groupByRegion(): Map<PassportRegion, PassportSpec[]> {
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pt-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground pt-1">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
@@ -326,7 +326,7 @@ function CountryOption({
|
||||
>
|
||||
<span>{spec.flag}</span>
|
||||
<span className="flex-1 text-start">{spec.name}</span>
|
||||
<span className="text-muted-foreground/60 tabular-nums text-[10px]">
|
||||
<span className="text-muted-foreground tabular-nums text-[10px]">
|
||||
{formatDimensions(doc)}
|
||||
</span>
|
||||
{selected && <Check className="h-3 w-3 text-primary shrink-0" />}
|
||||
@@ -656,7 +656,7 @@ export function PassportPhotoSettings() {
|
||||
if (!specs || specs.length === 0) return null;
|
||||
return (
|
||||
<div key={region}>
|
||||
<p className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||
<p className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{REGION_LABELS[region]}
|
||||
</p>
|
||||
{specs.map((spec) => (
|
||||
|
||||
@@ -207,7 +207,7 @@ export function PdfToImageSettings() {
|
||||
className="w-full mt-1.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">{DPI_LABELS[store.dpi] ?? ""}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{DPI_LABELS[store.dpi] ?? ""}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -265,7 +265,7 @@ export function PdfToImageSettings() {
|
||||
disabled={!store.file || !store.pageCount || store.processing || selectedCount === 0}
|
||||
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"
|
||||
>
|
||||
{store.processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{store.processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
{store.processing
|
||||
? t.toolSettings["pdf-to-image"].converting
|
||||
: `Convert ${selectedCount} page${selectedCount !== 1 ? "s" : ""}`}
|
||||
|
||||
@@ -73,7 +73,7 @@ const GRADIENT_PRESETS = [
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pt-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground pt-1">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
@@ -425,7 +425,7 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEffectsOpen(!effectsOpen)}
|
||||
className="flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 hover:text-foreground w-full pt-1"
|
||||
className="flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground w-full pt-1"
|
||||
>
|
||||
{effectsOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
Effects
|
||||
|
||||
@@ -397,7 +397,7 @@ export function SharpeningSettings() {
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pt-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground pt-1">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
@@ -429,7 +429,7 @@ function SliderControl({
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor={id} className={`text-xs ${color || "text-muted-foreground"}`}>
|
||||
{label}
|
||||
{hint && <span className="text-[10px] text-muted-foreground/60 ms-1">({hint})</span>}
|
||||
{hint && <span className="text-[10px] text-muted-foreground ms-1">({hint})</span>}
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground tabular-nums w-10 text-end">
|
||||
{displayValue}
|
||||
|
||||
@@ -81,7 +81,7 @@ export function SplitCanvas() {
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
|
||||
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
|
||||
<p className="text-sm text-muted-foreground">Generating preview...</p>
|
||||
<p className="text-xs text-muted-foreground/60">{filename}</p>
|
||||
<p className="text-xs text-muted-foreground">{filename}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -91,7 +91,7 @@ export function SplitCanvas() {
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-center p-4">
|
||||
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
|
||||
<p className="text-sm text-muted-foreground">Loading preview...</p>
|
||||
<p className="text-xs text-muted-foreground/60">{filename}</p>
|
||||
<p className="text-xs text-muted-foreground">{filename}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -402,7 +402,7 @@ export function SplitSettings() {
|
||||
disabled={!hasFile || processing || fileStoreProcessing}
|
||||
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" />}
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
{processing
|
||||
? "Splitting..."
|
||||
: files.length > 1
|
||||
|
||||
@@ -321,7 +321,7 @@ export function StitchSettings() {
|
||||
disabled={!hasEnoughFiles || 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" />}
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
{processing
|
||||
? uploadProgress < 100
|
||||
? `Uploading... ${uploadProgress}%`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
|
||||
@@ -48,6 +49,7 @@ function parseSvgDimensions(file: File): Promise<SvgDims | null> {
|
||||
}
|
||||
|
||||
export function SvgToRasterSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("svg-to-raster");
|
||||
@@ -322,13 +324,13 @@ export function SvgToRasterSettings() {
|
||||
type="button"
|
||||
onClick={() => setBgColor("#ffffff")}
|
||||
className={`w-8 h-8 rounded border-2 bg-white ${bgColor === "#ffffff" ? "border-primary" : "border-border"}`}
|
||||
aria-label="White background"
|
||||
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="Black background"
|
||||
aria-label={t.a11y.blackBackground}
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
|
||||
@@ -53,8 +53,9 @@ export function TransparencyFixerControls({
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
data-testid="remove-watermark-toggle"
|
||||
aria-pressed={removeWatermark}
|
||||
aria-checked={removeWatermark}
|
||||
onClick={() => setRemoveWatermark(!removeWatermark)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
removeWatermark ? "bg-primary" : "bg-muted"
|
||||
@@ -72,7 +73,7 @@ export function TransparencyFixerControls({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen(!advancedOpen)}
|
||||
className="flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 hover:text-foreground w-full pt-1"
|
||||
className="flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground w-full pt-1"
|
||||
>
|
||||
{advancedOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
Advanced
|
||||
@@ -100,7 +101,7 @@ export function TransparencyFixerControls({
|
||||
|
||||
{/* Output Format dropdown */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 mb-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">
|
||||
Output Format
|
||||
</p>
|
||||
<select
|
||||
|
||||
@@ -158,7 +158,7 @@ export function UpscaleControls({ settings: initialSettings, onChange }: Upscale
|
||||
onChange={(e) => setDenoise(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground/70 mt-1">
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
Smooths out grain and noise. Higher values remove more noise but may soften details.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -329,6 +329,9 @@ export function VectorizeSettings() {
|
||||
<span className="text-xs text-muted-foreground">Invert Colors</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={invert}
|
||||
aria-label={t.toolSettings.vectorize.invertColors}
|
||||
onClick={() => updateSetting(setInvert)(!invert)}
|
||||
className={`w-9 h-5 rounded-full transition-colors ${invert ? "bg-primary" : "bg-muted"}`}
|
||||
>
|
||||
|
||||
@@ -197,7 +197,7 @@ export function WatermarkImageSettings() {
|
||||
disabled={!hasFile || !watermarkFile || 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" />}
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
{processing
|
||||
? "Processing..."
|
||||
: files.length > 1
|
||||
|
||||
Reference in New Issue
Block a user