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:
@@ -4,6 +4,7 @@ import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-route
|
||||
import { Toaster, toast } from "sonner";
|
||||
import { ConnectionMonitor } from "./components/common/connection-monitor";
|
||||
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
|
||||
import { RouteAnnouncer } from "./components/common/route-announcer";
|
||||
import { I18nProvider } from "./contexts/i18n-context";
|
||||
import { useAuth } from "./hooks/use-auth";
|
||||
import { useMobile } from "./hooks/use-mobile";
|
||||
@@ -221,6 +222,13 @@ export function App() {
|
||||
<ConnectionMonitor />
|
||||
<Toaster position={isMobile ? "top-center" : "bottom-right"} />
|
||||
<BrowserRouter>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:text-sm focus:font-medium focus:shadow-lg"
|
||||
>
|
||||
{en.a11y.skipToContent}
|
||||
</a>
|
||||
<RouteAnnouncer />
|
||||
<KeyboardShortcutProvider>
|
||||
<AuthGuard>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
|
||||
@@ -134,7 +134,7 @@ export function BeforeAfterSlider({
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="slider"
|
||||
aria-label="Before/after comparison slider"
|
||||
aria-label={t.a11y.beforeAfterSlider}
|
||||
aria-valuenow={Math.round(position)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useDrag } from "@use-gesture/react";
|
||||
import { X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
|
||||
interface BottomSheetProps {
|
||||
open: boolean;
|
||||
@@ -17,7 +19,9 @@ export function BottomSheet({
|
||||
children,
|
||||
maxHeight = "70dvh",
|
||||
}: BottomSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const sheetRef = useRef<HTMLDivElement>(null);
|
||||
useFocusTrap(sheetRef, open);
|
||||
const [translateY, setTranslateY] = useState(0);
|
||||
|
||||
// Close on Escape key
|
||||
@@ -74,6 +78,9 @@ export function BottomSheet({
|
||||
{/* Sheet */}
|
||||
<div
|
||||
ref={sheetRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? "bottom-sheet-title" : undefined}
|
||||
className="fixed inset-x-0 bottom-0 z-50 bg-background border-t border-border rounded-t-2xl shadow-xl flex flex-col animate-in slide-in-from-bottom"
|
||||
style={{
|
||||
maxHeight,
|
||||
@@ -89,11 +96,14 @@ export function BottomSheet({
|
||||
{/* Header */}
|
||||
{title && (
|
||||
<div className="flex items-center justify-between px-4 pb-2 shrink-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
||||
<h2 id="bottom-sheet-title" className="text-sm font-semibold text-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={t.common.close}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -205,7 +205,7 @@ export function Dropzone({
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="File drop zone"
|
||||
aria-label={t.a11y.fileDropZone}
|
||||
onDragEnter={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
@@ -239,7 +239,7 @@ export function Dropzone({
|
||||
<p className={cn("font-medium", compact ? "text-sm" : "text-base", "text-foreground/80")}>
|
||||
{t.dropzone.dropPrompt}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground/70">{t.dropzone.browseOrPaste}</p>
|
||||
<p className="text-sm text-muted-foreground">{t.dropzone.browseOrPaste}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -256,7 +256,7 @@ export function Dropzone({
|
||||
<Upload className="h-4 w-4" />
|
||||
{t.common.upload}
|
||||
</button>
|
||||
<p className="text-xs text-muted-foreground/50">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{acceptDescription ?? t.dropzone.defaultFormats}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Check, FolderOpen, ImageIcon, Loader2, Search, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import {
|
||||
apiListFiles,
|
||||
formatHeaders,
|
||||
@@ -40,7 +41,7 @@ function AuthImage({ src, alt, className }: { src: string; alt: string; classNam
|
||||
if (failed) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center bg-muted/50", className)}>
|
||||
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
|
||||
<ImageIcon className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -64,6 +65,8 @@ interface FileLibraryModalProps {
|
||||
|
||||
export function FileLibraryModal({ open, onClose, onImport }: FileLibraryModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
useFocusTrap(dialogRef, open);
|
||||
const [files, setFiles] = useState<UserFile[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
@@ -149,11 +152,17 @@ export function FileLibraryModal({ open, onClose, onImport }: FileLibraryModalPr
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm cursor-default"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative z-10 w-full max-w-lg max-h-[80dvh] bg-background border border-border rounded-xl shadow-xl flex flex-col mx-4">
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="file-library-title"
|
||||
className="relative z-10 w-full max-w-lg max-h-[80dvh] bg-background border border-border rounded-xl shadow-xl flex flex-col mx-4"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-border shrink-0">
|
||||
<FolderOpen className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-sm font-semibold text-foreground flex-1">
|
||||
<h2 id="file-library-title" className="text-sm font-semibold text-foreground flex-1">
|
||||
{t.automate.importFromLibrary}
|
||||
</h2>
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useGesture } from "@use-gesture/react";
|
||||
import { FileImage, Maximize, Minimize2, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { formatFileSize } from "@/lib/download";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -49,6 +50,7 @@ export function ImageViewer({
|
||||
imageWrapperStyle,
|
||||
imageWrapperChildren,
|
||||
}: ImageViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [zoom, setZoom] = useState(DEFAULT_ZOOM);
|
||||
const [naturalWidth, setNaturalWidth] = useState<number | null>(null);
|
||||
const [naturalHeight, setNaturalHeight] = useState<number | null>(null);
|
||||
@@ -203,6 +205,7 @@ export function ImageViewer({
|
||||
disabled={zoom <= ZOOM_STEPS[0]}
|
||||
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Zoom out"
|
||||
aria-label={t.a11y.zoomOut}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -215,6 +218,7 @@ export function ImageViewer({
|
||||
disabled={zoom >= ZOOM_STEPS[ZOOM_STEPS.length - 1]}
|
||||
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Zoom in"
|
||||
aria-label={t.a11y.zoomIn}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -224,6 +228,7 @@ export function ImageViewer({
|
||||
onClick={fitToContainer}
|
||||
className={`px-2 py-1 rounded text-xs ${fitMode === "fit" ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted"}`}
|
||||
title="Fit to view"
|
||||
aria-label={t.a11y.fitToView}
|
||||
>
|
||||
<Maximize className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -232,6 +237,7 @@ export function ImageViewer({
|
||||
onClick={actualSize}
|
||||
className={`px-2 py-1 rounded text-xs ${fitMode === "actual" && zoom === 100 ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted"}`}
|
||||
title="Actual size (100%)"
|
||||
aria-label={t.a11y.actualSize}
|
||||
>
|
||||
<Minimize2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -251,7 +257,7 @@ export function ImageViewer({
|
||||
<div className="flex flex-col items-center justify-center gap-3 text-center">
|
||||
<FileImage className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Preview not available</p>
|
||||
<p className="text-xs text-muted-foreground/60">{filename}</p>
|
||||
<p className="text-xs text-muted-foreground">{filename}</p>
|
||||
</div>
|
||||
) : bgPreview?.backgroundSrc || bgPreview?.containerBackground ? (
|
||||
/* Layered bg-removal preview: background layer + subject layer */
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback } from "react";
|
||||
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
|
||||
import { ImageViewer } from "@/components/common/image-viewer";
|
||||
import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
const BROWSER_PREVIEWABLE_EXTS = new Set([
|
||||
@@ -24,6 +25,7 @@ function canBrowserPreview(url: string): boolean {
|
||||
}
|
||||
|
||||
export function MultiImageViewer() {
|
||||
const { t } = useTranslation();
|
||||
const { entries, selectedIndex, setSelectedIndex, navigateNext, navigatePrev } = useFileStore();
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
@@ -65,7 +67,7 @@ export function MultiImageViewer() {
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Image viewer"
|
||||
aria-label={t.a11y.imageViewer}
|
||||
className="flex flex-col w-full h-full min-h-0"
|
||||
onKeyDown={hasMultiple ? handleKeyDown : undefined}
|
||||
tabIndex={hasMultiple ? 0 : undefined}
|
||||
@@ -76,7 +78,7 @@ export function MultiImageViewer() {
|
||||
type="button"
|
||||
onClick={navigatePrev}
|
||||
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
|
||||
aria-label="Previous image"
|
||||
aria-label={t.a11y.previousImage}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -120,7 +122,7 @@ export function MultiImageViewer() {
|
||||
type="button"
|
||||
onClick={navigateNext}
|
||||
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
|
||||
aria-label="Next image"
|
||||
aria-label={t.a11y.nextImage}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -22,7 +22,11 @@ export function ProgressCard({ active, phase, label, stage, percent, elapsed }:
|
||||
const sublabel = [stage, `${elapsed}s`].filter(Boolean).join(" · ");
|
||||
|
||||
return (
|
||||
<div className="bg-muted/80 border border-border rounded-xl p-3 space-y-2.5">
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="bg-muted/80 border border-border rounded-xl p-3 space-y-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
{icon}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
export function RouteAnnouncer() {
|
||||
const location = useLocation();
|
||||
const isFirstRender = useRef(true);
|
||||
const announcerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const h1 = document.querySelector("h1");
|
||||
if (h1) {
|
||||
if (!h1.hasAttribute("tabindex")) {
|
||||
h1.setAttribute("tabindex", "-1");
|
||||
}
|
||||
h1.focus({ preventScroll: true });
|
||||
if (announcerRef.current) {
|
||||
announcerRef.current.textContent = h1.textContent || "";
|
||||
}
|
||||
} else {
|
||||
const main = document.getElementById("main-content");
|
||||
if (main) {
|
||||
if (!main.hasAttribute("tabindex")) {
|
||||
main.setAttribute("tabindex", "-1");
|
||||
}
|
||||
main.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={announcerRef}
|
||||
className="sr-only"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
role="status"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export function SearchBar({ value, onChange, placeholder }: SearchBarProps) {
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={resolvedPlaceholder}
|
||||
aria-label={resolvedPlaceholder}
|
||||
className="w-full ps-10 pe-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -36,8 +36,9 @@ export function ToolCard({ tool }: ToolCardProps) {
|
||||
<div className="group flex items-center gap-3 relative">
|
||||
<button
|
||||
type="button"
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity absolute -left-5"
|
||||
className="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity absolute -left-7 p-2"
|
||||
title={t.toolCard.addToFavourites}
|
||||
aria-label={t.toolCard.addToFavourites}
|
||||
>
|
||||
<Star className="h-3 w-3 text-muted-foreground hover:text-yellow-500" />
|
||||
</button>
|
||||
@@ -58,10 +59,26 @@ export function ToolCard({ tool }: ToolCardProps) {
|
||||
{t.common.experimental}
|
||||
</span>
|
||||
)}
|
||||
{aiStatus === "not_installed" && <Download className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
{aiStatus === "queued" && <Clock className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
{aiStatus === "not_installed" && (
|
||||
<>
|
||||
<Download className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="sr-only">{t.a11y.notInstalled}</span>
|
||||
</>
|
||||
)}
|
||||
{aiStatus === "queued" && (
|
||||
<>
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="sr-only">{t.a11y.queued}</span>
|
||||
</>
|
||||
)}
|
||||
{aiStatus === "installing" && (
|
||||
<Loader2 className="h-3.5 w-3.5 text-muted-foreground animate-spin" />
|
||||
<>
|
||||
<Loader2
|
||||
className="h-3.5 w-3.5 text-muted-foreground animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{t.a11y.installing}</span>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AlertCircle, Check, Clock, Link, Loader2, RotateCw, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { type UrlImportEntry, useUrlImport } from "@/hooks/use-url-import";
|
||||
import { format } from "@/lib/format";
|
||||
import { extractUrls } from "@/lib/url-parser";
|
||||
@@ -45,6 +46,8 @@ function filenameFromUrl(url: string): string {
|
||||
|
||||
export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
useFocusTrap(dialogRef, true);
|
||||
const [text, setText] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
@@ -104,18 +107,22 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
|
||||
|
||||
{/* Modal card */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="url-import-title"
|
||||
className="relative z-10 w-full max-w-lg bg-background border border-border rounded-xl shadow-xl flex flex-col mx-4"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-border shrink-0">
|
||||
<Link className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-sm font-semibold text-foreground flex-1">{t.urlImport.title}</h2>
|
||||
<h2 id="url-import-title" className="text-sm font-semibold text-foreground flex-1">
|
||||
{t.urlImport.title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
aria-label={t.common.close}
|
||||
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -190,7 +197,7 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
|
||||
>
|
||||
{adding ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
{t.urlImport.adding}
|
||||
</>
|
||||
) : (
|
||||
@@ -215,7 +222,7 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
|
||||
>
|
||||
{importing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
{t.urlImport.adding}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { AnchorPosition } from "@/types/editor";
|
||||
@@ -21,6 +22,7 @@ const ANCHOR_POSITIONS: AnchorPosition[] = [
|
||||
];
|
||||
|
||||
export function CanvasResizeDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
const resizeCanvas = useEditorStore((s) => s.resizeCanvas);
|
||||
|
||||
@@ -57,7 +59,7 @@ export function CanvasResizeDialog({ open, onClose }: { open: boolean; onClose:
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
aria-label={t.common.close}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { editorStageRefHolder } from "@/components/editor/editor-canvas";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type {
|
||||
@@ -63,6 +64,7 @@ function getMimeType(format: ExportFormat): string {
|
||||
}
|
||||
|
||||
export function ExportDialog({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
const markClean = useEditorStore((s) => s.markClean);
|
||||
|
||||
@@ -454,7 +456,7 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 text-muted-foreground hover:text-foreground rounded transition-colors"
|
||||
aria-label="Close"
|
||||
aria-label={t.common.close}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// apps/web/src/components/editor/common/fill-dialog.tsx
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { cn, generateId } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { CanvasObject } from "@/types/editor";
|
||||
@@ -44,6 +45,7 @@ function resolveColor(
|
||||
}
|
||||
|
||||
export function FillDialog({ open, onClose }: FillDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [content, setContent] = useState<FillContent>("foreground");
|
||||
const [customColor, setCustomColor] = useState("#ff0000");
|
||||
const [opacity, setOpacity] = useState(100);
|
||||
@@ -119,7 +121,7 @@ export function FillDialog({ open, onClose }: FillDialogProps) {
|
||||
<div
|
||||
className="w-80 rounded-lg bg-card border border-border shadow-xl p-4"
|
||||
role="dialog"
|
||||
aria-label="Fill"
|
||||
aria-label={t.a11y.fill}
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3">Fill</h3>
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Lock, Unlock, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
export function ImageResizeDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
const resizeImage = useEditorStore((s) => s.resizeImage);
|
||||
|
||||
@@ -68,7 +70,7 @@ export function ImageResizeDialog({ open, onClose }: { open: boolean; onClose: (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
aria-label={t.common.close}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// apps/web/src/components/editor/common/loading-overlay.tsx
|
||||
import { X } from "lucide-react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
export function LoadingOverlay() {
|
||||
const { t } = useTranslation();
|
||||
const loadingState = useEditorStore((s) => s.loadingState);
|
||||
const setLoadingState = useEditorStore((s) => s.setLoadingState);
|
||||
|
||||
@@ -43,7 +45,7 @@ export function LoadingOverlay() {
|
||||
type="button"
|
||||
onClick={() => setLoadingState(null)}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground mt-1"
|
||||
aria-label="Cancel operation"
|
||||
aria-label={t.a11y.cancelOperation}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
|
||||
@@ -60,7 +60,7 @@ export function WelcomeScreen() {
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
aria-label="Image drop zone"
|
||||
aria-label={t.a11y.imageDropZone}
|
||||
className="absolute inset-0 flex items-center justify-center z-10"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
|
||||
@@ -404,7 +404,7 @@ function MenuItemRow({ item, onClose }: { item: MenuItem; onClose: () => void })
|
||||
className={cn(
|
||||
"flex items-center justify-between px-3 py-1 text-xs cursor-default select-none rounded-sm",
|
||||
item.disabled
|
||||
? "text-muted-foreground/50"
|
||||
? "text-muted-foreground"
|
||||
: "text-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
data-testid={`menu-item-${toTestId(item.label)}`}
|
||||
@@ -436,7 +436,7 @@ function MenuItemRow({ item, onClose }: { item: MenuItem; onClose: () => void })
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full px-3 py-1 text-xs cursor-default select-none rounded-sm text-start",
|
||||
item.disabled
|
||||
? "text-muted-foreground/50 pointer-events-none"
|
||||
? "text-muted-foreground pointer-events-none"
|
||||
: "text-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
disabled={item.disabled}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// apps/web/src/components/editor/editor-right-panel.tsx
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { AdjustmentsPanel } from "./panels/adjustments-panel";
|
||||
@@ -15,6 +16,7 @@ const TABS = [
|
||||
];
|
||||
|
||||
export function EditorRightPanel() {
|
||||
const { t } = useTranslation();
|
||||
const visible = useEditorStore((s) => s.rightPanelVisible);
|
||||
const activeTab = useEditorStore((s) => s.rightPanelTab);
|
||||
const setTab = useEditorStore((s) => s.setRightPanelTab);
|
||||
@@ -27,7 +29,7 @@ export function EditorRightPanel() {
|
||||
type="button"
|
||||
onClick={togglePanel}
|
||||
className="flex items-center justify-center w-6 bg-card border-l border-border"
|
||||
aria-label="Expand panel"
|
||||
aria-label={t.a11y.expandPanel}
|
||||
>
|
||||
<ChevronRight size={14} className="text-muted-foreground rotate-180" />
|
||||
</button>
|
||||
@@ -63,7 +65,7 @@ export function EditorRightPanel() {
|
||||
type="button"
|
||||
onClick={togglePanel}
|
||||
className="px-1.5 py-2 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Collapse panel"
|
||||
aria-label={t.a11y.collapsePanel}
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ArrowLeftRight, Check, X } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { ASPECT_RATIOS } from "@/components/editor/tools/crop-tool";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
@@ -9,6 +10,7 @@ import { useEditorStore } from "@/stores/editor-store";
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CropOptions() {
|
||||
const { t } = useTranslation();
|
||||
const cropState = useEditorStore((s) => s.cropState);
|
||||
const setCropState = useEditorStore((s) => s.setCropState);
|
||||
const applyCrop = useEditorStore((s) => s.applyCrop);
|
||||
@@ -123,7 +125,7 @@ export function CropOptions() {
|
||||
type="button"
|
||||
onClick={handleSwap}
|
||||
title="Swap dimensions"
|
||||
aria-label="Swap dimensions"
|
||||
aria-label={t.a11y.swapDimensions}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ArrowLeftRight className="h-3.5 w-3.5" />
|
||||
@@ -150,7 +152,7 @@ export function CropOptions() {
|
||||
type="button"
|
||||
onClick={handleApply}
|
||||
title="Apply Crop (Enter)"
|
||||
aria-label="Apply Crop"
|
||||
aria-label={t.a11y.applyCrop}
|
||||
className={cn(
|
||||
"flex h-7 items-center gap-1 rounded bg-primary px-2.5 text-xs text-primary-foreground",
|
||||
"hover:bg-primary/90 transition-colors",
|
||||
@@ -163,7 +165,7 @@ export function CropOptions() {
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
title="Cancel Crop (Escape)"
|
||||
aria-label="Cancel Crop"
|
||||
aria-label={t.a11y.cancelCrop}
|
||||
className={cn(
|
||||
"flex h-7 items-center gap-1 rounded border border-border px-2.5 text-xs",
|
||||
"text-muted-foreground hover:bg-muted hover:text-foreground transition-colors",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Underline,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { TextAttrs } from "@/types/editor";
|
||||
@@ -131,6 +132,7 @@ function NumberInput({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FontDropdown({ value, onChange }: { value: string; onChange: (name: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const fonts = useMemo(() => getAllFonts(), []);
|
||||
@@ -162,7 +164,7 @@ function FontDropdown({ value, onChange }: { value: string; onChange: (name: str
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Font family"
|
||||
aria-label={t.a11y.fontFamily}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 h-7 px-2 rounded border border-border bg-background",
|
||||
@@ -231,6 +233,7 @@ function FontDropdown({ value, onChange }: { value: string; onChange: (name: str
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TextOptions() {
|
||||
const { t } = useTranslation();
|
||||
const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
|
||||
const objects = useEditorStore((s) => s.objects);
|
||||
|
||||
@@ -409,7 +412,7 @@ export function TextOptions() {
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
aria-label="Text color"
|
||||
aria-label={t.a11y.textColor}
|
||||
value={attrs.fill}
|
||||
onChange={(e) => updateSelected({ fill: e.target.value })}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Check, FlipHorizontal2, FlipVertical2, Lock, Unlock, X } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import type { TransformToolApi } from "@/components/editor/tools/transform-tool";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -55,6 +56,7 @@ function NumericInput({
|
||||
}
|
||||
|
||||
export function TransformOptions({ api }: { api: TransformToolApi }) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
values,
|
||||
lockedAspect,
|
||||
@@ -132,7 +134,7 @@ export function TransformOptions({ api }: { api: TransformToolApi }) {
|
||||
type="button"
|
||||
onClick={flipHorizontal}
|
||||
title="Flip Horizontal"
|
||||
aria-label="Flip Horizontal"
|
||||
aria-label={t.a11y.flipHorizontal}
|
||||
className="flex h-7 w-7 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
<FlipHorizontal2 className="h-4 w-4" />
|
||||
@@ -141,7 +143,7 @@ export function TransformOptions({ api }: { api: TransformToolApi }) {
|
||||
type="button"
|
||||
onClick={flipVertical}
|
||||
title="Flip Vertical"
|
||||
aria-label="Flip Vertical"
|
||||
aria-label={t.a11y.flipVertical}
|
||||
className="flex h-7 w-7 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
<FlipVertical2 className="h-4 w-4" />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { ArrowLeftRight, RotateCcw } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { HexColorPicker } from "react-colorful";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { ColorSwatch } from "../common/color-swatch";
|
||||
@@ -108,6 +109,7 @@ function ColorInputFields({
|
||||
color: string;
|
||||
onColorChange: (hex: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rgb = hexToRgb(color);
|
||||
const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
|
||||
|
||||
@@ -130,7 +132,7 @@ function ColorInputFields({
|
||||
)}
|
||||
maxLength={7}
|
||||
spellCheck={false}
|
||||
aria-label="Hex color value"
|
||||
aria-label={t.a11y.hexColorValue}
|
||||
data-testid="color-hex-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -296,6 +298,7 @@ function ColorPickerPopover({
|
||||
// --- Main component ---
|
||||
|
||||
export function ColorPanel() {
|
||||
const { t } = useTranslation();
|
||||
const foregroundColor = useEditorStore((s) => s.foregroundColor);
|
||||
const backgroundColor = useEditorStore((s) => s.backgroundColor);
|
||||
const recentColors = useEditorStore((s) => s.recentColors);
|
||||
@@ -362,7 +365,7 @@ export function ColorPanel() {
|
||||
pickerTarget === "bg" ? "border-primary ring-1 ring-primary" : "border-border",
|
||||
)}
|
||||
style={{ backgroundColor: backgroundColor }}
|
||||
aria-label="Background color"
|
||||
aria-label={t.a11y.backgroundColor}
|
||||
data-testid="bg-color-swatch"
|
||||
/>
|
||||
{/* Foreground swatch (top-left, overlapping) */}
|
||||
@@ -374,7 +377,7 @@ export function ColorPanel() {
|
||||
pickerTarget === "fg" ? "border-primary ring-1 ring-primary" : "border-border",
|
||||
)}
|
||||
style={{ backgroundColor: foregroundColor }}
|
||||
aria-label="Foreground color"
|
||||
aria-label={t.a11y.foregroundColor}
|
||||
data-testid="fg-color-swatch"
|
||||
/>
|
||||
|
||||
@@ -388,7 +391,7 @@ export function ColorPanel() {
|
||||
"bg-card/80 hover:bg-muted",
|
||||
)}
|
||||
title="Swap colors (X)"
|
||||
aria-label="Swap foreground and background colors"
|
||||
aria-label={t.a11y.swapColors}
|
||||
data-testid="swap-colors"
|
||||
>
|
||||
<ArrowLeftRight size={10} />
|
||||
@@ -404,7 +407,7 @@ export function ColorPanel() {
|
||||
"bg-card/80 hover:bg-muted",
|
||||
)}
|
||||
title="Reset colors (D)"
|
||||
aria-label="Reset to default black and white"
|
||||
aria-label={t.a11y.resetColors}
|
||||
data-testid="reset-colors"
|
||||
>
|
||||
<RotateCcw size={10} />
|
||||
@@ -426,7 +429,7 @@ export function ColorPanel() {
|
||||
)}
|
||||
maxLength={7}
|
||||
spellCheck={false}
|
||||
aria-label="Foreground color hex value"
|
||||
aria-label={t.a11y.foregroundHex}
|
||||
data-testid="foreground-hex-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
@@ -80,6 +81,7 @@ interface HistoryEntry {
|
||||
}
|
||||
|
||||
export function HistoryPanel() {
|
||||
const { t } = useTranslation();
|
||||
const lastAction = useEditorStore((s) => s.lastAction);
|
||||
|
||||
// Force re-render when history changes by subscribing to history version
|
||||
@@ -170,7 +172,7 @@ export function HistoryPanel() {
|
||||
? "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
: "text-muted-foreground/30 cursor-not-allowed",
|
||||
)}
|
||||
aria-label="Undo"
|
||||
aria-label={t.a11y.undo}
|
||||
title="Undo (Ctrl+Z)"
|
||||
>
|
||||
<Undo2 size={14} />
|
||||
@@ -185,7 +187,7 @@ export function HistoryPanel() {
|
||||
? "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
: "text-muted-foreground/30 cursor-not-allowed",
|
||||
)}
|
||||
aria-label="Redo"
|
||||
aria-label={t.a11y.redo}
|
||||
title="Redo (Ctrl+Shift+Z)"
|
||||
>
|
||||
<Redo2 size={14} />
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Unlock,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { EditorLayer, ObjectEffects } from "@/types/editor";
|
||||
@@ -80,6 +81,7 @@ const DEFAULT_STROKE: NonNullable<ObjectEffects["stroke"]> = {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function LayersPanel() {
|
||||
const { t } = useTranslation();
|
||||
const layers = useEditorStore((s) => s.layers);
|
||||
const activeLayerId = useEditorStore((s) => s.activeLayerId);
|
||||
const objects = useEditorStore((s) => s.objects);
|
||||
@@ -167,7 +169,11 @@ export function LayersPanel() {
|
||||
</div>
|
||||
|
||||
{/* Layer list */}
|
||||
<div className="flex-1 overflow-y-auto py-1 min-h-0" role="listbox" aria-label="Layers">
|
||||
<div
|
||||
className="flex-1 overflow-y-auto py-1 min-h-0"
|
||||
role="listbox"
|
||||
aria-label={t.a11y.layers}
|
||||
>
|
||||
{displayLayers.map((layer) => {
|
||||
const realIndex = layers.findIndex((l) => l.id === layer.id);
|
||||
return (
|
||||
@@ -202,7 +208,7 @@ export function LayersPanel() {
|
||||
onClick={addLayer}
|
||||
className="flex items-center justify-center h-7 w-7 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="New Layer (Ctrl+Shift+N)"
|
||||
aria-label="Add layer"
|
||||
aria-label={t.a11y.addLayer}
|
||||
data-testid="add-layer-btn"
|
||||
>
|
||||
<Plus size={16} />
|
||||
@@ -217,7 +223,7 @@ export function LayersPanel() {
|
||||
disabled={layers.length <= 1}
|
||||
className="flex items-center justify-center h-7 w-7 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Delete Layer"
|
||||
aria-label="Delete layer"
|
||||
aria-label={t.a11y.deleteLayer}
|
||||
data-testid="delete-layer-btn"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
@@ -523,7 +529,7 @@ function LayerRow({
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Layers size={12} className="text-muted-foreground/50" />
|
||||
<Layers size={12} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -673,7 +679,7 @@ function LayerEffectsSection({
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-0.5 max-h-[200px] overflow-y-auto">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70 px-1">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground px-1">
|
||||
Layer Effects
|
||||
</p>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Minus, Plus } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
const THUMBNAIL_MAX_HEIGHT = 80;
|
||||
@@ -10,6 +11,7 @@ const MIN_ZOOM = 0.01;
|
||||
const MAX_ZOOM = 64;
|
||||
|
||||
export function NavigatorPanel() {
|
||||
const { t } = useTranslation();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
@@ -245,7 +247,7 @@ export function NavigatorPanel() {
|
||||
type="button"
|
||||
onClick={() => setZoom(Math.max(MIN_ZOOM, zoom / 1.2))}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Zoom out"
|
||||
aria-label={t.a11y.zoomOut}
|
||||
>
|
||||
<Minus size={12} />
|
||||
</button>
|
||||
@@ -262,7 +264,7 @@ export function NavigatorPanel() {
|
||||
type="button"
|
||||
onClick={() => setZoom(Math.min(MAX_ZOOM, zoom * 1.2))}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Zoom in"
|
||||
aria-label={t.a11y.zoomIn}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
|
||||
@@ -44,7 +44,7 @@ function AuthImage({ src, alt, className }: { src: string; alt: string; classNam
|
||||
if (failed) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center bg-muted/50", className)}>
|
||||
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
|
||||
<ImageIcon className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { APP_VERSION } from "@snapotter/shared";
|
||||
import { BookOpen, ExternalLink, Github, Keyboard, X } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { formatShortcut } from "@/hooks/use-keyboard-shortcuts";
|
||||
|
||||
interface HelpDialogProps {
|
||||
@@ -25,6 +26,8 @@ const SHORTCUTS = [
|
||||
|
||||
export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
useFocusTrap(dialogRef, open);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
@@ -44,14 +47,23 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<div className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-lg max-h-[85dvh] flex flex-col overflow-hidden">
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="help-dialog-title"
|
||||
className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-lg max-h-[85dvh] flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border shrink-0">
|
||||
<h2 className="text-lg font-semibold text-foreground">{t.help.heading}</h2>
|
||||
<h2 id="help-dialog-title" className="text-lg font-semibold text-foreground">
|
||||
{t.help.heading}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
aria-label={t.a11y.closeHelp}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Globe, Menu, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useConnectionStore } from "@/stores/connection-store";
|
||||
@@ -30,7 +31,9 @@ export function AppLayout({
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const { locale, setLocale, supportedLocales } = useTranslation();
|
||||
const mobileSidebarRef = useRef<HTMLDivElement>(null);
|
||||
useFocusTrap(mobileSidebarRef, mobileSidebarOpen);
|
||||
const { t, locale, setLocale, supportedLocales } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const connectionStatus = useConnectionStore((s) => s.status);
|
||||
const bannerVisible = connectionStatus !== "connected";
|
||||
@@ -58,7 +61,13 @@ export function AppLayout({
|
||||
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm cursor-default"
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
<div className="fixed inset-y-0 left-0 z-50 w-64 bg-background border-r border-border shadow-xl animate-in slide-in-from-left">
|
||||
<div
|
||||
ref={mobileSidebarRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t.a11y.openSidebar}
|
||||
className="fixed inset-y-0 left-0 z-50 w-64 bg-background border-r border-border shadow-xl animate-in slide-in-from-left"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<OtterLogo className="h-5 w-5 text-primary" />
|
||||
@@ -70,6 +79,7 @@ export function AppLayout({
|
||||
type="button"
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
className="p-2.5 rounded-lg hover:bg-muted"
|
||||
aria-label={t.a11y.closeSidebar}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -118,6 +128,7 @@ export function AppLayout({
|
||||
type="button"
|
||||
onClick={() => setMobileSidebarOpen(true)}
|
||||
className="p-2.5 -ms-1 rounded-lg hover:bg-muted"
|
||||
aria-label={t.a11y.openSidebar}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -132,7 +143,10 @@ export function AppLayout({
|
||||
|
||||
{showToolPanel && !isMobile && <ToolPanel />}
|
||||
|
||||
<main className={cn("flex-1 flex flex-col overflow-hidden", isMobile && "pt-12 pb-20")}>
|
||||
<main
|
||||
id="main-content"
|
||||
className={cn("flex-1 flex flex-col overflow-hidden", isMobile && "pt-12 pb-20")}
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto p-6 flex items-center justify-center">
|
||||
{children || <Dropzone onFiles={onFiles} onUrlImport={onUrlImport} accept="image/*" />}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useTheme } from "@/hooks/use-theme";
|
||||
|
||||
function LanguageSelector() {
|
||||
const { locale, setLocale, supportedLocales } = useTranslation();
|
||||
const { t, locale, setLocale, supportedLocales } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -52,7 +52,7 @@ function LanguageSelector() {
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
role="img"
|
||||
aria-label="Selected"
|
||||
aria-label={t.a11y.selected}
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
|
||||
@@ -44,6 +44,7 @@ export function Sidebar({
|
||||
expanded = false,
|
||||
}: SidebarProps) {
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const { topItems, bottomItems } = useNavItems();
|
||||
|
||||
const renderItem = (item: SidebarItem, isActive: boolean) => {
|
||||
@@ -101,11 +102,11 @@ export function Sidebar({
|
||||
|
||||
if (expanded) {
|
||||
return (
|
||||
<div className="flex flex-col p-3 gap-1">
|
||||
<nav aria-label={t.a11y.navigationMenu} className="flex flex-col p-3 gap-1">
|
||||
{topItems.map((item) => renderItem(item, location.pathname === item.href))}
|
||||
<div className="border-t border-border my-2" />
|
||||
{bottomItems.map((item) => renderItem(item, false))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,9 +116,9 @@ export function Sidebar({
|
||||
<OtterLogo className="h-7 w-7 text-primary" />
|
||||
</div>
|
||||
<div className="border-t border-border w-10 mb-2" />
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
<nav aria-label={t.a11y.navigationMenu} className="flex flex-col gap-1 flex-1">
|
||||
{topItems.map((item) => renderItem(item, location.pathname === item.href))}
|
||||
</div>
|
||||
</nav>
|
||||
<div className="border-t border-border w-10 my-2" />
|
||||
<div className="flex flex-col gap-1">
|
||||
{bottomItems.map((item) => renderItem(item, false))}
|
||||
|
||||
@@ -299,7 +299,7 @@ function BundleCard({
|
||||
disabled
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium opacity-50"
|
||||
>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
||||
{t.settings.aiFeatures.installing}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -26,9 +26,10 @@ import {
|
||||
Wrench,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useMobile } from "@/hooks/use-mobile";
|
||||
import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@/lib/api";
|
||||
import { format, plural } from "@/lib/format";
|
||||
@@ -127,6 +128,10 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const NAV_ITEMS = useNavItems();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const mobileDialogRef = useRef<HTMLDivElement>(null);
|
||||
useFocusTrap(dialogRef, open && !isMobile);
|
||||
useFocusTrap(mobileDialogRef, open && isMobile);
|
||||
|
||||
const visibleNavItems = NAV_ITEMS.filter(
|
||||
(item) =>
|
||||
@@ -148,14 +153,23 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-background">
|
||||
<div
|
||||
ref={mobileDialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="settings-dialog-title-mobile"
|
||||
className="fixed inset-0 z-50 flex flex-col bg-background"
|
||||
>
|
||||
{/* Mobile header */}
|
||||
<div className="flex items-center justify-between px-4 pt-4 pb-2 shrink-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">{t.settings.heading}</h2>
|
||||
<h2 id="settings-dialog-title-mobile" className="text-sm font-semibold text-foreground">
|
||||
{t.settings.heading}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-2.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
aria-label={t.a11y.closeSettings}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -169,7 +183,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
||||
type="button"
|
||||
onClick={() => setSection(item.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium whitespace-nowrap shrink-0",
|
||||
"flex items-center gap-1.5 px-3 py-2.5 rounded-full text-xs font-medium whitespace-nowrap shrink-0",
|
||||
section === item.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground",
|
||||
@@ -211,14 +225,18 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="settings-dialog-title"
|
||||
className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-3xl h-[85dvh] flex overflow-hidden"
|
||||
>
|
||||
{/* Sidebar nav */}
|
||||
<div className="w-48 border-r border-border bg-muted/30 p-3 space-y-1 shrink-0">
|
||||
<div className="flex items-center justify-between mb-4 px-2">
|
||||
<h2 className="text-sm font-semibold text-foreground">{t.settings.heading}</h2>
|
||||
<h2 id="settings-dialog-title" className="text-sm font-semibold text-foreground">
|
||||
{t.settings.heading}
|
||||
</h2>
|
||||
</div>
|
||||
{visibleNavItems.map((item) => (
|
||||
<button
|
||||
@@ -244,6 +262,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute top-3 right-3 p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
aria-label={t.a11y.closeSettings}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -426,6 +445,7 @@ function GeneralSection() {
|
||||
<select
|
||||
value={defaultToolView}
|
||||
onChange={(e) => setDefaultToolView(e.target.value)}
|
||||
aria-label={t.settings.general.defaultToolViewLabel}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="sidebar">{t.settings.general.sidebarOption}</option>
|
||||
@@ -440,6 +460,7 @@ function GeneralSection() {
|
||||
<select
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value)}
|
||||
aria-label={t.settings.system.languageLabel}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
{supportedLocales.map((l) => (
|
||||
@@ -464,7 +485,7 @@ function GeneralSection() {
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
|
||||
{t.settings.general.saveButton}
|
||||
</button>
|
||||
{saveMsg && (
|
||||
@@ -553,6 +574,7 @@ function SystemSection() {
|
||||
type="number"
|
||||
value={settings.fileUploadLimitMb || "100"}
|
||||
onChange={(e) => updateSetting("fileUploadLimitMb", e.target.value)}
|
||||
aria-label={t.settings.system.fileUploadLimitLabel}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24"
|
||||
min={1}
|
||||
/>
|
||||
@@ -565,6 +587,7 @@ function SystemSection() {
|
||||
<select
|
||||
value={settings.defaultTheme || "system"}
|
||||
onChange={(e) => updateSetting("defaultTheme", e.target.value)}
|
||||
aria-label={t.settings.system.defaultThemeLabel}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="light">{t.settings.system.lightOption}</option>
|
||||
@@ -580,6 +603,7 @@ function SystemSection() {
|
||||
<select
|
||||
value={settings.defaultLocale || "en"}
|
||||
onChange={(e) => updateSetting("defaultLocale", e.target.value)}
|
||||
aria-label={t.settings.system.languageLabel}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
{SUPPORTED_LOCALES.map((l) => (
|
||||
@@ -598,6 +622,7 @@ function SystemSection() {
|
||||
type="number"
|
||||
value={settings.loginAttemptLimit || "5"}
|
||||
onChange={(e) => updateSetting("loginAttemptLimit", e.target.value)}
|
||||
aria-label={t.settings.system.loginAttemptLimitLabel}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24"
|
||||
min={1}
|
||||
max={100}
|
||||
@@ -617,6 +642,7 @@ function SystemSection() {
|
||||
type="number"
|
||||
value={settings.tempFileMaxAgeHours || "24"}
|
||||
onChange={(e) => updateSetting("tempFileMaxAgeHours", e.target.value)}
|
||||
aria-label={t.settings.fileManagement.maxAge}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24"
|
||||
min={1}
|
||||
/>
|
||||
@@ -627,6 +653,9 @@ function SystemSection() {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={settings.startupCleanup !== "false"}
|
||||
aria-label={t.settings.fileManagement.startupCleanup}
|
||||
onClick={() =>
|
||||
updateSetting("startupCleanup", settings.startupCleanup === "false" ? "true" : "false")
|
||||
}
|
||||
@@ -651,7 +680,7 @@ function SystemSection() {
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
|
||||
{t.settings.system.saveButton}
|
||||
</button>
|
||||
{saveMsg && (
|
||||
@@ -731,13 +760,19 @@ function SecuritySection() {
|
||||
|
||||
<div className="space-y-3 max-w-sm">
|
||||
<div className="relative">
|
||||
<label htmlFor="current-password" className="sr-only">
|
||||
{t.settings.security.currentPasswordPlaceholder}
|
||||
</label>
|
||||
<input
|
||||
id="current-password"
|
||||
type={showCurrent ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
placeholder={t.settings.security.currentPasswordPlaceholder}
|
||||
className="w-full px-3 py-2 pe-10 rounded-lg border border-border bg-background text-sm text-foreground"
|
||||
required
|
||||
aria-invalid={message?.type === "error" || undefined}
|
||||
aria-describedby={message ? "password-change-error" : undefined}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -749,13 +784,19 @@ function SecuritySection() {
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<label htmlFor="new-password" className="sr-only">
|
||||
{t.settings.security.newPasswordPlaceholder}
|
||||
</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type={showNew ? "text" : "password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder={t.settings.security.newPasswordPlaceholder}
|
||||
className="w-full px-3 py-2 pe-10 rounded-lg border border-border bg-background text-sm text-foreground"
|
||||
required
|
||||
aria-invalid={message?.type === "error" || undefined}
|
||||
aria-describedby={message ? "password-change-error" : undefined}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -767,13 +808,19 @@ function SecuritySection() {
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<label htmlFor="confirm-password" className="sr-only">
|
||||
{t.settings.security.confirmPasswordPlaceholder}
|
||||
</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type={showConfirm ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder={t.settings.security.confirmPasswordPlaceholder}
|
||||
className="w-full px-3 py-2 pe-10 rounded-lg border border-border bg-background text-sm text-foreground"
|
||||
required
|
||||
aria-invalid={message?.type === "error" || undefined}
|
||||
aria-describedby={message ? "password-change-error" : undefined}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -787,6 +834,8 @@ function SecuritySection() {
|
||||
|
||||
{message && (
|
||||
<p
|
||||
id="password-change-error"
|
||||
role="alert"
|
||||
className={cn(
|
||||
"text-sm",
|
||||
message.type === "error"
|
||||
@@ -803,7 +852,7 @@ function SecuritySection() {
|
||||
disabled={submitting}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
|
||||
{t.settings.security.changePasswordButton}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1098,16 +1147,26 @@ function PeopleSection() {
|
||||
{t.settings.people.newMemberHeading}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
placeholder={t.settings.people.usernamePlaceholder}
|
||||
required
|
||||
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div>
|
||||
<label htmlFor="new-user-username" className="sr-only">
|
||||
{t.settings.people.usernamePlaceholder}
|
||||
</label>
|
||||
<input
|
||||
id="new-user-username"
|
||||
type="text"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
placeholder={t.settings.people.usernamePlaceholder}
|
||||
required
|
||||
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label htmlFor="new-user-password" className="sr-only">
|
||||
{t.auth.password}
|
||||
</label>
|
||||
<input
|
||||
id="new-user-password"
|
||||
type={showGeneratedPw ? "text" : "password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => {
|
||||
@@ -1188,7 +1247,7 @@ function PeopleSection() {
|
||||
disabled={adding || atLimit}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{adding && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{adding && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
|
||||
{t.common.create}
|
||||
</button>
|
||||
<button
|
||||
@@ -1223,7 +1282,11 @@ function PeopleSection() {
|
||||
{t.settings.people.copyPasswordWarning}
|
||||
</p>
|
||||
)}
|
||||
{addError && <p className="text-sm text-destructive">{addError}</p>}
|
||||
{addError && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{addError}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -1440,6 +1503,7 @@ function PeopleSection() {
|
||||
}}
|
||||
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Actions"
|
||||
aria-label={t.common.actions}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -1595,6 +1659,7 @@ function ApiKeysSection() {
|
||||
value={keyName}
|
||||
onChange={(e) => setKeyName(e.target.value)}
|
||||
placeholder={t.settings.apiKeys.keyNamePlaceholder}
|
||||
aria-label={t.settings.apiKeys.keyNamePlaceholder}
|
||||
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground w-48"
|
||||
/>
|
||||
<button
|
||||
@@ -1603,7 +1668,11 @@ function ApiKeysSection() {
|
||||
disabled={generating}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{generating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Key className="h-4 w-4" />}
|
||||
{generating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Key className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
{t.settings.apiKeys.generateButton}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1678,6 +1747,7 @@ function ApiKeysSection() {
|
||||
onClick={() => copyKey(newKey)}
|
||||
className="p-2 rounded-lg hover:bg-muted transition-colors text-muted-foreground shrink-0"
|
||||
title="Copy"
|
||||
aria-label={t.common.copy}
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</button>
|
||||
@@ -1718,6 +1788,7 @@ function ApiKeysSection() {
|
||||
onClick={() => deleteKey(k.id)}
|
||||
className="p-1.5 rounded-lg hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Delete key"
|
||||
aria-label={t.a11y.deleteKey}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -1895,7 +1966,7 @@ function TeamsSection() {
|
||||
disabled={creating}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{creating && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{creating && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
|
||||
{t.common.create}
|
||||
</button>
|
||||
<button
|
||||
@@ -2359,6 +2430,7 @@ function RolesSection() {
|
||||
}}
|
||||
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Edit role"
|
||||
aria-label={t.a11y.editRole}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -2367,6 +2439,7 @@ function RolesSection() {
|
||||
onClick={() => handleDelete(role)}
|
||||
className="p-1.5 rounded-lg hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Delete role"
|
||||
aria-label={t.a11y.deleteRole}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -2740,6 +2813,9 @@ function ToolsSection() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={!isDisabled}
|
||||
aria-label={getToolName(t, tool.id, tool.name)}
|
||||
onClick={() => toggleTool(tool.id)}
|
||||
className={cn(
|
||||
"w-11 h-6 rounded-full transition-colors relative shrink-0 ms-3",
|
||||
@@ -2780,7 +2856,7 @@ function ToolsSection() {
|
||||
disabled={saving || loadFailed}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
|
||||
{t.settings.tools.saveButton}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -2825,6 +2901,9 @@ function AnalyticsSection() {
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
aria-label={t.analytics.settingsTitle}
|
||||
onClick={() => toggleAnalytics(!enabled)}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
"[tabindex]:not([tabindex='-1'])",
|
||||
].join(", ");
|
||||
|
||||
export function useFocusTrap(containerRef: React.RefObject<HTMLElement | null>, active: boolean) {
|
||||
const returnFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !containerRef.current) return;
|
||||
|
||||
returnFocusRef.current = document.activeElement as HTMLElement;
|
||||
const container = containerRef.current;
|
||||
|
||||
const getFocusableElements = () =>
|
||||
Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
||||
(el) => !el.closest("[aria-hidden='true']"),
|
||||
);
|
||||
|
||||
const focusFirst = () => {
|
||||
const elements = getFocusableElements();
|
||||
if (elements.length > 0) {
|
||||
elements[0].focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab") return;
|
||||
|
||||
const elements = getFocusableElements();
|
||||
if (elements.length === 0) return;
|
||||
|
||||
const first = elements[0];
|
||||
const last = elements[elements.length - 1];
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const elements = getFocusableElements();
|
||||
if (elements.length > 0 && !container.contains(document.activeElement)) {
|
||||
elements[0].focus();
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(container, { childList: true, subtree: true });
|
||||
container.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
requestAnimationFrame(focusFirst);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("keydown", handleKeyDown);
|
||||
observer.disconnect();
|
||||
if (returnFocusRef.current && returnFocusRef.current.isConnected) {
|
||||
returnFocusRef.current.focus();
|
||||
}
|
||||
};
|
||||
}, [active, containerRef]);
|
||||
}
|
||||
@@ -54,9 +54,7 @@ export function AnalyticsConsentPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground/60">
|
||||
{t.analytics.consentChangeable}
|
||||
</p>
|
||||
<p className="text-center text-xs text-muted-foreground">{t.analytics.consentChangeable}</p>
|
||||
|
||||
<div className="flex gap-2.5">
|
||||
<button
|
||||
|
||||
@@ -910,7 +910,7 @@ export function AutomatePage() {
|
||||
{/* Preview content */}
|
||||
{!previewCollapsed && (
|
||||
<section
|
||||
aria-label="Image area"
|
||||
aria-label={t.a11y.imageArea}
|
||||
className="flex-1 flex flex-col overflow-hidden min-h-0"
|
||||
onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
|
||||
tabIndex={hasMultiple ? 0 : undefined}
|
||||
@@ -921,7 +921,7 @@ export function AutomatePage() {
|
||||
type="button"
|
||||
onClick={navigatePrev}
|
||||
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
|
||||
aria-label="Previous image"
|
||||
aria-label={t.a11y.previousImage}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -931,7 +931,7 @@ export function AutomatePage() {
|
||||
type="button"
|
||||
onClick={navigateNext}
|
||||
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
|
||||
aria-label="Next image"
|
||||
aria-label={t.a11y.nextImage}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -151,6 +151,7 @@ export function EditorPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen overflow-hidden bg-background text-foreground">
|
||||
<h1 className="sr-only">{t.editor.welcome.heading}</h1>
|
||||
<EditorMenuBar
|
||||
onNewDocument={() => setShowNewDocument(true)}
|
||||
onOpenImage={handleOpenImage}
|
||||
|
||||
@@ -18,6 +18,7 @@ export function FilesPage() {
|
||||
if (isMobile) {
|
||||
return (
|
||||
<AppLayout showToolPanel={false}>
|
||||
<h1 className="sr-only">{t.files.myFiles}</h1>
|
||||
<div className="flex flex-col h-full w-full overflow-hidden">
|
||||
{/* Mobile tabs */}
|
||||
<div className="flex border-b border-border">
|
||||
@@ -68,7 +69,7 @@ export function FilesPage() {
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="File Details"
|
||||
aria-label={t.a11y.fileDetails}
|
||||
className="fixed inset-0 z-50 bg-black/50"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setShowDetails(false);
|
||||
@@ -95,6 +96,7 @@ export function FilesPage() {
|
||||
|
||||
return (
|
||||
<AppLayout showToolPanel={false}>
|
||||
<h1 className="sr-only">{t.files.myFiles}</h1>
|
||||
<div className="flex h-full w-full overflow-hidden">
|
||||
<FilesNav />
|
||||
{activeTab === "recent" ? (
|
||||
|
||||
@@ -138,6 +138,7 @@ export function FullscreenGridPage() {
|
||||
|
||||
{/* Grid */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
|
||||
<h1 className="sr-only">{t.nav.tools}</h1>
|
||||
{activeCategories.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<Search className="h-12 w-12 mx-auto mb-4 opacity-30" />
|
||||
|
||||
@@ -99,6 +99,7 @@ export function HomePage() {
|
||||
if (isMobile && hasFile) {
|
||||
return (
|
||||
<AppLayout showToolPanel={false} onFiles={handleFiles}>
|
||||
<h1 className="sr-only">{t.nav.tools}</h1>
|
||||
<div className="flex flex-col h-full w-full">
|
||||
{/* File info bar */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
|
||||
@@ -141,11 +142,25 @@ export function HomePage() {
|
||||
{getToolName(t, tool.id, tool.name)}
|
||||
</span>
|
||||
{status === "not_installed" && (
|
||||
<Download className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<>
|
||||
<Download className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="sr-only">{t.a11y.notInstalled}</span>
|
||||
</>
|
||||
)}
|
||||
{status === "queued" && (
|
||||
<>
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="sr-only">{t.a11y.queued}</span>
|
||||
</>
|
||||
)}
|
||||
{status === "queued" && <Clock className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
{status === "installing" && (
|
||||
<Loader2 className="h-3.5 w-3.5 text-muted-foreground animate-spin" />
|
||||
<>
|
||||
<Loader2
|
||||
className="h-3.5 w-3.5 text-muted-foreground animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{t.a11y.installing}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
@@ -160,7 +175,7 @@ export function HomePage() {
|
||||
<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">{t.homePage.generatingPreview}</p>
|
||||
<p className="text-xs text-muted-foreground/60">{selectedFileName}</p>
|
||||
<p className="text-xs text-muted-foreground">{selectedFileName}</p>
|
||||
</div>
|
||||
) : originalBlobUrl ? (
|
||||
<ImageViewer
|
||||
@@ -182,6 +197,7 @@ export function HomePage() {
|
||||
// File uploaded — desktop: tool selector on left, image preview on right
|
||||
return (
|
||||
<AppLayout showToolPanel={false} onFiles={handleFiles}>
|
||||
<h1 className="sr-only">{t.nav.tools}</h1>
|
||||
<div className="flex h-full w-full">
|
||||
{/* Left panel: Tool selector */}
|
||||
<div className="w-64 lg:w-80 border-r border-border overflow-y-auto shrink-0">
|
||||
@@ -233,13 +249,31 @@ export function HomePage() {
|
||||
{getToolName(t, tool.id, tool.name)}
|
||||
</span>
|
||||
{status === "not_installed" && (
|
||||
<Download className="h-3.5 w-3.5 text-muted-foreground ms-auto" />
|
||||
<>
|
||||
<Download
|
||||
className="h-3.5 w-3.5 text-muted-foreground ms-auto"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{t.a11y.notInstalled}</span>
|
||||
</>
|
||||
)}
|
||||
{status === "queued" && (
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground ms-auto" />
|
||||
<>
|
||||
<Clock
|
||||
className="h-3.5 w-3.5 text-muted-foreground ms-auto"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{t.a11y.queued}</span>
|
||||
</>
|
||||
)}
|
||||
{status === "installing" && (
|
||||
<Loader2 className="h-3.5 w-3.5 text-muted-foreground ms-auto animate-spin" />
|
||||
<>
|
||||
<Loader2
|
||||
className="h-3.5 w-3.5 text-muted-foreground ms-auto animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{t.a11y.installing}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
@@ -279,13 +313,31 @@ export function HomePage() {
|
||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm">{getToolName(t, tool.id, tool.name)}</span>
|
||||
{status === "not_installed" && (
|
||||
<Download className="h-3.5 w-3.5 text-muted-foreground ms-auto" />
|
||||
<>
|
||||
<Download
|
||||
className="h-3.5 w-3.5 text-muted-foreground ms-auto"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{t.a11y.notInstalled}</span>
|
||||
</>
|
||||
)}
|
||||
{status === "queued" && (
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground ms-auto" />
|
||||
<>
|
||||
<Clock
|
||||
className="h-3.5 w-3.5 text-muted-foreground ms-auto"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{t.a11y.queued}</span>
|
||||
</>
|
||||
)}
|
||||
{status === "installing" && (
|
||||
<Loader2 className="h-3.5 w-3.5 text-muted-foreground ms-auto animate-spin" />
|
||||
<>
|
||||
<Loader2
|
||||
className="h-3.5 w-3.5 text-muted-foreground ms-auto animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{t.a11y.installing}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
@@ -305,7 +357,7 @@ export function HomePage() {
|
||||
<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">{t.homePage.generatingPreview}</p>
|
||||
<p className="text-xs text-muted-foreground/60">{selectedFileName}</p>
|
||||
<p className="text-xs text-muted-foreground">{selectedFileName}</p>
|
||||
</div>
|
||||
) : originalBlobUrl ? (
|
||||
<ImageViewer
|
||||
|
||||
@@ -38,7 +38,7 @@ function RotatingPhrase() {
|
||||
}
|
||||
|
||||
function LanguageSelector() {
|
||||
const { locale, setLocale, supportedLocales } = useTranslation();
|
||||
const { t, locale, setLocale, supportedLocales } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -74,7 +74,7 @@ function LanguageSelector() {
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
role="img"
|
||||
aria-label="Language"
|
||||
aria-label={t.a11y.language}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
@@ -112,7 +112,7 @@ function LanguageSelector() {
|
||||
strokeLinejoin="round"
|
||||
className="text-primary shrink-0"
|
||||
role="img"
|
||||
aria-label="Selected"
|
||||
aria-label={t.a11y.selected}
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMobile } from "@/hooks/use-mobile";
|
||||
import { formatFileSize } from "@/lib/download";
|
||||
import { format } from "@/lib/format";
|
||||
import { ICON_MAP } from "@/lib/icon-map";
|
||||
import { getToolName } from "@/lib/tool-i18n";
|
||||
import { getToolRegistryEntry } from "@/lib/tool-registry";
|
||||
@@ -117,7 +118,7 @@ function FileSelectionInfo({
|
||||
>
|
||||
{isSelected && <CheckCircle2 className="h-3 w-3 text-primary shrink-0" />}
|
||||
<span className="truncate flex-1 min-w-0">{file.name}</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground/70">
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{getFileFormat(file.name)}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] tabular-nums">{formatFileSize(file.size)}</span>
|
||||
@@ -203,6 +204,13 @@ export function ToolPage() {
|
||||
const hasPrev = selectedIndex > 0;
|
||||
const hasNext = selectedIndex < entries.length - 1;
|
||||
|
||||
const liveMessage = useMemo(() => {
|
||||
if (!currentEntry) return "";
|
||||
if (currentEntry.status === "completed" && processedUrl) return t.a11y.processingComplete;
|
||||
if (currentEntry.status === "failed") return t.a11y.processingFailed;
|
||||
return "";
|
||||
}, [currentEntry, processedUrl, t.a11y.processingComplete, t.a11y.processingFailed]);
|
||||
|
||||
const handleImageKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowLeft") {
|
||||
@@ -635,7 +643,7 @@ export function ToolPage() {
|
||||
<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">{t.toolPage.generatingPreview}</p>
|
||||
<p className="text-xs text-muted-foreground/60">{selectedFileName}</p>
|
||||
<p className="text-xs text-muted-foreground">{selectedFileName}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -706,7 +714,7 @@ export function ToolPage() {
|
||||
type="button"
|
||||
onClick={navigatePrev}
|
||||
className="absolute left-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
|
||||
aria-label="Previous image"
|
||||
aria-label={t.a11y.previousImage}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -716,13 +724,20 @@ export function ToolPage() {
|
||||
type="button"
|
||||
onClick={navigateNext}
|
||||
className="absolute right-3 z-10 w-8 h-8 rounded-full bg-background/80 border border-border shadow-sm flex items-center justify-center hover:bg-background transition-colors"
|
||||
aria-label="Next image"
|
||||
aria-label={t.a11y.nextImage}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{hasMultiple && (
|
||||
<div className="absolute top-3 right-3 z-10 bg-background/80 border border-border px-2 py-0.5 rounded-full text-xs text-muted-foreground tabular-nums">
|
||||
<div
|
||||
role="status"
|
||||
aria-label={format(t.a11y.imageNOfTotal, {
|
||||
n: selectedIndex + 1,
|
||||
total: entries.length,
|
||||
})}
|
||||
className="absolute top-3 right-3 z-10 bg-background/80 border border-border px-2 py-0.5 rounded-full text-xs text-muted-foreground tabular-nums"
|
||||
>
|
||||
{selectedIndex + 1} / {entries.length}
|
||||
</div>
|
||||
)}
|
||||
@@ -796,9 +811,9 @@ export function ToolPage() {
|
||||
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
|
||||
<IconComponent className="h-5 w-5" />
|
||||
</div>
|
||||
<h2 className="font-semibold text-lg text-foreground flex-1">
|
||||
<h1 className="font-semibold text-lg text-foreground flex-1">
|
||||
{getToolName(t, tool.id, tool.name)}
|
||||
</h2>
|
||||
</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileSettingsOpen(!mobileSettingsOpen)}
|
||||
@@ -810,11 +825,14 @@ export function ToolPage() {
|
||||
|
||||
{/* Main area: image viewer (full height) */}
|
||||
<section
|
||||
aria-label="Image area"
|
||||
aria-label={t.a11y.imageArea}
|
||||
className="flex-1 flex flex-col min-h-0 min-w-0"
|
||||
onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
|
||||
tabIndex={hasMultiple ? 0 : undefined}
|
||||
>
|
||||
<div aria-live="polite" aria-atomic="true" className="sr-only">
|
||||
{liveMessage}
|
||||
</div>
|
||||
<div className="flex-1 relative flex items-center justify-center p-4 min-h-0 min-w-0">
|
||||
{renderNavArrows()}
|
||||
{renderImageArea()}
|
||||
@@ -851,9 +869,9 @@ export function ToolPage() {
|
||||
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
|
||||
<IconComponent className="h-5 w-5" />
|
||||
</div>
|
||||
<h2 className="font-semibold text-lg text-foreground">
|
||||
<h1 className="font-semibold text-lg text-foreground">
|
||||
{getToolName(t, tool.id, tool.name)}
|
||||
</h2>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{renderSettingsContent()}
|
||||
@@ -861,11 +879,14 @@ export function ToolPage() {
|
||||
|
||||
{/* Main area: image viewer */}
|
||||
<section
|
||||
aria-label="Image area"
|
||||
aria-label={t.a11y.imageArea}
|
||||
className="flex-1 flex flex-col min-h-0 min-w-0"
|
||||
onKeyDown={hasMultiple ? handleImageKeyDown : undefined}
|
||||
tabIndex={hasMultiple ? 0 : undefined}
|
||||
>
|
||||
<div aria-live="polite" aria-atomic="true" className="sr-only">
|
||||
{liveMessage}
|
||||
</div>
|
||||
<div className="flex-1 relative flex items-center justify-center p-6 min-h-0 min-w-0">
|
||||
{renderNavArrows()}
|
||||
{renderImageArea()}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
}
|
||||
|
||||
@theme {
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary: #2563eb;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-background: #ffffff;
|
||||
--color-foreground: #0f172a;
|
||||
@@ -126,3 +126,18 @@ input[type="range"]::-moz-range-track {
|
||||
input[type="checkbox"] { width: 20px; height: 20px; }
|
||||
button, [role="button"], a { touch-action: manipulation; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
.animate-spin {
|
||||
animation-duration: 1.5s !important;
|
||||
animation-iteration-count: infinite !important;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user