diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..cf1a5608 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,162 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What Is SnapOtter + +Open-source, self-hostable image manipulation suite (51 tools). Single Docker container, no external services. Dual-licensed AGPLv3 / commercial. + +## Monorepo Layout + +``` +apps/web/ # Main React SPA (port 1349) +apps/api/ # Fastify API server (port 13490) +apps/landing/ # Marketing site (Vite + React, deployed to Cloudflare Pages) +apps/docs/ # VitePress documentation site (deployed to Cloudflare Pages) +packages/shared/ # Constants, types, i18n, permissions -- consumed by all apps +packages/image-engine/ # Sharp-based image processing pipeline +packages/ai/ # Python sidecar bridge for ML models +``` + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| Frontend | React 19, Vite 6, Tailwind CSS 4, Zustand, react-router-dom v7 | +| Backend | Fastify 5, tsx (no compile step in dev), Sharp | +| Database | SQLite via Drizzle ORM (better-sqlite3) | +| AI/ML | Python sidecar (rembg, RealESRGAN, PaddleOCR, MediaPipe, LaMa) | +| Docs | VitePress | +| Testing | Vitest (unit/integration), Playwright (e2e) | +| CI/CD | GitHub Actions, semantic-release, Docker multi-arch | +| Linting | Biome (format + lint in one pass) | + +## Commands + +```bash +pnpm dev # Start all dev servers (web on :1349, api on :13490) +pnpm build # Build all workspaces (turbo, packages first) +pnpm typecheck # TypeScript check across monorepo +pnpm lint # Biome lint + format check +pnpm lint:fix # Biome auto-fix +pnpm test # All Vitest tests (unit + integration) +pnpm test:unit # Unit tests only +pnpm test:integration # Integration tests only (full API) +pnpm test:e2e # Playwright e2e tests (main app) +pnpm test:e2e:landing # Playwright e2e tests (landing site) +pnpm test:e2e:docs # Playwright e2e tests (docs site) +pnpm test:coverage # Tests with V8 coverage report + +# Run a single test file +pnpm vitest run tests/unit/my-test.test.ts +pnpm vitest run tests/integration/my-test.test.ts + +# Run a single e2e spec +pnpm playwright test tests/e2e/my-test.spec.ts + +# Database migrations (run from apps/api/) +cd apps/api && npx drizzle-kit generate # Generate from schema changes +cd apps/api && npx drizzle-kit migrate # Apply pending migrations +``` + +## Key Conventions + +- **Simplicity over complexity** -- do not over-engineer +- **Double quotes**, **semicolons**, **2-space indent** (enforced by Biome) +- **ES modules** in all workspaces (`"type": "module"`) +- Conventional commits for semantic-release (`feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `chore:`) +- Zod for all API input validation +- i18n strings in `packages/shared/src/i18n/en.ts` (reference locale, ~1500 keys) +- All user-facing strings use `useTranslation()` hook from `apps/web/src/contexts/i18n-context.tsx` +- Use `t.section.key` for string references, `format(t.key, { var })` for interpolation, `plural(n, one, other)` for pluralization +- Tailwind uses logical properties for RTL support (`ms-` not `ml-`, `text-start` not `text-left`) +- 21 supported languages. Adding a new locale: create `packages/shared/src/i18n/.ts` typed as `TranslationKeys`, add to `SUPPORTED_LOCALES` in `index.ts` + +## Architecture: How a Tool Works + +A "tool" is defined in three places that share the same `toolId` string: + +1. **Shared metadata** (`packages/shared/src/constants.ts`) -- `TOOLS[]` array with id, name, description, category, icon, route. Single source of truth for tool metadata consumed by both frontend and backend. + +2. **API route** (`apps/api/src/routes/tools/.ts`) -- Processing logic. Most tools use the `createToolRoute` factory (`apps/api/src/routes/tool-factory.ts`), which handles multipart parsing, file validation, format decoding (HEIC/RAW/PSD), SVG sanitization, EXIF auto-orientation, Zod settings validation, output saving, preview generation, file versioning, and analytics. A simple tool is ~30 lines: a Zod schema + a process function that calls `@snapotter/image-engine`. Routes are registered in `apps/api/src/routes/tools/index.ts`. + +3. **Frontend settings** (`apps/web/src/components/tools/-settings.tsx`) -- UI component for the tool's settings panel. Registered in `apps/web/src/lib/tool-registry.tsx` with a `displayMode` (side-by-side, before-after, live-preview, interactive-crop, interactive-eraser, no-dropzone, custom-results). + +### Adding a New Tool + +1. Add tool definition to `TOOLS[]` in `packages/shared/src/constants.ts` +2. Create `apps/api/src/routes/tools/.ts` -- export a `register(app)` function that calls `createToolRoute(app, { toolId, settingsSchema, process })` +3. Add to the registration array in `apps/api/src/routes/tools/index.ts` +4. Create `apps/web/src/components/tools/-settings.tsx` +5. Add lazy import + registry entry in `apps/web/src/lib/tool-registry.tsx` +6. Add i18n strings in `packages/shared/src/i18n/en.ts` + +### Request Lifecycle + +**Standard tools**: Frontend sends `POST /api/v1/tools/:toolId` (multipart with file + JSON settings) via XHR with upload progress. The `createToolRoute` factory processes and returns `{jobId, downloadUrl, originalSize, processedSize}`. + +**AI tools**: Do NOT use `createToolRoute` for their HTTP route. They return `202 Accepted` immediately, process asynchronously via the Python sidecar, and stream progress via SSE (`EventSource` on `/api/v1/jobs/:jobId/progress`). They still register a sync wrapper via `registerToolProcessFn()` for pipeline/batch reuse. + +### Pipeline System + +Pipelines chain tools: each step's output buffer feeds the next step's input. The tool registry (`toolRegistry` Map) enables pipelines and batch processing to call any registered tool's process function without duplicating logic. CRUD stored in `pipelines` DB table as JSON. Batch processing uses `p-queue` concurrency and returns a ZIP via `archiver`. + +## AI Sidecar (packages/ai/) + +Two-tier Python execution: + +1. **Persistent dispatcher** (primary): Long-lived Python process (`packages/ai/python/dispatcher.py`) communicating via JSON over stdio. Node sends `{id, script, args}` on stdin, Python responds `{id, stdout, exitCode}` on stdout. Progress events stream on stderr as `{progress, stage}`. Pre-imports heavy ML libraries at startup to eliminate cold-start latency. Auto-restarts after 50 requests to prevent memory leaks. + +2. **Per-request fallback**: Fresh Python process per request if dispatcher is unavailable. Exponential backoff with crash recovery (5 crashes in 60s = permanent disable). + +AI tools require model bundles defined in `packages/shared/src/features.ts` (`FEATURE_BUNDLES`). The API checks `isToolInstalled()` before processing; the frontend shows an install prompt if not installed. + +## Image Engine (packages/image-engine/) + +`processImage(input, operations[], outputFormat?)` pipes a buffer through named operations. Each operation is a pure function `(Sharp, options) => Promise`. The `OPERATION_MAP` in `engine.ts` dispatches by string key. Individual operations are also exported for direct use by API routes. + +## Auth and Permissions + +Session-based auth with scrypt password hashing. Three built-in roles (`admin`, `editor`, `user`) with 16 granular permissions defined in `packages/shared/src/permissions.ts`. Custom roles stored in `roles` table. API keys (prefixed `si_`) carry optional scoped permissions that intersect with user role permissions. Auth can be disabled entirely (synthetic anonymous user with `user` role). + +## Frontend State + +14 Zustand stores in `apps/web/src/stores/`. The central one is `file-store.ts` managing `FileEntry[]` with blob URLs, processing status, and batch ZIP. Key hooks: `use-tool-processor.ts` (single-file XHR + batch + SSE progress) and `use-pipeline-processor.ts`. + +All page components are `lazy()`-loaded. The key route is `/:toolId` rendering `ToolPage` (`apps/web/src/pages/tool-page.tsx`), which looks up metadata from shared constants and the frontend tool registry. + +## Database + +SQLite via Drizzle ORM. Schema: `apps/api/src/db/schema.ts`. Migrations in `apps/api/drizzle/`. + +Tables: users, teams, sessions, settings (key-value), jobs, apiKeys, pipelines, auditLog, roles, userFiles. + +## Testing + +- `tests/unit/` -- Vitest unit tests +- `tests/integration/` -- Vitest integration tests (one per tool/feature, full API) +- `tests/e2e/` -- Playwright specs (Chromium primary, Firefox/WebKit for cross-browser) +- `tests/fixtures/` -- Small test images in various formats + +Vitest uses single-fork pool (shared SQLite connection), 30s timeouts, and injects test env vars. Playwright auth setup project logs in and saves storage state; spins up both dev servers with a fresh test DB. + +## Pre-commit Hooks + +Husky + lint-staged runs `biome check --write` on staged `*.{ts,tsx,js,jsx,json}` files before every commit. If a commit fails, fix the lint issues rather than bypassing the hook. + +## Do Not Modify Config Files + +Biome, TypeScript, and editor config files are protected by hooks. Fix the code to satisfy the linter/compiler, not the other way around. This prevents a common AI failure mode where rules get weakened instead of code getting fixed. + +## Model Routing for Subagents + +Always use **Opus 4.7, Max Effort, 1M context** for every agent and subagent. No exceptions. No model downgrading. + +## Strategic Compaction + +When context gets large, compact at logical phase boundaries: + +- **Good times to compact**: After research and before planning. After debugging and before implementing the fix. After completing a major feature. +- **Bad times to compact**: Mid-implementation. While actively debugging. During a multi-step refactor. +- **Survives compaction**: This CLAUDE.md, active tasks, git state, memory files +- **Lost on compaction**: Intermediate reasoning, file contents previously read, conversation flow diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 31f8be80..c0a88b6b 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -23,6 +23,7 @@ import { analyticsRoutes } from "./routes/analytics.js"; import { apiKeyRoutes } from "./routes/api-keys.js"; import { auditLogRoutes } from "./routes/audit-log.js"; import { registerBatchRoutes } from "./routes/batch.js"; +import { configRoutes } from "./routes/config.js"; import { docsRoutes } from "./routes/docs.js"; import { registerFeatureRoutes } from "./routes/features.js"; import { registerFetchUrlsRoute } from "./routes/fetch-urls.js"; @@ -172,6 +173,9 @@ await app.register(cookie, { hook: "onRequest", }); +// Public config routes (no auth required) +await configRoutes(app); + // Auth middleware (must be registered before routes it protects) await authMiddleware(app); diff --git a/apps/api/src/routes/config.ts b/apps/api/src/routes/config.ts new file mode 100644 index 00000000..f5a67af6 --- /dev/null +++ b/apps/api/src/routes/config.ts @@ -0,0 +1,14 @@ +import { eq } from "drizzle-orm"; +import type { FastifyInstance } from "fastify"; +import { db, schema } from "../db/index.js"; + +export async function configRoutes(app: FastifyInstance): Promise { + app.get("/api/v1/config/locale", async (_request, reply) => { + const row = db + .select() + .from(schema.settings) + .where(eq(schema.settings.key, "defaultLocale")) + .get(); + return reply.send({ defaultLocale: row?.value ?? "en" }); + }); +} diff --git a/apps/docs/guide/translations.md b/apps/docs/guide/translations.md index 3765ecde..65069f0c 100644 --- a/apps/docs/guide/translations.md +++ b/apps/docs/guide/translations.md @@ -1,78 +1,129 @@ # Translation guide -SnapOtter ships with English by default. The i18n system is designed so adding a new language is straightforward. +SnapOtter ships with 21 languages out of the box. The i18n system uses a lightweight custom runtime with TypeScript-enforced locale completeness and dynamic code-splitting. + +## Supported languages + +| Code | Language | Native Name | Direction | +|------|----------|-------------|-----------| +| `en` | English | English | LTR | +| `zh-CN` | Chinese (Simplified) | 简体中文 | LTR | +| `zh-TW` | Chinese (Traditional) | 繁體中文 | LTR | +| `ja` | Japanese | 日本語 | LTR | +| `ko` | Korean | 한국어 | LTR | +| `es` | Spanish | Español | LTR | +| `fr` | French | Français | LTR | +| `it` | Italian | Italiano | LTR | +| `pt-BR` | Portuguese (Brazil) | Português (Brasil) | LTR | +| `de` | German | Deutsch | LTR | +| `nl` | Dutch | Nederlands | LTR | +| `sv` | Swedish | Svenska | LTR | +| `ru` | Russian | Русский | LTR | +| `pl` | Polish | Polski | LTR | +| `uk` | Ukrainian | Українська | LTR | +| `ar` | Arabic | العربية | RTL | +| `tr` | Turkish | Türkçe | LTR | +| `hi` | Hindi | हिन्दी | LTR | +| `vi` | Vietnamese | Tiếng Việt | LTR | +| `id` | Indonesian | Bahasa Indonesia | LTR | +| `th` | Thai | ไทย | LTR | + +## How language detection works + +SnapOtter uses a three-tier resolution order: + +1. **User preference** -- stored in `localStorage("snapotter-locale")` and synced to user settings when authenticated +2. **Browser auto-detect** -- walks the `navigator.languages` array with BCP 47 prefix matching +3. **Instance default** -- the admin's `DEFAULT_LOCALE` env var (fetched from `GET /api/v1/config/locale`) +4. **English fallback** -- always available + +Users can change language from: +- The **footer Globe selector** (desktop, always visible) +- The **login page** language selector (pre-auth) +- The **Settings > General** section (per-user preference) +- The **mobile sidebar** language dropdown +- The **Settings > System** section sets the instance-wide default (admin only) ## How translations work -All UI strings live in `packages/shared/src/i18n/`. The reference file is `en.ts`, which exports a typed object with every string the app uses. Other languages are separate files (e.g., `de.ts`, `fr.ts`) that export the same shape. +All UI strings live in `packages/shared/src/i18n/`. The reference file is `en.ts`, which exports a typed object with every string the app uses (~1500 keys). Other languages are separate files (e.g., `de.ts`, `fr.ts`) that export the same shape. -The `TranslationKeys` type is derived from the English file, so TypeScript will catch any missing keys in any translation file. +The `TranslationKeys` type uses `DeepStringRecord` to accept any string value while enforcing the key structure. TypeScript catches missing keys in any translation file at compile time. + +Only the active locale is loaded at runtime via dynamic `import()`, keeping the main bundle small. + +## Using translations in components + +```tsx +import { useTranslation } from "@/contexts/i18n-context"; +import { format, plural } from "@/lib/format"; + +function MyComponent() { + const { t, locale, setLocale } = useTranslation(); + + return ( +
+

{t.common.settings}

+

{format(t.settings.people.deleteConfirm, { username: "admin" })}

+

{plural(count, t.automate.fileCount, t.automate.fileCountPlural)}

+
+ ); +} +``` ## Requesting a translation -To request a new language or report a mistranslation, open a [GitHub Issue](https://github.com/snapotter-hq/snapotter/issues) with: +To request a new language or report a mistranslation, open a [GitHub Issue](https://github.com/snapotter-hq/SnapOtter/issues) with: - The language name and locale code (e.g., German / `de`) - Any specific strings or sections you want translated - If you have a translation ready, paste the translated strings directly in the issue -We do not accept pull requests. Submitting translations via issues is the right path. - ## How to create a translation (for your own fork) -If you are running a fork and want to add a language yourself: - ### 1. Copy the reference file ```bash -cp packages/shared/src/i18n/en.ts packages/shared/src/i18n/de.ts +cp packages/shared/src/i18n/en.ts packages/shared/src/i18n/XX.ts ``` ### 2. Translate the strings -Open your new file and translate every string value. Keep the object structure and keys exactly the same - only change the values. +Open your new file and translate every string value. Keep the object structure and keys exactly the same. ```ts -// packages/shared/src/i18n/de.ts -export const de = { +import type { TranslationKeys } from "./en.js"; + +export const xx: TranslationKeys = { common: { - upload: "Vom Computer hochladen", - process: "Verarbeiten", - download: "Herunterladen", - cancel: "Abbrechen", + upload: "Your translation here", // ... translate all entries }, - tools: { - resize: { - name: "Grosse andern", - description: "Grosse nach Pixeln, Prozent oder Social-Media-Vorgaben andern", - }, - // ... translate all tool entries - }, - // ... translate all sections: settings, auth, pipeline, nav + // ... translate all sections } as const; ``` -Things to keep in mind: +Rules: +- Do not translate object keys, only string values +- Keep `as const` at the end +- Import `TranslationKeys` from `./en.js` and type your export +- Keep `{variable}` placeholders exactly as-is +- Arrays (`rotatingPhrases`, `progressMessages`) must have the same number of entries +- Do not translate: SnapOtter, JPEG, PNG, WebP, EXIF, API, and other technical terms -- Do not translate object keys, only values. -- Keep the `as const` assertion at the end. -- If a string is the same in your language (technical terms, proper nouns), leave the English value. +### 3. Register the locale -### 3. Export the new language - -Edit `packages/shared/src/i18n/index.ts` to include your language: +Add your locale to `SUPPORTED_LOCALES` in `packages/shared/src/i18n/index.ts`: ```ts -export type { TranslationKeys } from "./en.js"; -export { en } from "./en.js"; -export { de } from "./de.js"; +{ code: "xx", name: "Language Name", nativeName: "Native Name", dir: "ltr" }, ``` ### 4. Verify ```bash pnpm typecheck # catches missing or mistyped keys +pnpm lint # formatting check pnpm dev # manually verify strings appear correctly ``` @@ -80,13 +131,25 @@ pnpm dev # manually verify strings appear correctly When adding a new feature that needs new UI strings: -1. Add the new keys to `packages/shared/src/i18n/en.ts` first. This is the reference file. -2. Run `pnpm typecheck` to make sure all language files still satisfy the `TranslationKeys` type. +1. Add the new keys to `en.ts` first (the reference file) +2. Run `pnpm typecheck` -- every locale file will fail if missing the new key +3. Add the new key to all locale files (use English as a temporary fallback) + +## Configuration + +Set the instance default language via environment variable: + +```yaml +DEFAULT_LOCALE: "de" # German as the default for all new users +``` ## File reference | File | Purpose | |------|---------| -| `packages/shared/src/i18n/en.ts` | English strings (reference locale) | -| `packages/shared/src/i18n/index.ts` | Exports all locales and the `TranslationKeys` type | -| `packages/shared/src/constants.ts` | Tool registry (names/descriptions also live here) | +| `packages/shared/src/i18n/en.ts` | English strings (reference locale, ~1500 keys) | +| `packages/shared/src/i18n/index.ts` | `SUPPORTED_LOCALES`, `loadTranslations()`, type exports | +| `packages/shared/src/i18n/.ts` | Per-language translation files | +| `apps/web/src/contexts/i18n-context.tsx` | `I18nProvider`, `useTranslation()` hook | +| `apps/web/src/lib/format.ts` | `format()`, `plural()`, `formatFileSize()` helpers | +| `apps/api/src/routes/config.ts` | `GET /api/v1/config/locale` public endpoint | diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a4e92404..a9ca89a1 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,9 +1,10 @@ -import { APP_VERSION, shouldShowConsent } from "@snapotter/shared"; +import { APP_VERSION, en, shouldShowConsent } from "@snapotter/shared"; import { Component, type ErrorInfo, lazy, type ReactNode, Suspense, useEffect } from "react"; import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom"; import { Toaster } from "sonner"; import { ConnectionMonitor } from "./components/common/connection-monitor"; import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider"; +import { I18nProvider } from "./contexts/i18n-context"; import { useAuth } from "./hooks/use-auth"; import { identify, initAnalytics, setAnalyticsConsent } from "./lib/analytics"; import { useAnalyticsStore } from "./stores/analytics-store"; @@ -55,9 +56,9 @@ class ErrorBoundary extends Component< return (
-

Something went wrong

+

{en.common.somethingWentWrong}

- {this.state.error?.message || "An unexpected error occurred."} + {this.state.error?.message || en.common.unexpectedError}

@@ -132,7 +133,7 @@ function AuthGuard({ children }: { children: React.ReactNode }) {
-

Loading...

+

{en.common.loading}

); @@ -205,36 +206,41 @@ export function App() { return ( - - - - - - }> - - } /> - } /> - } /> - } /> - } /> - } /> - {/* Redirects: old color tools consolidated into adjust-colors */} - } - /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - - + + + + + + + }> + + } /> + } /> + } /> + } /> + } /> + } /> + {/* Redirects: old color tools consolidated into adjust-colors */} + } + /> + } /> + } + /> + } /> + } /> + } /> + } /> + } /> + + + + + + ); } diff --git a/apps/web/src/components/common/before-after-slider.tsx b/apps/web/src/components/common/before-after-slider.tsx index d2ae6eb1..239c48a4 100644 --- a/apps/web/src/components/common/before-after-slider.tsx +++ b/apps/web/src/components/common/before-after-slider.tsx @@ -1,4 +1,6 @@ import { type PointerEvent, useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; +import { format } from "@/lib/format"; interface BeforeAfterSliderProps { /** URL or data URL of original image. */ @@ -33,6 +35,7 @@ export function BeforeAfterSlider({ afterSize, initialPosition = 50, }: BeforeAfterSliderProps) { + const { t } = useTranslation(); const containerRef = useRef(null); const [position, setPosition] = useState(initialPosition); // percentage 0-100 const [isDragging, setIsDragging] = useState(false); @@ -160,10 +163,10 @@ export function BeforeAfterSlider({ {/* Labels */}
- Original + {t.comparison.original}
- Processed + {t.comparison.processed}
@@ -176,10 +179,10 @@ export function BeforeAfterSlider({ Processed: {formatSize(afterSize)} {savingsPercent !== null && Number(savingsPercent) > 0 && ( - ({savingsPercent}% smaller) + ({savingsPercent}% smaller) )} {savingsPercent !== null && Number(savingsPercent) < 0 && ( - ({Math.abs(Number(savingsPercent))}% larger) + ({Math.abs(Number(savingsPercent))}% larger) )} diff --git a/apps/web/src/components/common/collapsible-section.tsx b/apps/web/src/components/common/collapsible-section.tsx index 20e26d23..d538dd55 100644 --- a/apps/web/src/components/common/collapsible-section.tsx +++ b/apps/web/src/components/common/collapsible-section.tsx @@ -28,7 +28,7 @@ export function CollapsibleSection({ ) : ( )} - {title} + {title} {warning && } {badge && ( diff --git a/apps/web/src/components/common/connection-banner.tsx b/apps/web/src/components/common/connection-banner.tsx index f48aaa82..be16608a 100644 --- a/apps/web/src/components/common/connection-banner.tsx +++ b/apps/web/src/components/common/connection-banner.tsx @@ -1,7 +1,9 @@ import { CheckCircle2, Loader2, WifiOff } from "lucide-react"; +import { useTranslation } from "@/contexts/i18n-context"; import { useConnectionStore } from "@/stores/connection-store"; export function ConnectionBanner() { + const { t } = useTranslation(); const status = useConnectionStore((s) => s.status); if (status === "connected") return null; @@ -11,19 +13,19 @@ export function ConnectionBanner() { bg: "bg-amber-500 dark:bg-amber-600", text: "text-amber-950 dark:text-amber-50", icon: , - message: "Reconnecting to server\u2026", + message: t.errors.reconnecting, }, offline: { bg: "bg-amber-500 dark:bg-amber-600", text: "text-amber-950 dark:text-amber-50", icon: , - message: "You\u2019re offline", + message: t.errors.offline, }, reconnected: { bg: "bg-emerald-500 dark:bg-emerald-600", text: "text-emerald-950 dark:text-emerald-50", icon: , - message: "Connected", + message: t.errors.connected, }, }[status]; diff --git a/apps/web/src/components/common/dropzone.tsx b/apps/web/src/components/common/dropzone.tsx index b45fc5a0..9eb97836 100644 --- a/apps/web/src/components/common/dropzone.tsx +++ b/apps/web/src/components/common/dropzone.tsx @@ -1,5 +1,6 @@ import { FileImage, ImageUp, Upload } from "lucide-react"; import { type DragEvent, useCallback, useEffect, useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; import { useUrlImport } from "@/hooks/use-url-import"; import { cn } from "@/lib/utils"; import { UrlImportModal } from "./url-import-modal"; @@ -111,6 +112,7 @@ export function Dropzone({ fileFilter, acceptDescription, }: DropzoneProps) { + const { t } = useTranslation(); const checkFile = fileFilter ?? isImageFile; const resolvedAccept = expandAccept(accept); const [isDragging, setIsDragging] = useState(false); @@ -129,13 +131,13 @@ export function Dropzone({ const file = await importSingleUrl(url); if (file) { if (!checkFile(file)) { - setUrlError(acceptDescription ?? "This file type is not supported by this tool"); + setUrlError(acceptDescription ?? t.dropzone.unsupportedFileType); } else { setUrlInput(""); onUrlImport?.(file); } } else { - setUrlError("Could not fetch image from URL"); + setUrlError(t.dropzone.urlFetchFailed); } setUrlLoading(false); }, [urlInput, importSingleUrl, onUrlImport, checkFile, acceptDescription]); @@ -235,11 +237,9 @@ export function Dropzone({

- Drop your images here -

-

- click anywhere to browse, or paste from clipboard + {t.dropzone.dropPrompt}

+

{t.dropzone.browseOrPaste}

- {acceptDescription ?? "PNG, JPG, WebP, HEIC, RAW, PSD, and 65+ formats"} + {acceptDescription ?? t.dropzone.defaultFormats}

{!compact && onUrlImport && ( @@ -282,7 +282,7 @@ export function Dropzone({ } }} onClick={(e) => e.stopPropagation()} - placeholder="Paste image URL..." + placeholder={t.dropzone.urlPlaceholder} className="flex-1 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none" disabled={urlLoading} /> @@ -307,7 +307,7 @@ export function Dropzone({ }} className="text-xs text-primary hover:text-primary/80" > - Import multiple URLs... + {t.dropzone.importMultipleUrls} )} @@ -325,7 +325,7 @@ export function Dropzone({ className="flex items-center justify-between text-xs text-muted-foreground px-2 py-0.5" > {f.name} - {(f.size / 1024).toFixed(0)} KB + {(f.size / 1024).toFixed(0)} KB ))} diff --git a/apps/web/src/components/common/file-library-modal.tsx b/apps/web/src/components/common/file-library-modal.tsx index 1002856f..e68fa680 100644 --- a/apps/web/src/components/common/file-library-modal.tsx +++ b/apps/web/src/components/common/file-library-modal.tsx @@ -124,7 +124,7 @@ export function FileLibraryModal({ open, onClose, onImport }: FileLibraryModalPr placeholder="Search files..." value={searchQuery} onChange={handleSearchChange} - className="w-full pl-8 pr-3 py-1.5 text-sm bg-muted rounded-lg border border-border focus:outline-none focus:ring-2 focus:ring-primary/50 text-foreground placeholder:text-muted-foreground" + className="w-full ps-8 pe-3 py-1.5 text-sm bg-muted rounded-lg border border-border focus:outline-none focus:ring-2 focus:ring-primary/50 text-foreground placeholder:text-muted-foreground" /> diff --git a/apps/web/src/components/common/image-viewer.tsx b/apps/web/src/components/common/image-viewer.tsx index 609694ba..98d4e871 100644 --- a/apps/web/src/components/common/image-viewer.tsx +++ b/apps/web/src/components/common/image-viewer.tsx @@ -307,7 +307,7 @@ export function ImageViewer({ {/* Info bar */}
- {filename} + {filename}
{(originalWidth || naturalWidth) != null && (originalHeight || naturalHeight) != null && ( diff --git a/apps/web/src/components/common/review-panel.tsx b/apps/web/src/components/common/review-panel.tsx index b0bde0f7..d8238b28 100644 --- a/apps/web/src/components/common/review-panel.tsx +++ b/apps/web/src/components/common/review-panel.tsx @@ -10,6 +10,7 @@ import { } from "lucide-react"; import { useMemo, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; +import { useTranslation } from "@/contexts/i18n-context"; import { formatFileSize, triggerDownload } from "@/lib/download"; import { ICON_MAP } from "@/lib/icon-map"; import { getSuggestedTools } from "@/lib/suggested-tools"; @@ -33,6 +34,7 @@ export function ReviewPanel({ onUndo, currentToolId, }: ReviewPanelProps) { + const { t } = useTranslation(); const [isExpanded, setIsExpanded] = useState(true); const [isSuggestionsExpanded, setIsSuggestionsExpanded] = useState(true); const navigate = useNavigate(); @@ -66,7 +68,7 @@ export function ReviewPanel({ onClick={() => setIsExpanded(!isExpanded)} className="flex items-center justify-between w-full text-sm font-medium text-muted-foreground hover:text-foreground" > - Review + {t.reviewPanel.reviewHeading} {isExpanded ? : } @@ -99,7 +101,7 @@ export function ReviewPanel({ className="flex-1 py-2 rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted flex items-center justify-center gap-1.5 text-xs font-medium" > - Undo + {t.reviewPanel.undoButton}
@@ -117,7 +119,7 @@ export function ReviewPanel({ className="flex items-center justify-center gap-1.5 w-full py-2 rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted text-xs font-medium" > - Open in Editor + {t.reviewPanel.openInEditor} {/* Suggested tools */} @@ -129,7 +131,7 @@ export function ReviewPanel({ onClick={() => setIsSuggestionsExpanded(!isSuggestionsExpanded)} className="flex items-center justify-between w-full text-xs font-medium text-muted-foreground hover:text-foreground" > - Continue editing + {t.reviewPanel.continueEditing} {isSuggestionsExpanded ? ( ) : ( @@ -151,7 +153,7 @@ export function ReviewPanel({ className="flex items-center gap-2 w-full px-2 py-1.5 rounded text-xs text-muted-foreground hover:text-foreground hover:bg-muted group" > - {tool.name} + {tool.name} ); diff --git a/apps/web/src/components/common/search-bar.tsx b/apps/web/src/components/common/search-bar.tsx index e3464b9a..9c428cf0 100644 --- a/apps/web/src/components/common/search-bar.tsx +++ b/apps/web/src/components/common/search-bar.tsx @@ -1,4 +1,5 @@ import { Search } from "lucide-react"; +import { useTranslation } from "@/contexts/i18n-context"; interface SearchBarProps { value: string; @@ -6,7 +7,9 @@ interface SearchBarProps { placeholder?: string; } -export function SearchBar({ value, onChange, placeholder = "Search tools..." }: SearchBarProps) { +export function SearchBar({ value, onChange, placeholder }: SearchBarProps) { + const { t } = useTranslation(); + const resolvedPlaceholder = placeholder ?? t.common.search; return (
@@ -15,8 +18,8 @@ export function SearchBar({ value, onChange, placeholder = "Search tools..." }: tabIndex={0} value={value} onChange={(e) => onChange(e.target.value)} - placeholder={placeholder} - className="w-full pl-10 pr-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" + placeholder={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" />
); diff --git a/apps/web/src/components/common/side-by-side-comparison.tsx b/apps/web/src/components/common/side-by-side-comparison.tsx index d7380371..4215d95c 100644 --- a/apps/web/src/components/common/side-by-side-comparison.tsx +++ b/apps/web/src/components/common/side-by-side-comparison.tsx @@ -1,4 +1,6 @@ import { useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; +import { format } from "@/lib/format"; interface SideBySideComparisonProps { beforeSrc: string; @@ -19,6 +21,7 @@ export function SideBySideComparison({ beforeSize, afterSize, }: SideBySideComparisonProps) { + const { t } = useTranslation(); const [beforeDims, setBeforeDims] = useState<{ w: number; h: number } | null>(null); const [afterDims, setAfterDims] = useState<{ w: number; h: number } | null>(null); @@ -34,7 +37,7 @@ export function SideBySideComparison({ {/* Original */}
- Original + {t.comparison.original}
- Processed + {t.comparison.processed}
0 ? "text-green-600 dark:text-green-400" : "text-red-500"}`} > {Number(savingsPercent) > 0 - ? `${savingsPercent}% smaller` - : `${Math.abs(Number(savingsPercent))}% larger`} + ? format(t.toolSettings["optimize-for-web"].smaller, { percent: savingsPercent }) + : format(t.toolSettings["optimize-for-web"].larger, { + percent: Math.abs(Number(savingsPercent)), + })}

)}
diff --git a/apps/web/src/components/common/tool-card.tsx b/apps/web/src/components/common/tool-card.tsx index 6cfd2341..e328501b 100644 --- a/apps/web/src/components/common/tool-card.tsx +++ b/apps/web/src/components/common/tool-card.tsx @@ -3,7 +3,9 @@ import { PYTHON_SIDECAR_TOOLS, TOOL_BUNDLE_MAP } from "@snapotter/shared"; import { Clock, Download, FileImage, Loader2, Star } from "lucide-react"; import { useMemo } from "react"; import { Link } from "react-router-dom"; +import { useTranslation } from "@/contexts/i18n-context"; import { ICON_MAP } from "@/lib/icon-map"; +import { getToolName } from "@/lib/tool-i18n"; import { cn } from "@/lib/utils"; import { useFeaturesStore } from "@/stores/features-store"; @@ -12,6 +14,7 @@ interface ToolCardProps { } export function ToolCard({ tool }: ToolCardProps) { + const { t } = useTranslation(); const IconComponent = (ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage; @@ -34,7 +37,7 @@ export function ToolCard({ tool }: ToolCardProps) { @@ -47,10 +50,12 @@ export function ToolCard({ tool }: ToolCardProps) { )} > - {tool.name} + + {getToolName(t, tool.id, tool.name)} + {tool.experimental && ( - Experimental + {t.common.experimental} )} {aiStatus === "not_installed" && } diff --git a/apps/web/src/components/common/url-import-modal.tsx b/apps/web/src/components/common/url-import-modal.tsx index a6234d9e..6a0dcf22 100644 --- a/apps/web/src/components/common/url-import-modal.tsx +++ b/apps/web/src/components/common/url-import-modal.tsx @@ -1,6 +1,8 @@ import { AlertCircle, Check, Clock, Link, Loader2, RotateCw, X } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; import { type UrlImportEntry, useUrlImport } from "@/hooks/use-url-import"; +import { format } from "@/lib/format"; import { extractUrls } from "@/lib/url-parser"; // ── Types ────────────────────────────────────────────────────── @@ -42,6 +44,7 @@ function filenameFromUrl(url: string): string { // ── Component ────────────────────────────────────────────────── export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) { + const { t } = useTranslation(); const [text, setText] = useState(""); const [adding, setAdding] = useState(false); @@ -108,7 +111,7 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) { {/* Header */}
-

Import from URLs

+

{t.urlImport.title}

@@ -204,7 +205,7 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) { onClick={handleClose} className="px-4 py-2 text-sm rounded-lg border border-border text-foreground hover:bg-muted" > - Cancel + {t.common.cancel} diff --git a/apps/web/src/components/editor/common/context-menu.tsx b/apps/web/src/components/editor/common/context-menu.tsx index 43dea68f..a57fcc3f 100644 --- a/apps/web/src/components/editor/common/context-menu.tsx +++ b/apps/web/src/components/editor/common/context-menu.tsx @@ -259,7 +259,7 @@ export function ContextMenu({ onClick={item.action} disabled={item.disabled} className={cn( - "flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm", + "flex w-full items-center gap-2.5 px-3 py-1.5 text-start text-sm", "text-foreground hover:bg-muted transition-colors", "disabled:cursor-not-allowed disabled:opacity-40", )} diff --git a/apps/web/src/components/editor/common/export-dialog.tsx b/apps/web/src/components/editor/common/export-dialog.tsx index f7974425..212b16ea 100644 --- a/apps/web/src/components/editor/common/export-dialog.tsx +++ b/apps/web/src/components/editor/common/export-dialog.tsx @@ -820,7 +820,7 @@ export function AutosaveRecoveryBanner({
Recovered unsaved work from {timeStr}. -
+
); diff --git a/apps/web/src/components/editor/common/welcome-screen.tsx b/apps/web/src/components/editor/common/welcome-screen.tsx index 1046630f..9caf77b5 100644 --- a/apps/web/src/components/editor/common/welcome-screen.tsx +++ b/apps/web/src/components/editor/common/welcome-screen.tsx @@ -2,12 +2,14 @@ import { FilePlus, ImagePlus } from "lucide-react"; import { useCallback, useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; import { useEditorStore } from "@/stores/editor-store"; import { NewDocumentDialog } from "./new-document-dialog"; const ACCEPTED_TYPES = ".png,.jpg,.jpeg,.webp,.gif,.bmp,.tiff,.svg,.avif,.svgz"; export function WelcomeScreen() { + const { t } = useTranslation(); const [showNewDoc, setShowNewDoc] = useState(false); const [isDragOver, setIsDragOver] = useState(false); const loadImage = useEditorStore((s) => s.loadImage); @@ -70,8 +72,10 @@ export function WelcomeScreen() { }`} >
-

Image Editor

-

Drop an image here to get started

+

+ {t.editor.welcome.heading} +

+

{t.editor.welcome.dropDescription}

@@ -81,7 +85,7 @@ export function WelcomeScreen() { className="flex items-center gap-3 w-full px-4 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors" > - Open Image + {t.editor.welcome.openImageButton}
-

Or paste from clipboard (Ctrl+V)

+

{t.editor.welcome.pasteHint}

diff --git a/apps/web/src/components/editor/editor-menu-bar.tsx b/apps/web/src/components/editor/editor-menu-bar.tsx index dd27f405..ea0f28e4 100644 --- a/apps/web/src/components/editor/editor-menu-bar.tsx +++ b/apps/web/src/components/editor/editor-menu-bar.tsx @@ -386,11 +386,11 @@ function MenuItemRow({ item, onClose }: { item: MenuItem; onClose: () => void }) data-testid={`menu-item-${toTestId(item.label)}`} > {item.label} - +
{submenuOpen && (
void }) {item.dividerAfter &&
} diff --git a/apps/web/src/components/editor/editor-status-bar.tsx b/apps/web/src/components/editor/editor-status-bar.tsx index 0ed8e05b..474be90b 100644 --- a/apps/web/src/components/editor/editor-status-bar.tsx +++ b/apps/web/src/components/editor/editor-status-bar.tsx @@ -31,7 +31,7 @@ export function EditorStatusBar() { const val = Number.parseFloat(e.target.value); if (!Number.isNaN(val) && val > 0) setZoom(val / 100); }} - className="w-14 bg-transparent text-right text-xs border-none outline-none" + className="w-14 bg-transparent text-end text-xs border-none outline-none" min={0.01} max={6400} step={0.1} diff --git a/apps/web/src/components/editor/options/eyedropper-options.tsx b/apps/web/src/components/editor/options/eyedropper-options.tsx index 6c3a275d..513a5470 100644 --- a/apps/web/src/components/editor/options/eyedropper-options.tsx +++ b/apps/web/src/components/editor/options/eyedropper-options.tsx @@ -33,7 +33,7 @@ export function EyedropperOptions({
{/* Sample size dropdown */}
- Sample: + Sample: - {pastLength} / 50 + {pastLength} / 50
{/* History list */} @@ -206,7 +206,7 @@ export function HistoryPanel() { type="button" onClick={() => jumpToState(entry)} className={cn( - "flex items-center gap-2 w-full px-2 py-1.5 text-left text-xs transition-colors", + "flex items-center gap-2 w-full px-2 py-1.5 text-start text-xs transition-colors", isCurrent && "bg-primary/10 text-foreground font-medium", isFuture && "text-muted-foreground/40", !isCurrent && diff --git a/apps/web/src/components/editor/panels/layers-panel.tsx b/apps/web/src/components/editor/panels/layers-panel.tsx index cff39646..00db0ef2 100644 --- a/apps/web/src/components/editor/panels/layers-panel.tsx +++ b/apps/web/src/components/editor/panels/layers-panel.tsx @@ -467,8 +467,8 @@ function LayerRow({ className={cn( "flex items-center gap-1.5 px-1.5 py-1 rounded cursor-pointer select-none group", "hover:bg-muted/50 transition-colors", - isActive && "bg-primary/10 border-l-2 border-primary", - !isActive && "border-l-2 border-transparent", + isActive && "bg-primary/10 border-s-2 border-primary", + !isActive && "border-s-2 border-transparent", )} role="option" aria-selected={isActive} @@ -545,7 +545,7 @@ function LayerRow({ - + {zoomPercent}%
diff --git a/apps/web/src/components/features/feature-install-prompt.tsx b/apps/web/src/components/features/feature-install-prompt.tsx index 308a862f..c1a8c454 100644 --- a/apps/web/src/components/features/feature-install-prompt.tsx +++ b/apps/web/src/components/features/feature-install-prompt.tsx @@ -1,6 +1,8 @@ import type { FeatureBundleState } from "@snapotter/shared"; import { AlertCircle, Clock, Download, Loader2, RotateCcw } from "lucide-react"; import { useEffect, useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; +import { format } from "@/lib/format"; import { useFeaturesStore } from "@/stores/features-store"; const PROGRESS_MESSAGES = [ @@ -56,6 +58,7 @@ export function FeatureInstallPrompt({ toolName, toolDescription, }: FeatureInstallPromptProps) { + const { t } = useTranslation(); const { installBundle, clearError, installing, errors, startTimes, queued } = useFeaturesStore(); const progress = installing[bundle.id] ?? null; const error = errors[bundle.id] ?? null; @@ -97,10 +100,8 @@ export function FeatureInstallPrompt({ return (
-

Feature Not Enabled

-

- This feature is not enabled. Ask your administrator to enable it in Settings. -

+

{t.features.notEnabledTitle}

+

{t.features.notEnabledDescription}

); } @@ -112,21 +113,21 @@ export function FeatureInstallPrompt({

{displayName}

{displayDescription}

- This feature requires an additional download (~{bundle.estimatedSize}) + {format(t.features.requiresDownload, { size: bundle.estimatedSize })}

{error && (
- {error} + {error}
)} @@ -144,7 +145,7 @@ export function FeatureInstallPrompt({ {PROGRESS_MESSAGES[messageIndex]}
- {eta &&

{eta}

} + {eta &&

{eta}

}
)} @@ -152,7 +153,7 @@ export function FeatureInstallPrompt({ {isQueued && (
- Queued for installation... + {t.features.queued}
)} @@ -162,7 +163,7 @@ export function FeatureInstallPrompt({ onClick={handleInstall} className="px-6 py-2.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 font-medium" > - Enable {displayName} + {format(t.features.enableButton, { name: displayName })} )}
diff --git a/apps/web/src/components/files/file-details.tsx b/apps/web/src/components/files/file-details.tsx index 2d4d9745..cd4d6dd6 100644 --- a/apps/web/src/components/files/file-details.tsx +++ b/apps/web/src/components/files/file-details.tsx @@ -195,7 +195,7 @@ function DetailRow({ label, value }: { label: string; value: string }) { return (
{label} - {value} + {value}
); } diff --git a/apps/web/src/components/files/file-list-item.tsx b/apps/web/src/components/files/file-list-item.tsx index d18db104..9de67a4b 100644 --- a/apps/web/src/components/files/file-list-item.tsx +++ b/apps/web/src/components/files/file-list-item.tsx @@ -80,12 +80,12 @@ export function FileListItem({ file }: FileListItemProps) { {/* Size */} - + {formatSize(file.size)} {/* Date */} - + {formatDate(file.createdAt)}
diff --git a/apps/web/src/components/files/file-list.tsx b/apps/web/src/components/files/file-list.tsx index 08378bb1..8169f7db 100644 --- a/apps/web/src/components/files/file-list.tsx +++ b/apps/web/src/components/files/file-list.tsx @@ -64,7 +64,7 @@ export function FileList() { placeholder="Search files..." value={inputValue} onChange={handleSearchChange} - className="w-full pl-8 pr-3 py-1.5 text-sm bg-muted rounded-lg border border-border focus:outline-none focus:ring-2 focus:ring-primary/50 text-foreground placeholder:text-muted-foreground" + className="w-full ps-8 pe-3 py-1.5 text-sm bg-muted rounded-lg border border-border focus:outline-none focus:ring-2 focus:ring-primary/50 text-foreground placeholder:text-muted-foreground" /> diff --git a/apps/web/src/components/help/help-dialog.tsx b/apps/web/src/components/help/help-dialog.tsx index 1a1f5cad..99f1560b 100644 --- a/apps/web/src/components/help/help-dialog.tsx +++ b/apps/web/src/components/help/help-dialog.tsx @@ -1,6 +1,7 @@ import { APP_VERSION } from "@snapotter/shared"; import { BookOpen, ExternalLink, Github, Keyboard, X } from "lucide-react"; import { useEffect } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; import { formatShortcut } from "@/hooks/use-keyboard-shortcuts"; interface HelpDialogProps { @@ -23,6 +24,7 @@ const SHORTCUTS = [ ]; export function HelpDialog({ open, onClose }: HelpDialogProps) { + const { t } = useTranslation(); useEffect(() => { if (!open) return; const handler = (e: KeyboardEvent) => { @@ -45,7 +47,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
{/* Header */}
-

Help

+

{t.help.heading}

)} diff --git a/apps/web/src/components/layout/footer.tsx b/apps/web/src/components/layout/footer.tsx index 8755b034..8663b774 100644 --- a/apps/web/src/components/layout/footer.tsx +++ b/apps/web/src/components/layout/footer.tsx @@ -1,6 +1,70 @@ import { Globe, Moon, Sun } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; import { useTheme } from "@/hooks/use-theme"; +function LanguageSelector() { + const { locale, setLocale, supportedLocales } = useTranslation(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + } + if (open) document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [open]); + + const current = supportedLocales.find((l) => l.code === locale); + + return ( +
+ + {open && ( +
+ {supportedLocales.map((l) => ( + + ))} +
+ )} +
+ ); +} + export function Footer() { const { resolvedTheme, toggleTheme } = useTheme(); @@ -14,14 +78,7 @@ export function Footer() { > {resolvedTheme === "dark" ? : } - +
); } diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index 33372168..b30941d7 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -1,6 +1,7 @@ import { FolderOpen, Grid3x3, HelpCircle, LayoutGrid, Settings, Workflow } from "lucide-react"; import type { ComponentType, SVGProps } from "react"; import { Link, useLocation } from "react-router-dom"; +import { useTranslation } from "@/contexts/i18n-context"; import { cn } from "@/lib/utils"; import { ImageEditIcon } from "../common/image-edit-icon"; import { OtterLogo } from "../common/otter-logo"; @@ -11,18 +12,21 @@ interface SidebarItem { href?: string; } -const topItems: SidebarItem[] = [ - { icon: LayoutGrid, label: "Tools", href: "/" }, - { icon: Grid3x3, label: "Grid", href: "/fullscreen" }, - { icon: Workflow, label: "Automate", href: "/automate" }, - { icon: ImageEditIcon, label: "Editor", href: "/editor" }, - { icon: FolderOpen, label: "Files", href: "/files" }, -]; - -const bottomItems: SidebarItem[] = [ - { icon: HelpCircle, label: "Help" }, - { icon: Settings, label: "Settings" }, -]; +function useNavItems() { + const { t } = useTranslation(); + const topItems: SidebarItem[] = [ + { icon: LayoutGrid, label: t.sidebar.tools, href: "/" }, + { icon: Grid3x3, label: t.sidebar.grid, href: "/fullscreen" }, + { icon: Workflow, label: t.sidebar.automate, href: "/automate" }, + { icon: ImageEditIcon, label: t.sidebar.editor, href: "/editor" }, + { icon: FolderOpen, label: t.sidebar.files, href: "/files" }, + ]; + const bottomItems: SidebarItem[] = [ + { icon: HelpCircle, label: t.sidebar.help }, + { icon: Settings, label: t.sidebar.settings }, + ]; + return { topItems, bottomItems }; +} interface SidebarProps { onSettingsClick: () => void; @@ -40,6 +44,7 @@ export function Sidebar({ expanded = false, }: SidebarProps) { const location = useLocation(); + const { topItems, bottomItems } = useNavItems(); const renderItem = (item: SidebarItem, isActive: boolean) => { const content = expanded ? ( @@ -68,14 +73,14 @@ export function Sidebar({
); - if (item.label === "Settings") { + if (item === bottomItems[1]) { return ( ); } - if (item.label === "Help") { + if (item === bottomItems[0]) { return ( @@ -130,7 +131,7 @@ export function AiFeaturesSection() { {diskUsage !== null && (

- Disk usage: {formatBytes(diskUsage)} + {format(t.settings.aiFeatures.diskUsage, { size: formatBytes(diskUsage) })}

)} @@ -163,6 +164,7 @@ function BundleCard({ isQueued: boolean; startTime: number | null; }) { + const { t } = useTranslation(); const [confirming, setConfirming] = useState(false); const [messageIndex, setMessageIndex] = useState(() => Math.floor(Math.random() * PROGRESS_MESSAGES.length), @@ -197,24 +199,30 @@ function BundleCard({ {bundle.description} (~{bundle.estimatedSize})

-
+
{status === "installed" && ( <> - Installed + + {t.settings.aiFeatures.installed} + )} {status === "not_installed" && !error && ( <> - Not installed + + {t.settings.aiFeatures.notInstalled} + )} {status === "queued" && ( <> - Queued + + {t.settings.aiFeatures.queued} + )} {status === "installing" && progress && ( @@ -240,7 +248,7 @@ function BundleCard({ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors" > - Install + {t.settings.aiFeatures.install} )} {status === "installed" && !confirming && ( @@ -251,7 +259,7 @@ function BundleCard({ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors" > - Repair + {t.settings.aiFeatures.repair}
)} @@ -274,14 +282,14 @@ function BundleCard({ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 transition-colors" > - Confirm + {t.common.confirm}
)} @@ -292,7 +300,7 @@ function BundleCard({ 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" > - Installing... + {t.settings.aiFeatures.installing} )} {(status === "error" || error) && !isInstalling && !isQueued && ( @@ -302,7 +310,7 @@ function BundleCard({ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors" > - Retry + {t.common.retry} )}
@@ -319,7 +327,7 @@ function BundleCard({

{PROGRESS_MESSAGES[messageIndex]}

- {eta &&

{eta}

} + {eta &&

{eta}

} )} diff --git a/apps/web/src/components/settings/settings-dialog.tsx b/apps/web/src/components/settings/settings-dialog.tsx index 67d3cb30..a6e58686 100644 --- a/apps/web/src/components/settings/settings-dialog.tsx +++ b/apps/web/src/components/settings/settings-dialog.tsx @@ -1,4 +1,4 @@ -import { APP_VERSION, CATEGORIES, TOOLS } from "@snapotter/shared"; +import { APP_VERSION, CATEGORIES, SUPPORTED_LOCALES, TOOLS } from "@snapotter/shared"; import { Check, Copy, @@ -27,8 +27,11 @@ import { X, } from "lucide-react"; import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; import { useAuth } from "@/hooks/use-auth"; import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@/lib/api"; +import { format, plural } from "@/lib/format"; +import { getCategoryName, getToolDescription, getToolName } from "@/lib/tool-i18n"; import { cn, copyToClipboard } from "@/lib/utils"; import { useAnalyticsStore } from "@/stores/analytics-store"; import { useSettingsStore } from "@/stores/settings-store"; @@ -62,24 +65,62 @@ interface NavItem { requiredPermission?: string; } -const NAV_ITEMS: NavItem[] = [ - { id: "general", label: "General", icon: Settings }, - { id: "system", label: "System Settings", icon: Monitor, requiredPermission: "settings:write" }, - { id: "security", label: "Security", icon: Shield }, - { id: "people", label: "People", icon: Users, requiredPermission: "users:manage" }, - { id: "teams", label: "Teams", icon: UsersRound, requiredPermission: "teams:manage" }, - { id: "roles", label: "Roles", icon: Shield, requiredPermission: "users:manage" }, - { id: "audit-log", label: "Audit Log", icon: FileText, requiredPermission: "audit:read" }, - { id: "api-keys", label: "API Keys", icon: Key }, - { id: "ai-features", label: "AI Features", icon: Sparkles, requiredPermission: "settings:write" }, - { id: "tools", label: "Tools", icon: Wrench }, - { id: "analytics", label: "Product Analytics", icon: Eye }, - { id: "about", label: "About", icon: Info }, -]; +function useNavItems() { + const { t } = useTranslation(); + return useMemo( + () => [ + { id: "general", label: t.settings.nav.general, icon: Settings }, + { + id: "system", + label: t.settings.nav.systemSettings, + icon: Monitor, + requiredPermission: "settings:write", + }, + { id: "security", label: t.settings.nav.security, icon: Shield }, + { + id: "people", + label: t.settings.nav.people, + icon: Users, + requiredPermission: "users:manage", + }, + { + id: "teams", + label: t.settings.nav.teams, + icon: UsersRound, + requiredPermission: "teams:manage", + }, + { + id: "roles", + label: t.settings.nav.roles, + icon: Shield, + requiredPermission: "users:manage", + }, + { + id: "audit-log", + label: t.settings.nav.auditLog, + icon: FileText, + requiredPermission: "audit:read", + }, + { id: "api-keys", label: t.settings.nav.apiKeys, icon: Key }, + { + id: "ai-features", + label: t.settings.nav.aiFeatures, + icon: Sparkles, + requiredPermission: "settings:write", + }, + { id: "tools", label: t.settings.nav.tools, icon: Wrench }, + { id: "analytics", label: t.settings.nav.productAnalytics, icon: Eye }, + { id: "about", label: t.settings.nav.about, icon: Info }, + ], + [t], + ); +} export function SettingsDialog({ open, onClose }: SettingsDialogProps) { const [section, setSection] = useState
("general"); const { hasPermission } = useAuth(); + const { t } = useTranslation(); + const NAV_ITEMS = useNavItems(); const visibleNavItems = NAV_ITEMS.filter( (item) => !item.requiredPermission || hasPermission(item.requiredPermission), @@ -115,7 +156,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) { {/* Sidebar nav */}
-

Settings

+

{t.settings.heading}

{visibleNavItems.map((item) => (
@@ -309,25 +351,47 @@ function GeneralSection() { className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors" > - Log out + {t.settings.general.logOut} )} {/* Default view */} - + - {/* Version */} - + + + + + {APP_VERSION} @@ -339,13 +403,13 @@ function GeneralSection() { 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 && } - Save Settings + {t.settings.general.saveButton} {saveMsg && ( >({}); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -394,14 +459,14 @@ function SystemSection() { const theme = settings.defaultTheme as "light" | "dark" | "system"; useThemeStore.getState().setTheme(theme); } - setSaveMsg("Settings saved."); + setSaveMsg(t.settings.system.saveSuccess); } catch { - setSaveMsg("Failed to save settings."); + setSaveMsg(t.settings.system.saveFailed); } finally { setSaving(false); setTimeout(() => setSaveMsg(null), 3000); } - }, [settings]); + }, [settings, t]); if (loading) { return ( @@ -414,11 +479,14 @@ function SystemSection() { return (
-

System Settings

-

Server-side configuration and limits.

+

{t.settings.system.heading}

+

{t.settings.system.description}

- + - + - +
-

File Management

+

+ {t.settings.fileManagement.title} +

{saveMsg && ( { e.preventDefault(); if (newPassword !== confirmPassword) { - setMessage({ type: "error", text: "Passwords do not match" }); + setMessage({ type: "error", text: t.settings.security.passwordsMismatch }); return; } if (newPassword.length < 4) { - setMessage({ type: "error", text: "Password must be at least 4 characters" }); + setMessage({ type: "error", text: t.settings.security.passwordTooShort }); return; } @@ -556,15 +637,15 @@ function SecuritySection() { setMessage(null); try { await apiPost("/auth/change-password", { currentPassword, newPassword }); - setMessage({ type: "success", text: "Password changed successfully" }); + setMessage({ type: "success", text: t.settings.security.changeSuccess }); setCurrentPassword(""); setNewPassword(""); setConfirmPassword(""); } catch (err) { - const msg = err instanceof Error ? err.message : "Failed to change password"; + const msg = err instanceof Error ? err.message : t.settings.security.changeFailed; setMessage({ type: "error", - text: msg.includes("401") ? "Current password is incorrect" : msg, + text: msg.includes("401") ? t.settings.security.currentPasswordIncorrect : msg, }); } finally { setSubmitting(false); @@ -576,12 +657,14 @@ function SecuritySection() { return (
-

Security

-

Password and authentication settings.

+

{t.settings.security.heading}

+

{t.settings.security.description}

-

Change Password

+

+ {t.settings.security.changePasswordHeading} +

@@ -589,8 +672,8 @@ function SecuritySection() { type={showCurrent ? "text" : "password"} value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} - placeholder="Current Password" - className="w-full px-3 py-2 pr-10 rounded-lg border border-border bg-background text-sm text-foreground" + 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 />
-

- Login attempt limits can be configured in System Settings. -

+

{t.settings.security.loginAttemptLimitNote}

); @@ -665,6 +746,7 @@ function SecuritySection() { /* ────────────────────── People ────────────────────── */ function PeopleSection() { + const { t } = useTranslation(); const [users, setUsers] = useState([]); const [maxUsers, setMaxUsers] = useState(5); const [loading, setLoading] = useState(true); @@ -748,11 +830,13 @@ function PeopleSection() { setNewRole("user"); setNewTeam("Default"); setShowAddForm(false); - setActionMsg({ type: "success", text: "User created successfully" }); + setActionMsg({ type: "success", text: t.settings.people.createSuccess }); await loadUsers(); } catch (err) { - const msg = err instanceof Error ? err.message : "Failed to create user"; - setAddError(msg.includes("403") ? `User limit reached (${maxUsers} max)` : msg); + const msg = err instanceof Error ? err.message : t.settings.people.createFailed; + setAddError( + msg.includes("403") ? format(t.settings.people.userLimitReached, { max: maxUsers }) : msg, + ); } finally { setAdding(false); setTimeout(() => setActionMsg(null), 3000); @@ -763,13 +847,16 @@ function PeopleSection() { const handleDeleteUser = useCallback( async (id: string, username: string) => { - if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return; + if (!confirm(format(t.settings.people.deleteConfirm, { username }))) return; try { await apiDelete(`/auth/users/${id}`); - setActionMsg({ type: "success", text: `User "${username}" deleted` }); + setActionMsg({ + type: "success", + text: format(t.settings.people.deleteSuccess, { username }), + }); await loadUsers(); } catch { - setActionMsg({ type: "error", text: "Failed to delete user" }); + setActionMsg({ type: "error", text: t.settings.people.deleteFailed }); } setOpenMenuId(null); setTimeout(() => setActionMsg(null), 3000); @@ -787,13 +874,13 @@ function PeopleSection() { team: editTeam, }); setEditingUser(null); - setActionMsg({ type: "success", text: "User updated" }); + setActionMsg({ type: "success", text: t.settings.people.updateSuccess }); await loadUsers(); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to update user"; setActionMsg({ type: "error", - text: msg.includes("400") ? "Cannot remove your own admin role" : msg, + text: msg.includes("400") ? t.settings.people.cannotRemoveOwnAdmin : msg, }); } setTimeout(() => setActionMsg(null), 3000); @@ -811,7 +898,7 @@ function PeopleSection() { }); setResetPasswordUser(null); setResetPassword(""); - setActionMsg({ type: "success", text: "Password reset successfully" }); + setActionMsg({ type: "success", text: t.settings.people.resetSuccess }); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to reset password"; setActionMsg({ type: "error", text: msg }); @@ -833,17 +920,19 @@ function PeopleSection() {
{/* Header */}
-

People

-

- Manage workspace members and their permissions -

+

{t.settings.people.heading}

+

{t.settings.people.description}

{/* User count */}

{maxUsers > 0 - ? `${users.length} / ${maxUsers} users` - : `${users.length} ${users.length === 1 ? "user" : "users"}`} + ? `${users.length} / ${maxUsers} ${plural(maxUsers, format(t.settings.people.userCount, { count: "" }), format(t.settings.people.userCountPlural, { count: "" })).trim()}` + : plural( + users.length, + format(t.settings.people.userCount, { count: users.length }), + format(t.settings.people.userCountPlural, { count: users.length }), + )}

{/* Action message */} @@ -868,8 +957,8 @@ function PeopleSection() { type="text" value={search} onChange={(e) => setSearch(e.target.value)} - placeholder="Search members..." - className="w-full pl-9 pr-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" + placeholder={t.settings.people.searchPlaceholder} + className="w-full ps-9 pe-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" />
@@ -898,13 +991,15 @@ function PeopleSection() { onSubmit={handleAddUser} className="p-4 rounded-lg border border-border bg-muted/20 space-y-3" > -

New Member

+

+ {t.settings.people.newMemberHeading} +

setNewUsername(e.target.value)} - placeholder="Username" + placeholder={t.settings.people.usernamePlaceholder} required className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" /> @@ -912,7 +1007,7 @@ function PeopleSection() { type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} - placeholder="Password" + placeholder={t.auth.password} required minLength={8} className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" @@ -926,14 +1021,14 @@ function PeopleSection() { availableRoles.map((r) => ( )) ) : ( <> - - - + + + )} @@ -942,9 +1037,9 @@ function PeopleSection() { onChange={(e) => setNewTeam(e.target.value)} className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" > - {teams.map((t) => ( - ))} {teams.length === 0 && } @@ -957,27 +1052,28 @@ function PeopleSection() { 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 && } - Create + {t.common.create}
{addError &&

{addError}

} )} - {/* Edit user modal */} {editingUser && (
-

Edit {editingUser.username}

+

+ {t.common.edit} {editingUser.username} +

@@ -1004,9 +1100,9 @@ function PeopleSection() { onChange={(e) => setEditTeam(e.target.value)} className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground w-40" > - {teams.map((t) => ( - ))} {teams.length === 0 && } @@ -1015,34 +1111,35 @@ function PeopleSection() { type="submit" className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors" > - Save + {t.common.save}
)} - {/* Reset password modal */} {resetPasswordUser && (

- Reset password for {resetPasswordUser.username} + {format(t.settings.people.resetPasswordHeading, { + username: resetPasswordUser.username, + })}

setResetPassword(e.target.value)} - placeholder="New password (min 8 chars)" + placeholder={t.settings.people.newPasswordLabel} required minLength={8} className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground w-60" @@ -1051,7 +1148,7 @@ function PeopleSection() { type="submit" className="px-4 py-2 rounded-lg bg-orange-500 text-white text-sm font-medium hover:bg-orange-600 transition-colors" > - Reset Password + {t.settings.people.resetPasswordButton}
-

- This will invalidate all sessions and API keys for this user. -

+

{t.settings.people.resetPasswordWarning}

)} @@ -1074,16 +1169,16 @@ function PeopleSection() {
{/* Table header */}
- User - Role - Team + {t.settings.people.tableHeaderUser} + {t.settings.people.tableHeaderRole} + {t.settings.people.tableHeaderTeam}
{/* Table rows */} {filteredUsers.length === 0 ? (
- {search ? "No members match your search." : "No users found."} + {search ? t.settings.people.noSearchResults : t.settings.people.noUsersFound}
) : ( filteredUsers.map((u) => ( @@ -1098,13 +1193,13 @@ function PeopleSection() {
{u.username} {u.hasOidcLink && u.hasLocalPassword !== false && ( - - Local + OIDC + + {t.auth.methodBoth} )} {u.hasOidcLink && u.hasLocalPassword === false && ( - - OIDC + + {t.auth.methodOidc} )}
@@ -1157,7 +1252,7 @@ function PeopleSection() { className="flex items-center gap-2 w-full px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors" > - Edit Role / Team + {t.settings.people.editRoleTeamAction} {u.hasLocalPassword !== false && ( )}
@@ -1180,7 +1275,7 @@ function PeopleSection() { className="flex items-center gap-2 w-full px-3 py-2 text-sm text-destructive hover:bg-destructive/10 transition-colors" > - Delete User + {t.settings.people.deleteUserAction}
)} @@ -1196,6 +1291,7 @@ function PeopleSection() { /* ────────────────────── API Keys ────────────────────── */ function ApiKeysSection() { + const { t } = useTranslation(); const [keys, setKeys] = useState([]); const [loading, setLoading] = useState(true); const [newKey, setNewKey] = useState(null); @@ -1257,7 +1353,7 @@ function ApiKeysSection() { const deleteKey = useCallback( async (id: number) => { - if (!confirm("Delete this API key? Any integrations using it will stop working.")) return; + if (!confirm(t.settings.apiKeys.deleteConfirm)) return; try { await apiDelete(`/v1/api-keys/${id}`); await loadKeys(); @@ -1279,10 +1375,8 @@ function ApiKeysSection() { return (
-

API Keys

-

- Manage API keys for programmatic access to SnapOtter. -

+

{t.settings.apiKeys.heading}

+

{t.settings.apiKeys.description}

{/* Generate new key */} @@ -1291,7 +1385,7 @@ function ApiKeysSection() { type="text" value={keyName} onChange={(e) => setKeyName(e.target.value)} - placeholder="Key name (optional)" + placeholder={t.settings.apiKeys.keyNamePlaceholder} className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground w-48" />
@@ -1312,7 +1406,9 @@ function ApiKeysSection() { onClick={() => setShowScoping(!showScoping)} className="text-xs text-muted-foreground hover:text-foreground transition-colors" > - {showScoping ? "Remove permission scoping" : "Restrict permissions (optional)"} + {showScoping + ? t.settings.apiKeys.removeScopingLabel + : t.settings.apiKeys.restrictPermissionsLabel} {showScoping && ( @@ -1377,16 +1473,16 @@ function ApiKeysSection() { {copied ? : } -

- Store this key securely. It will not be shown again. -

+

{t.settings.apiKeys.keyWarning}

)} {/* Existing keys list */} {keys.length > 0 && (
-

Existing Keys

+

+ {t.settings.apiKeys.existingKeysHeading} +

{keys.map((k) => (
- No API keys yet. Generate one to get started. -

+

{t.settings.apiKeys.emptyState}

)}
); @@ -1433,6 +1527,7 @@ function ApiKeysSection() { /* ────────────────────── Teams ────────────────────── */ function TeamsSection() { + const { t } = useTranslation(); const [teams, setTeams] = useState([]); const [loading, setLoading] = useState(true); const [showCreateForm, setShowCreateForm] = useState(false); @@ -1477,13 +1572,13 @@ function TeamsSection() { await apiPost("/v1/teams", { name: newTeamName.trim() }); setNewTeamName(""); setShowCreateForm(false); - setActionMsg({ type: "success", text: "Team created successfully" }); + setActionMsg({ type: "success", text: t.settings.teams.createSuccess }); await loadTeams(); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to create team"; setActionMsg({ type: "error", - text: msg.includes("409") ? "A team with that name already exists" : msg, + text: msg.includes("409") ? t.settings.teams.duplicateName : msg, }); } finally { setCreating(false); @@ -1500,7 +1595,7 @@ function TeamsSection() { await apiPut(`/v1/teams/${id}`, { name: editingTeamName.trim() }); setEditingTeamId(null); setEditingTeamName(""); - setActionMsg({ type: "success", text: "Team renamed" }); + setActionMsg({ type: "success", text: t.settings.teams.renameSuccess }); await loadTeams(); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to rename team"; @@ -1513,7 +1608,7 @@ function TeamsSection() { const handleDelete = useCallback( async (id: number, name: string) => { - if (!confirm(`Delete team "${name}"? Members will be unassigned.`)) return; + if (!confirm(format(t.settings.teams.deleteConfirm, { name }))) return; try { await apiDelete(`/v1/teams/${id}`); setActionMsg({ type: "success", text: `Team "${name}" deleted` }); @@ -1522,7 +1617,7 @@ function TeamsSection() { const msg = err instanceof Error ? err.message : "Failed to delete team"; setActionMsg({ type: "error", - text: msg.includes("400") ? "Cannot delete the default team or a team with members" : msg, + text: msg.includes("400") ? t.settings.teams.cannotDeleteDefault : msg, }); } setOpenMenuId(null); @@ -1542,10 +1637,8 @@ function TeamsSection() { return (
-

Teams

-

- Organize members into teams for better management. -

+

{t.settings.teams.heading}

+

{t.settings.teams.description}

{actionMsg && ( @@ -1568,7 +1661,7 @@ function TeamsSection() { 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" > - Create New Team + {t.settings.teams.createButton}
@@ -1577,13 +1670,13 @@ function TeamsSection() { onSubmit={handleCreate} className="p-4 rounded-lg border border-border bg-muted/20 space-y-3" > -

New Team

+

{t.settings.teams.newTeamHeading}

setNewTeamName(e.target.value)} - placeholder="Team name" + placeholder={t.settings.teams.teamNamePlaceholder} required className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground flex-1" /> @@ -1593,14 +1686,14 @@ function TeamsSection() { 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 && } - Create + {t.common.create}
@@ -1608,21 +1701,23 @@ function TeamsSection() {
- Team Name - Members + {t.settings.teams.tableHeaderTeamName} + {t.settings.teams.totalMembers}
{teams.length === 0 ? ( -
No teams found.
+
+ {t.settings.teams.emptyState} +
) : ( - teams.map((t) => ( + teams.map((tm) => (
- {editingTeamId === t.id ? ( + {editingTeamId === tm.id ? (
el?.focus()} onKeyDown={(e) => { - if (e.key === "Enter") handleRename(t.id); + if (e.key === "Enter") handleRename(tm.id); if (e.key === "Escape") setEditingTeamId(null); }} />
) : ( - {t.name} + {tm.name} )}
- {t.memberCount} + {tm.memberCount}
- {openMenuId === t.id && ( + {openMenuId === tm.id && (
{ - setEditingTeamId(t.id); - setEditingTeamName(t.name); + setEditingTeamId(tm.id); + setEditingTeamName(tm.name); setOpenMenuId(null); }} className="flex items-center gap-2 w-full px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors" > - Rename + {t.settings.teams.renameAction}
)} @@ -1720,6 +1815,7 @@ const PERMISSION_GROUPS = [ ]; function RolesSection() { + const { t } = useTranslation(); const [roles, setRoles] = useState([]); const [loading, setLoading] = useState(true); const [showCreateForm, setShowCreateForm] = useState(false); @@ -1763,13 +1859,13 @@ function RolesSection() { setNewDescription(""); setNewPermissions([]); setShowCreateForm(false); - setActionMsg({ type: "success", text: "Role created successfully" }); + setActionMsg({ type: "success", text: t.settings.roles.createSuccess }); await loadRoles(); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to create role"; setActionMsg({ type: "error", - text: msg.includes("409") ? "A role with that name already exists" : msg, + text: msg.includes("409") ? t.settings.roles.duplicateRoleError : msg, }); } setTimeout(() => setActionMsg(null), 3000); @@ -1788,7 +1884,7 @@ function RolesSection() { permissions: editPermissions, }); setEditingRole(null); - setActionMsg({ type: "success", text: "Role updated" }); + setActionMsg({ type: "success", text: t.settings.roles.updateSuccess }); await loadRoles(); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to update role"; @@ -1834,10 +1930,8 @@ function RolesSection() { return (
-

Roles

-

- Manage roles and their permissions. Built-in roles cannot be modified. -

+

{t.settings.roles.heading}

+

{t.settings.roles.description}

{actionMsg && ( @@ -1860,7 +1954,7 @@ function RolesSection() { 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" > - Create Custom Role + {t.settings.roles.createButton}
@@ -1870,13 +1964,13 @@ function RolesSection() { onSubmit={handleCreate} className="p-4 rounded-lg border border-border bg-muted/20 space-y-3" > -

New Role

+

{t.settings.roles.newRoleHeading}

setNewName(e.target.value)} - placeholder="Role name" + placeholder={t.settings.roles.roleNamePlaceholder} required className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" /> @@ -1884,12 +1978,14 @@ function RolesSection() { type="text" value={newDescription} onChange={(e) => setNewDescription(e.target.value)} - placeholder="Description (optional)" + placeholder={t.settings.roles.descriptionPlaceholder} className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" />
-

Permissions

+

+ {t.settings.roles.permissionsLabel} +

{PERMISSION_GROUPS.map((group) => (
@@ -1938,13 +2034,15 @@ function RolesSection() { onSubmit={handleUpdate} className="p-4 rounded-lg border border-primary/30 bg-primary/5 space-y-3" > -

Edit Role: {editingRole.name}

+

+ {format(t.settings.roles.editHeading, { name: editingRole.name })} +

setEditName(e.target.value)} - placeholder="Role name" + placeholder={t.settings.roles.roleNamePlaceholder} required className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" /> @@ -1952,12 +2050,14 @@ function RolesSection() { type="text" value={editDescription} onChange={(e) => setEditDescription(e.target.value)} - placeholder="Description (optional)" + placeholder={t.settings.roles.descriptionPlaceholder} className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" />
-

Permissions

+

+ {t.settings.roles.permissionsLabel} +

{PERMISSION_GROUPS.map((group) => (
@@ -1998,7 +2098,9 @@ function RolesSection() { {/* Role cards */}
{roles.length === 0 ? ( -

No roles found.

+

+ {t.settings.roles.emptyState} +

) : ( roles.map((role) => (
- Built-in + {t.settings.roles.builtInBadge} )} @@ -2108,6 +2210,7 @@ function formatRelativeTime(iso: string): string { } function AuditLogSection() { + const { t } = useTranslation(); const [entries, setEntries] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); @@ -2148,13 +2251,13 @@ function AuditLogSection() { return (
-

Audit Log

+

{t.settings.auditLog.heading}

0 && (
-

Preview

+

{t.toolSettings["bulk-rename"].preview}

{previewNames.map((name) => (
{processing && } - {processing ? "Renaming..." : `Rename ${files.length} Files`} + {processing + ? t.toolSettings["bulk-rename"].renaming + : format(t.toolSettings["bulk-rename"].submit, { count: files.length })} {downloadReady && (

- ZIP downloaded successfully + {t.toolSettings["bulk-rename"].zipDownloaded}

)}
diff --git a/apps/web/src/components/tools/collage-preview.tsx b/apps/web/src/components/tools/collage-preview.tsx index 0034a825..c6e824a1 100644 --- a/apps/web/src/components/tools/collage-preview.tsx +++ b/apps/web/src/components/tools/collage-preview.tsx @@ -24,6 +24,7 @@ import { } from "lucide-react"; import { type DragEvent, useCallback, useEffect, useRef, useState } from "react"; import { isImageFile } from "@/components/common/dropzone"; +import { useTranslation } from "@/contexts/i18n-context"; import { type CollageTemplate, getTemplateById } from "@/lib/collage-templates"; import { cn } from "@/lib/utils"; import type { CellTransform, CollageImage } from "@/stores/collage-store"; @@ -50,6 +51,7 @@ function displayUrl(img: CollageImage): string { } export function CollagePreview() { + const { t } = useTranslation(); const images = useCollageStore((s) => s.images); const templateId = useCollageStore((s) => s.templateId); const phase = useCollageStore((s) => s.phase); @@ -570,7 +572,7 @@ function CollageCell({ onChange={handleZoomSlider} className="flex-1 h-1.5 accent-white cursor-pointer" /> - + {transform.zoom.toFixed(1)}x )} diff --git a/apps/web/src/components/tools/compare-settings.tsx b/apps/web/src/components/tools/compare-settings.tsx index 1b21e8c1..34f8f844 100644 --- a/apps/web/src/components/tools/compare-settings.tsx +++ b/apps/web/src/components/tools/compare-settings.tsx @@ -1,8 +1,10 @@ import { Download, Loader2, Upload } from "lucide-react"; import { useRef, useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; import { formatHeaders } from "@/lib/api"; import { useFileStore } from "@/stores/file-store"; export function CompareSettings() { + const { t } = useTranslation(); const { files, processing, error, setProcessing, setError, setProcessedUrl } = useFileStore(); const [secondFile, setSecondFile] = useState(null); const [similarity, setSimilarity] = useState(null); diff --git a/apps/web/src/components/tools/compose-settings.tsx b/apps/web/src/components/tools/compose-settings.tsx index e782587d..911251c2 100644 --- a/apps/web/src/components/tools/compose-settings.tsx +++ b/apps/web/src/components/tools/compose-settings.tsx @@ -1,8 +1,10 @@ import { Download, Loader2, Upload } from "lucide-react"; import { useRef, useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; import { formatHeaders } from "@/lib/api"; import { useFileStore } from "@/stores/file-store"; export function ComposeSettings() { + const { t } = useTranslation(); const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } = useFileStore(); const [overlayFile, setOverlayFile] = useState(null); @@ -166,7 +168,7 @@ export function ComposeSettings() { 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 && } - {processing ? "Processing..." : "Compose"} + {processing ? "Processing..." : t.toolSettings.compose.submit} {downloadUrl && ( diff --git a/apps/web/src/components/tools/compress-settings.tsx b/apps/web/src/components/tools/compress-settings.tsx index 577fcbcc..afc2b838 100644 --- a/apps/web/src/components/tools/compress-settings.tsx +++ b/apps/web/src/components/tools/compress-settings.tsx @@ -1,7 +1,9 @@ import { Download, Minus, Plus } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; import { useFileStore } from "@/stores/file-store"; type CompressMode = "quality" | "targetSize"; @@ -13,6 +15,7 @@ export interface CompressControlsProps { } export function CompressControls({ settings: initialSettings, onChange }: CompressControlsProps) { + const { t } = useTranslation(); const [mode, setMode] = useState("targetSize"); const [quality, setQuality] = useState(75); const [targetSizeValue, setTargetSizeValue] = useState(""); @@ -47,21 +50,23 @@ export function CompressControls({ settings: initialSettings, onChange }: Compre
{/* Mode toggle */}
-

Compression Mode

+

+ {t.toolSettings.compress.compressionMode} +

@@ -69,7 +74,7 @@ export function CompressControls({ settings: initialSettings, onChange }: Compre {mode === "targetSize" ? (
- Smallest file - Best quality + {t.toolSettings.compress.smallestFile} + {t.toolSettings.compress.bestQuality}
)} @@ -137,6 +142,7 @@ export function CompressControls({ settings: initialSettings, onChange }: Compre } export function CompressSettings() { + const { t } = useTranslation(); const { files } = useFileStore(); const { processFiles, @@ -178,10 +184,17 @@ export function CompressSettings() { {/* Size info */} {originalSize != null && processedSize != null && (
-

Original: {(originalSize / 1024).toFixed(1)} KB

-

Processed: {(processedSize / 1024).toFixed(1)} KB

+

+ {format(t.toolSettings.compress.original, { size: (originalSize / 1024).toFixed(1) })} +

+

+ {format(t.toolSettings.compress.processed, { size: (processedSize / 1024).toFixed(1) })} +

- Saved: {originalSize > 0 ? ((1 - processedSize / originalSize) * 100).toFixed(1) : "0"}% + {format(t.toolSettings.compress.saved, { + percent: + originalSize > 0 ? ((1 - processedSize / originalSize) * 100).toFixed(1) : "0", + })}

)} @@ -191,7 +204,7 @@ export function CompressSettings() { - {files.length > 1 ? `Compress (${files.length} files)` : "Compress"} + {files.length > 1 + ? format(t.toolSettings.compress.submitBatch, { count: files.length }) + : t.toolSettings.compress.submit} )} @@ -216,7 +231,7 @@ export function CompressSettings() { className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5" > - Download + {t.common.download} )} diff --git a/apps/web/src/components/tools/content-aware-resize-settings.tsx b/apps/web/src/components/tools/content-aware-resize-settings.tsx index 4f8cf6b8..fdfe7a48 100644 --- a/apps/web/src/components/tools/content-aware-resize-settings.tsx +++ b/apps/web/src/components/tools/content-aware-resize-settings.tsx @@ -1,7 +1,9 @@ import { Download, Info } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; import { useFileStore } from "@/stores/file-store"; function HintIcon({ text }: { text: string }) { @@ -162,6 +164,7 @@ export function ContentAwareResizeControls({ } export function ContentAwareResizeSettings() { + const { t } = useTranslation(); const { files } = useFileStore(); const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = useToolProcessor("content-aware-resize"); @@ -200,7 +203,7 @@ export function ContentAwareResizeSettings() { - {files.length > 1 ? `Resize (${files.length} files)` : "Resize"} + {files.length > 1 + ? format(t.toolSettings["content-aware-resize"].submitBatch, { count: files.length }) + : t.toolSettings["content-aware-resize"].submit} )} diff --git a/apps/web/src/components/tools/convert-settings.tsx b/apps/web/src/components/tools/convert-settings.tsx index dcb2f0a4..93ca65d7 100644 --- a/apps/web/src/components/tools/convert-settings.tsx +++ b/apps/web/src/components/tools/convert-settings.tsx @@ -1,7 +1,9 @@ import { Download } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; import { useFileStore } from "@/stores/file-store"; const OUTPUT_FORMATS = [ @@ -43,6 +45,7 @@ export interface ConvertControlsProps { } export function ConvertControls({ settings: initialSettings, onChange }: ConvertControlsProps) { + const { t } = useTranslation(); const [format, setFormat] = useState("png"); const [quality, setQuality] = useState(85); @@ -74,7 +77,7 @@ export function ConvertControls({ settings: initialSettings, onChange }: Convert {/* Target format */}
loadTemplate(t.name)} - className="flex-1 text-left text-xs px-2 py-1 rounded-md border border-input hover:bg-muted/50 truncate" + className="flex-1 text-start text-xs px-2 py-1 rounded-md border border-input hover:bg-muted/50 truncate" > {t.name} diff --git a/apps/web/src/components/tools/enhance-faces-settings.tsx b/apps/web/src/components/tools/enhance-faces-settings.tsx index e2a9ee30..8ee5d994 100644 --- a/apps/web/src/components/tools/enhance-faces-settings.tsx +++ b/apps/web/src/components/tools/enhance-faces-settings.tsx @@ -110,7 +110,7 @@ export function EnhanceFacesControls({ /> Only enhance main face -

+

For portraits - ignores background faces

diff --git a/apps/web/src/components/tools/erase-object-settings.tsx b/apps/web/src/components/tools/erase-object-settings.tsx index 9c5c1e8c..f6a01e72 100644 --- a/apps/web/src/components/tools/erase-object-settings.tsx +++ b/apps/web/src/components/tools/erase-object-settings.tsx @@ -1,7 +1,9 @@ import { Download, Redo, Trash2 } from "lucide-react"; import { useRef, useState } from "react"; import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; import { formatHeaders } from "@/lib/api"; +import { format } from "@/lib/format"; import { generateId } from "@/lib/utils"; import { useFileStore } from "@/stores/file-store"; import type { EraserCanvasRef } from "./eraser-canvas"; @@ -36,6 +38,7 @@ export function EraseObjectSettings({ onMaskCenter, maskedFileCount, }: EraseObjectSettingsProps) { + const { t } = useTranslation(); const { files, entries, @@ -386,7 +389,7 @@ export function EraseObjectSettings({
{brushSize}px
@@ -400,8 +403,8 @@ export function EraseObjectSettings({ className="w-full mt-1" />
- Fine - Wide + {t.toolSettings["erase-object"].fine} + {t.toolSettings["erase-object"].wide}
@@ -493,7 +496,7 @@ export function EraseObjectSettings({ @@ -505,7 +508,9 @@ export function EraseObjectSettings({ disabled={!hasFile || (!hasStrokes && maskedFileCount === 0) || 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" > - {maskedFileCount > 1 ? `Erase All (${maskedFileCount})` : "Erase Object"} + {maskedFileCount > 1 + ? format(t.toolSettings["erase-object"].submitBatch, { count: maskedFileCount }) + : t.toolSettings["erase-object"].submit} )} diff --git a/apps/web/src/components/tools/favicon-settings.tsx b/apps/web/src/components/tools/favicon-settings.tsx index ad288542..0140ba6c 100644 --- a/apps/web/src/components/tools/favicon-settings.tsx +++ b/apps/web/src/components/tools/favicon-settings.tsx @@ -2,7 +2,9 @@ import { Download } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; import { formatHeaders } from "@/lib/api"; +import { format } from "@/lib/format"; import { useFileStore } from "@/stores/file-store"; const SIZES = [ @@ -16,6 +18,7 @@ const SIZES = [ ]; export function FaviconSettings() { + const { t } = useTranslation(); const { files, error, setProcessing, setError } = useFileStore(); const [downloadUrl, setDownloadUrl] = useState(null); const [busy, setBusy] = useState(false); @@ -128,13 +131,14 @@ export function FaviconSettings() { return (

- Upload square images (recommended 512x512 or larger) to generate all favicon and app icon - sizes.{" "} - {files.length > 1 && `Each of the ${files.length} images gets its own folder in the ZIP.`} + {t.toolSettings.favicon.uploadHint}{" "} + {files.length > 1 && format(t.toolSettings.favicon.multipleHint, { count: files.length })}

-

Generated Sizes (per image)

+

+ {t.toolSettings.favicon.generatedSizes} +

{SIZES.map((s) => (
@@ -143,7 +147,9 @@ export function FaviconSettings() {
))}
-

+ manifest.json + HTML snippet

+

+ {t.toolSettings.favicon.plusManifest} +

{error &&

{error}

} @@ -152,7 +158,7 @@ export function FaviconSettings() { - Generate Favicons ({files.length} image{files.length !== 1 ? "s" : ""}) + {files.length !== 1 + ? format(t.toolSettings.favicon.submitPlural, { count: files.length }) + : format(t.toolSettings.favicon.submit, { count: files.length })} )} diff --git a/apps/web/src/components/tools/find-duplicates-results.tsx b/apps/web/src/components/tools/find-duplicates-results.tsx index cb74611d..f1d77249 100644 --- a/apps/web/src/components/tools/find-duplicates-results.tsx +++ b/apps/web/src/components/tools/find-duplicates-results.tsx @@ -1,4 +1,5 @@ import { ArrowLeft, ChevronLeft, ChevronRight, Crown, Search } from "lucide-react"; +import { useTranslation } from "@/contexts/i18n-context"; import { formatFileSize } from "@/lib/download"; import type { DuplicateFileInfo } from "@/stores/duplicate-store"; import { useDuplicateStore } from "@/stores/duplicate-store"; @@ -63,7 +64,7 @@ function OverviewGrid() { key={group.groupId} type="button" onClick={() => setSelectedGroup(gi)} - className="w-full text-left p-3 rounded-lg bg-muted/50 border border-border hover:border-primary/50 transition-colors" + className="w-full text-start p-3 rounded-lg bg-muted/50 border border-border hover:border-primary/50 transition-colors" >
@@ -190,7 +191,7 @@ function DetailComparison() { key={file.filename} type="button" onClick={() => overrideBest(selectedGroupIndex, fi)} - className="text-left" + className="text-start" title={isCurrentBest ? "Selected as best" : "Click to mark as best"} >
{file.filename}

Dimensions - + {file.width} x {file.height} File size - - {formatFileSize(file.fileSize)} - + {formatFileSize(file.fileSize)} Format - {file.format.toUpperCase()} + {file.format.toUpperCase()} Similarity {file.similarity}% @@ -252,6 +251,7 @@ function DetailComparison() { } export function FindDuplicatesResults() { + const { t } = useTranslation(); const { results, scanning, viewMode } = useDuplicateStore(); if (scanning) { diff --git a/apps/web/src/components/tools/find-duplicates-settings.tsx b/apps/web/src/components/tools/find-duplicates-settings.tsx index 23a291c9..00583c2c 100644 --- a/apps/web/src/components/tools/find-duplicates-settings.tsx +++ b/apps/web/src/components/tools/find-duplicates-settings.tsx @@ -1,5 +1,6 @@ import { Download, FolderArchive, Loader2 } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; import { formatHeaders } from "@/lib/api"; import { formatFileSize } from "@/lib/download"; import type { DuplicateResult } from "@/stores/duplicate-store"; @@ -15,6 +16,7 @@ const PRESET_DESCRIPTIONS: Record = { }; export function FindDuplicatesSettings() { + const { t } = useTranslation(); const { files } = useFileStore(); const { results, diff --git a/apps/web/src/components/tools/gif-tools-settings.tsx b/apps/web/src/components/tools/gif-tools-settings.tsx index d4c0931b..9b5b0178 100644 --- a/apps/web/src/components/tools/gif-tools-settings.tsx +++ b/apps/web/src/components/tools/gif-tools-settings.tsx @@ -1,8 +1,10 @@ import { Download, FlipHorizontal2, FlipVertical2, Link, RotateCw, Unlink } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; import { useGifInfo } from "@/hooks/use-gif-info"; import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; import { useFileStore } from "@/stores/file-store"; type GifMode = "resize" | "optimize" | "speed" | "reverse" | "extract" | "rotate"; @@ -25,6 +27,7 @@ export interface GifToolsControlsProps { } export function GifToolsControls({ settings: initialSettings, onChange }: GifToolsControlsProps) { + const { t } = useTranslation(); const { info, loading: infoLoading } = useGifInfo(); const isAnimated = (info?.pages ?? 0) > 1; @@ -643,6 +646,7 @@ export function GifToolsControls({ settings: initialSettings, onChange }: GifToo } export function GifToolsSettings() { + const { t } = useTranslation(); const { files } = useFileStore(); const { processFiles, @@ -678,7 +682,7 @@ export function GifToolsSettings() {

Processed: {(processedSize / 1024).toFixed(1)} KB {originalSize > 0 && ( - + ({Math.round(((processedSize - originalSize) / originalSize) * 100)}%) )} @@ -690,7 +694,7 @@ export function GifToolsSettings() { - {files.length > 1 ? `Process (${files.length} files)` : "Process"} + {files.length > 1 + ? format(t.toolSettings["gif-tools"].submitBatch, { count: files.length }) + : "Process"} )} diff --git a/apps/web/src/components/tools/image-enhancement-settings.tsx b/apps/web/src/components/tools/image-enhancement-settings.tsx index b5e2d509..648c5897 100644 --- a/apps/web/src/components/tools/image-enhancement-settings.tsx +++ b/apps/web/src/components/tools/image-enhancement-settings.tsx @@ -11,7 +11,9 @@ import { } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; import { useFileStore } from "@/stores/file-store"; type EnhancementMode = "auto" | "portrait" | "landscape" | "low-light" | "food" | "document"; @@ -144,6 +146,7 @@ export function ImageEnhancementControls({ onChange, onPreviewFilter, }: ImageEnhancementControlsProps) { + const { t } = useTranslation(); const { files } = useFileStore(); const [mode, setMode] = useState("auto"); const [intensity, setIntensity] = useState(50); @@ -283,7 +286,7 @@ export function ImageEnhancementControls({ {/* Mode selector */}

- Enhancement Mode + {t.toolSettings.imageEnhancement.enhancementMode}

{MODES.map(({ value, label, icon: Icon }) => ( @@ -307,7 +310,7 @@ export function ImageEnhancementControls({

- Intensity + {t.toolSettings.imageEnhancement.intensity}

{intensity}%
@@ -326,7 +329,7 @@ export function ImageEnhancementControls({
-

Deep Enhance (AI)

+

{t.toolSettings.imageEnhancement.deepEnhance}

Removes noise and artifacts using AI

@@ -358,7 +361,7 @@ export function ImageEnhancementControls({ {analysis && !analyzing && (

- Detected Issues + {t.toolSettings.imageEnhancement.detectedIssues}

{analysis.issues.length === 0 ? (

diff --git a/apps/web/src/components/tools/image-to-base64-results.tsx b/apps/web/src/components/tools/image-to-base64-results.tsx index f7614554..695a0cc9 100644 --- a/apps/web/src/components/tools/image-to-base64-results.tsx +++ b/apps/web/src/components/tools/image-to-base64-results.tsx @@ -1,5 +1,6 @@ import { Check, ClipboardCopy, Download, FileJson, FileText, Loader2 } from "lucide-react"; import { useCallback, useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; import type { Base64Result } from "@/stores/base64-store"; import { useBase64Store } from "@/stores/base64-store"; import { useFileStore } from "@/stores/file-store"; @@ -168,6 +169,7 @@ function FileResult({ result }: { result: Base64Result }) { // -- Main ResultsPanel ------------------------------------------------------ export function ImageToBase64Results() { + const { t } = useTranslation(); const { results, errors, processing, progress } = useBase64Store(); const { entries, selectedIndex, originalBlobUrl, selectedFileName } = useFileStore(); diff --git a/apps/web/src/components/tools/image-to-base64-settings.tsx b/apps/web/src/components/tools/image-to-base64-settings.tsx index 39d38159..c7bc2884 100644 --- a/apps/web/src/components/tools/image-to-base64-settings.tsx +++ b/apps/web/src/components/tools/image-to-base64-settings.tsx @@ -1,6 +1,8 @@ import { Loader2 } from "lucide-react"; import { useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; import { formatHeaders } from "@/lib/api"; +import { format } from "@/lib/format"; import { useBase64Store } from "@/stores/base64-store"; import { useFileStore } from "@/stores/file-store"; @@ -14,6 +16,7 @@ const OUTPUT_FORMATS = [ ] as const; export function ImageToBase64Settings() { + const { t } = useTranslation(); const { files } = useFileStore(); const { processing, setProcessing, setProgress, addResult, addError, reset } = useBase64Store(); diff --git a/apps/web/src/components/tools/info-settings.tsx b/apps/web/src/components/tools/info-settings.tsx index e05a283c..ece542d7 100644 --- a/apps/web/src/components/tools/info-settings.tsx +++ b/apps/web/src/components/tools/info-settings.tsx @@ -1,5 +1,6 @@ import { Loader2 } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; import { formatHeaders } from "@/lib/api"; import { useFileStore } from "@/stores/file-store"; @@ -31,6 +32,7 @@ interface ImageInfoData { } export function InfoSettings() { + const { t } = useTranslation(); const { files, processing, error, setProcessing, setError } = useFileStore(); const selectedIndex = useFileStore((s) => s.selectedIndex); const [info, setInfo] = useState(null); diff --git a/apps/web/src/components/tools/meme-generator-preview.tsx b/apps/web/src/components/tools/meme-generator-preview.tsx index 3804bb5e..e2aebee7 100644 --- a/apps/web/src/components/tools/meme-generator-preview.tsx +++ b/apps/web/src/components/tools/meme-generator-preview.tsx @@ -163,7 +163,7 @@ function TemplateGallery() { value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search templates..." - className={cn(INPUT_CLASS, "pl-8")} + className={cn(INPUT_CLASS, "ps-8")} />

@@ -183,7 +183,7 @@ function TemplateGallery() { )} > {cat.label} - ({categoryCounts[cat.id] ?? 0}) + ({categoryCounts[cat.id] ?? 0}) ))}
@@ -281,7 +281,7 @@ function LayoutPicker() { data-testid={`layout-${key}`} onClick={() => setCustomLayout(key)} className={cn( - "relative rounded-lg border-2 p-3 transition-all text-left", + "relative rounded-lg border-2 p-3 transition-all text-start", selected === key ? "border-primary bg-primary/5" : "border-border hover:border-primary/40", diff --git a/apps/web/src/components/tools/meme-generator-settings.tsx b/apps/web/src/components/tools/meme-generator-settings.tsx index bf9c1a57..b7b01c66 100644 --- a/apps/web/src/components/tools/meme-generator-settings.tsx +++ b/apps/web/src/components/tools/meme-generator-settings.tsx @@ -8,6 +8,7 @@ import { Sparkles, } from "lucide-react"; import { useCallback } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; import { cn } from "@/lib/utils"; import { FONT_OPTIONS, @@ -327,6 +328,7 @@ function ResultSettings() { // ── Main Settings Component ───────────────────────────────────────── export function MemeGeneratorSettings() { + const { t } = useTranslation(); const phase = useMemeStore((s) => s.phase); if (phase === "gallery") return ; diff --git a/apps/web/src/components/tools/noise-removal-settings.tsx b/apps/web/src/components/tools/noise-removal-settings.tsx index ef3fee11..9eccb766 100644 --- a/apps/web/src/components/tools/noise-removal-settings.tsx +++ b/apps/web/src/components/tools/noise-removal-settings.tsx @@ -1,7 +1,9 @@ import { Download } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { format } from "@/lib/format"; import { useFileStore } from "@/stores/file-store"; type Tier = "quick" | "balanced" | "quality" | "maximum"; @@ -203,6 +205,7 @@ export function NoiseRemovalControls({ } export function NoiseRemovalSettings() { + const { t } = useTranslation(); const { files, entries } = useFileStore(); const { processFiles, @@ -271,7 +274,9 @@ export function NoiseRemovalSettings() { 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" > - {hasMultiple ? `Remove Noise (${files.length} files)` : "Remove Noise"} + {hasMultiple + ? format(t.toolSettings["noise-removal"].submitBatch, { count: files.length }) + : t.toolSettings["noise-removal"].submit} )} diff --git a/apps/web/src/components/tools/ocr-settings.tsx b/apps/web/src/components/tools/ocr-settings.tsx index a6da74a3..1d4669c1 100644 --- a/apps/web/src/components/tools/ocr-settings.tsx +++ b/apps/web/src/components/tools/ocr-settings.tsx @@ -1,7 +1,9 @@ import { Check, ChevronDown, ChevronRight, Copy, Download, Info } from "lucide-react"; import { useRef, useState } from "react"; import { ProgressCard } from "@/components/common/progress-card"; +import { useTranslation } from "@/contexts/i18n-context"; import { formatHeaders } from "@/lib/api"; +import { format } from "@/lib/format"; import { copyToClipboard, generateId } from "@/lib/utils"; import { useFileStore } from "@/stores/file-store"; @@ -100,6 +102,7 @@ function ocrOneFile( } export function OcrSettings() { + const { t } = useTranslation(); const { files, processing, error, setProcessing, setError } = useFileStore(); const [quality, setQuality] = useState("balanced"); @@ -219,7 +222,7 @@ export function OcrSettings() { return (
{/* Quality selector */} - Quality + {t.toolSettings.ocr.quality}
{QUALITY_OPTIONS.map((opt) => ( @@ -290,7 +295,7 @@ export function OcrSettings() { - {files.length > 1 ? `Extract Text (${files.length} files)` : "Extract Text"} + {files.length > 1 + ? format(t.toolSettings.ocr.submitBatch, { count: files.length }) + : t.toolSettings.ocr.submit} )} @@ -311,7 +318,9 @@ export function OcrSettings() { {text !== null && (
- Extracted Text + + {t.toolSettings.ocr.extractedText} +
{text.length > 0 && (
@@ -626,7 +628,7 @@ export function PassportPhotoSettings() { }`} > {"\u2699\uFE0F"} - Custom Dimensions + Custom Dimensions {isCustom && } )} @@ -894,6 +896,7 @@ export function PassportPhotoSettings() { // ── Preview panel (right side) ──────────────────────────────────── export function PassportPhotoPreview() { + const { t } = useTranslation(); const { analyzeResult, countryCode, @@ -1154,7 +1157,7 @@ export function PassportPhotoPreview() { )} - + {pxDims.w}x{pxDims.h}px
diff --git a/apps/web/src/components/tools/pdf-to-image-preview.tsx b/apps/web/src/components/tools/pdf-to-image-preview.tsx index fce2bd5b..88d3e901 100644 --- a/apps/web/src/components/tools/pdf-to-image-preview.tsx +++ b/apps/web/src/components/tools/pdf-to-image-preview.tsx @@ -82,7 +82,7 @@ export function PdfToImagePreview() { {store.results.length} page {store.results.length !== 1 ? "s" : ""} converted {totalSize > 0 && ( - + ({formatSize(totalSize)}) )} @@ -187,7 +187,7 @@ export function PdfToImagePreview() { key={thumb.page} type="button" onClick={() => store.togglePage(thumb.page)} - className={`relative rounded-lg border overflow-hidden text-left transition-all ${ + className={`relative rounded-lg border overflow-hidden text-start transition-all ${ isSelected ? "border-primary ring-1 ring-primary/30" : "border-border opacity-50 hover:opacity-75" diff --git a/apps/web/src/components/tools/pdf-to-image-settings.tsx b/apps/web/src/components/tools/pdf-to-image-settings.tsx index 19674843..356656ce 100644 --- a/apps/web/src/components/tools/pdf-to-image-settings.tsx +++ b/apps/web/src/components/tools/pdf-to-image-settings.tsx @@ -1,5 +1,7 @@ import { Download, FileUp, Loader2, X } from "lucide-react"; import { useCallback, useRef } from "react"; +import { useTranslation } from "@/contexts/i18n-context"; +import { format } from "@/lib/format"; import { usePdfToImageStore } from "@/stores/pdf-to-image-store"; const FORMAT_OPTIONS = [ @@ -37,6 +39,7 @@ const COLOR_MODE_OPTIONS = [ const LOSSY_FORMATS = ["jpg", "webp", "avif", "heic", "heif", "jxl"]; export function PdfToImageSettings() { + const { t } = useTranslation(); const store = usePdfToImageStore(); const fileInputRef = useRef(null); @@ -79,7 +82,7 @@ export function PdfToImageSettings() { className="border-2 border-dashed border-border rounded-lg p-6 text-center cursor-pointer hover:border-primary/50 transition-colors w-full" > -

Drop a PDF here or click to select

+

{t.toolSettings["pdf-to-image"].dropPdf}

-

Output Format

+

+ {t.toolSettings["pdf-to-image"].outputFormat} +

{FORMAT_OPTIONS.map((opt) => ( diff --git a/apps/web/src/components/tools/pipeline-builder.tsx b/apps/web/src/components/tools/pipeline-builder.tsx index 964f770e..d0a98b46 100644 --- a/apps/web/src/components/tools/pipeline-builder.tsx +++ b/apps/web/src/components/tools/pipeline-builder.tsx @@ -16,7 +16,9 @@ import { import { CSS } from "@dnd-kit/utilities"; import { TOOLS } from "@snapotter/shared"; import { FileImage, GripVertical, X } from "lucide-react"; +import { useTranslation } from "@/contexts/i18n-context"; import { ICON_MAP } from "@/lib/icon-map"; +import { getToolName } from "@/lib/tool-i18n"; import { cn } from "@/lib/utils"; import type { PipelineStep } from "@/stores/pipeline-store"; import { PipelineStepSettings } from "./pipeline-step-settings"; @@ -52,6 +54,7 @@ function SortableStep({ onRemove, onUpdateSettings, }: SortableStepProps) { + const { t } = useTranslation(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: step.id, }); @@ -85,7 +88,7 @@ function SortableStep({ role="button" tabIndex={0} onClick={onToggle} - className="flex items-center gap-2 p-3 w-full text-left cursor-pointer" + className="flex items-center gap-2 p-3 w-full text-start cursor-pointer" > {/* Drag handle */} { @@ -108,11 +111,13 @@ function SortableStep({ {/* Tool icon + name */} - {tool.name} + + {getToolName(t, tool.id, tool.name)} + {/* Settings summary when collapsed */} {!isExpanded && summary && ( - {summary} + {summary} )} diff --git a/apps/web/src/components/tools/qr-generate-settings.tsx b/apps/web/src/components/tools/qr-generate-settings.tsx index f3796091..4ec2d70e 100644 --- a/apps/web/src/components/tools/qr-generate-settings.tsx +++ b/apps/web/src/components/tools/qr-generate-settings.tsx @@ -13,6 +13,7 @@ import { import QRCodeStyling from "qr-code-styling"; import { useCallback, useRef } from "react"; import { CollapsibleSection } from "@/components/common/collapsible-section"; +import { useTranslation } from "@/contexts/i18n-context"; import { type ContentType, type CornerDotType, @@ -345,6 +346,7 @@ function PillButton({ // ── Main settings component ────────────────────────────────────────── export function QrGenerateSettings() { + const { t } = useTranslation(); const store = useQrStore(); const logoInputRef = useRef(null); @@ -515,7 +517,7 @@ export function QrGenerateSettings() { {store.dotGradientEnabled && ( -
+