feat: add multi-language support for 20 locales

Add complete i18n infrastructure with 21 supported languages:
English, Simplified Chinese, Traditional Chinese, Japanese, Korean,
Spanish, French, Italian, Brazilian Portuguese, German, Dutch, Swedish,
Russian, Polish, Ukrainian, Arabic (RTL), Turkish, Hindi, Vietnamese,
Indonesian, and Thai.

- I18nProvider context with three-tier locale detection
  (user preference > navigator.languages > instance default > English)
- ~1500 translation keys per locale with TypeScript-enforced completeness
- Dynamic code-splitting: only the active locale is loaded at runtime
- Language selectors in footer, login page, settings, and mobile sidebar
- Arabic RTL support with CSS logical properties across all components
- Tool names, descriptions, and categories translated via i18n helpers
- Public API endpoint GET /api/v1/config/locale for instance default
- Multi-script font stack (CJK, Arabic, Devanagari, Thai, Cyrillic)
- format() and plural() helpers for interpolation and pluralization
- API error translation mapping (translateApiError)
- 36 Playwright e2e tests verifying all 21 locales load correctly
- 25 unit tests for format, plural, locale detection, and completeness
- Updated translations.md docs and CLAUDE.md conventions
This commit is contained in:
SnapOtter
2026-05-15 17:02:49 +08:00
parent 3a82936d93
commit d38621d7b9
141 changed files with 43160 additions and 936 deletions
+162
View File
@@ -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/<code>.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/<toolId>.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/<toolId>-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/<toolId>.ts` -- export a `register<Tool>(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/<toolId>-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<Sharp>`. 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
+4
View File
@@ -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);
+14
View File
@@ -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<void> {
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" });
});
}
+102 -39
View File
@@ -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 (
<div>
<h1>{t.common.settings}</h1>
<p>{format(t.settings.people.deleteConfirm, { username: "admin" })}</p>
<p>{plural(count, t.automate.fileCount, t.automate.fileCountPlural)}</p>
</div>
);
}
```
## 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/<locale>.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 |
+41 -35
View File
@@ -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 (
<div className="flex h-screen items-center justify-center bg-background text-foreground">
<div className="text-center space-y-4 max-w-md px-6">
<h1 className="text-xl font-semibold">Something went wrong</h1>
<h1 className="text-xl font-semibold">{en.common.somethingWentWrong}</h1>
<p className="text-sm text-muted-foreground">
{this.state.error?.message || "An unexpected error occurred."}
{this.state.error?.message || en.common.unexpectedError}
</p>
<button
type="button"
@@ -67,7 +68,7 @@ class ErrorBoundary extends Component<
}}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium"
>
Go Home
{en.common.goHome}
</button>
</div>
</div>
@@ -132,7 +133,7 @@ function AuthGuard({ children }: { children: React.ReactNode }) {
<div className="flex h-screen items-center justify-center bg-background text-foreground">
<div className="text-center space-y-3">
<div className="h-8 w-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto" />
<p className="text-sm text-muted-foreground">Loading...</p>
<p className="text-sm text-muted-foreground">{en.common.loading}</p>
</div>
</div>
);
@@ -205,36 +206,41 @@ export function App() {
return (
<ErrorBoundary>
<ConnectionMonitor />
<Toaster position="bottom-right" />
<BrowserRouter>
<KeyboardShortcutProvider>
<AuthGuard>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/change-password" element={<ChangePasswordPage />} />
<Route path="/automate" element={<AutomatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/fullscreen" element={<FullscreenGridPage />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
{/* Redirects: old color tools consolidated into adjust-colors */}
<Route
path="/brightness-contrast"
element={<Navigate to="/adjust-colors" replace />}
/>
<Route path="/saturation" element={<Navigate to="/adjust-colors" replace />} />
<Route path="/color-channels" element={<Navigate to="/adjust-colors" replace />} />
<Route path="/color-effects" element={<Navigate to="/adjust-colors" replace />} />
<Route path="/analytics-consent" element={<AnalyticsConsentPage />} />
<Route path="/editor" element={<EditorPage />} />
<Route path="/:toolId" element={<ToolPage />} />
<Route path="/" element={<HomePage />} />
</Routes>
</Suspense>
</AuthGuard>
</KeyboardShortcutProvider>
</BrowserRouter>
<I18nProvider>
<ConnectionMonitor />
<Toaster position="bottom-right" />
<BrowserRouter>
<KeyboardShortcutProvider>
<AuthGuard>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/change-password" element={<ChangePasswordPage />} />
<Route path="/automate" element={<AutomatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/fullscreen" element={<FullscreenGridPage />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
{/* Redirects: old color tools consolidated into adjust-colors */}
<Route
path="/brightness-contrast"
element={<Navigate to="/adjust-colors" replace />}
/>
<Route path="/saturation" element={<Navigate to="/adjust-colors" replace />} />
<Route
path="/color-channels"
element={<Navigate to="/adjust-colors" replace />}
/>
<Route path="/color-effects" element={<Navigate to="/adjust-colors" replace />} />
<Route path="/analytics-consent" element={<AnalyticsConsentPage />} />
<Route path="/editor" element={<EditorPage />} />
<Route path="/:toolId" element={<ToolPage />} />
<Route path="/" element={<HomePage />} />
</Routes>
</Suspense>
</AuthGuard>
</KeyboardShortcutProvider>
</BrowserRouter>
</I18nProvider>
</ErrorBoundary>
);
}
@@ -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<HTMLDivElement>(null);
const [position, setPosition] = useState(initialPosition); // percentage 0-100
const [isDragging, setIsDragging] = useState(false);
@@ -160,10 +163,10 @@ export function BeforeAfterSlider({
{/* Labels */}
<div className="absolute top-2 left-2 px-2 py-0.5 rounded bg-black/50 text-white text-xs font-medium pointer-events-none">
Original
{t.comparison.original}
</div>
<div className="absolute top-2 right-2 px-2 py-0.5 rounded bg-black/50 text-white text-xs font-medium pointer-events-none">
Processed
{t.comparison.processed}
</div>
</div>
@@ -176,10 +179,10 @@ export function BeforeAfterSlider({
<span className="px-2 py-1 rounded bg-primary/10 text-primary font-medium">
Processed: {formatSize(afterSize)}
{savingsPercent !== null && Number(savingsPercent) > 0 && (
<span className="ml-1">({savingsPercent}% smaller)</span>
<span className="ms-1">({savingsPercent}% smaller)</span>
)}
{savingsPercent !== null && Number(savingsPercent) < 0 && (
<span className="ml-1">({Math.abs(Number(savingsPercent))}% larger)</span>
<span className="ms-1">({Math.abs(Number(savingsPercent))}% larger)</span>
)}
</span>
</div>
@@ -28,7 +28,7 @@ export function CollapsibleSection({
) : (
<ChevronRight className="h-3 w-3 shrink-0" />
)}
<span className="flex-1 text-left">{title}</span>
<span className="flex-1 text-start">{title}</span>
{warning && <AlertTriangle className="h-3 w-3 text-amber-500 shrink-0" />}
{badge && (
<span className="px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-[10px]">
@@ -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: <Loader2 className="h-4 w-4 animate-spin" />,
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: <WifiOff className="h-4 w-4" />,
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: <CheckCircle2 className="h-4 w-4" />,
message: "Connected",
message: t.errors.connected,
},
}[status];
+11 -11
View File
@@ -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({
</div>
<div className="flex flex-col items-center gap-1.5">
<p className={cn("font-medium", compact ? "text-sm" : "text-base", "text-foreground/80")}>
Drop your images here
</p>
<p className="text-sm text-muted-foreground/70">
click anywhere to browse, or paste from clipboard
{t.dropzone.dropPrompt}
</p>
<p className="text-sm text-muted-foreground/70">{t.dropzone.browseOrPaste}</p>
</div>
<button
type="button"
@@ -254,10 +254,10 @@ export function Dropzone({
)}
>
<Upload className="h-4 w-4" />
Upload
{t.common.upload}
</button>
<p className="text-xs text-muted-foreground/50">
{acceptDescription ?? "PNG, JPG, WebP, HEIC, RAW, PSD, and 65+ formats"}
{acceptDescription ?? t.dropzone.defaultFormats}
</p>
{!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}
</button>
</>
)}
@@ -325,7 +325,7 @@ export function Dropzone({
className="flex items-center justify-between text-xs text-muted-foreground px-2 py-0.5"
>
<span className="truncate">{f.name}</span>
<span className="shrink-0 ml-2">{(f.size / 1024).toFixed(0)} KB</span>
<span className="shrink-0 ms-2">{(f.size / 1024).toFixed(0)} KB</span>
</div>
))}
</div>
@@ -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"
/>
</div>
</div>
@@ -307,7 +307,7 @@ export function ImageViewer({
{/* Info bar */}
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border text-xs text-muted-foreground shrink-0">
<span className="truncate mr-2">{filename}</span>
<span className="truncate me-2">{filename}</span>
<div className="flex items-center gap-3 shrink-0">
{(originalWidth || naturalWidth) != null && (originalHeight || naturalHeight) != null && (
<span>
@@ -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"
>
<span>Review</span>
<span>{t.reviewPanel.reviewHeading}</span>
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
@@ -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"
>
<Undo2 className="h-3.5 w-3.5" />
Undo
{t.reviewPanel.undoButton}
</button>
<button
type="button"
@@ -107,7 +109,7 @@ export function ReviewPanel({
className="flex-1 py-2 rounded-lg bg-primary text-primary-foreground flex items-center justify-center gap-1.5 text-xs font-medium hover:bg-primary/90"
>
<Download className="h-3.5 w-3.5" />
Download
{t.common.download}
</button>
</div>
@@ -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"
>
<PenTool className="h-3.5 w-3.5" />
Open in Editor
{t.reviewPanel.openInEditor}
</Link>
{/* 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"
>
<span>Continue editing</span>
<span>{t.reviewPanel.continueEditing}</span>
{isSuggestionsExpanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
@@ -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"
>
<ToolIcon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 text-left">{tool.name}</span>
<span className="flex-1 text-start">{tool.name}</span>
<ArrowRight className="h-3 w-3 opacity-0 group-hover:opacity-100 shrink-0" />
</button>
);
@@ -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 (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
@@ -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"
/>
</div>
);
@@ -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 */}
<div className="flex-1 flex flex-col items-center gap-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Original
{t.comparison.original}
</span>
<div className="w-full rounded-lg border border-border overflow-hidden flex items-center justify-center bg-muted/30 p-2 min-h-[200px] max-h-[60vh]">
<img
@@ -61,7 +64,7 @@ export function SideBySideComparison({
{/* Processed */}
<div className="flex-1 flex flex-col items-center gap-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Processed
{t.comparison.processed}
</span>
<div className="w-full rounded-lg border border-border overflow-hidden flex items-center justify-center bg-muted/30 p-2 min-h-[200px] max-h-[60vh]">
<img
@@ -92,8 +95,10 @@ export function SideBySideComparison({
className={`text-sm font-medium ${Number(savingsPercent) > 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)),
})}
</p>
)}
</div>
+8 -3
View File
@@ -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) {
<button
type="button"
className="opacity-0 group-hover:opacity-100 transition-opacity absolute -left-5"
title="Add to favourites"
title={t.toolCard.addToFavourites}
>
<Star className="h-3 w-3 text-muted-foreground hover:text-yellow-500" />
</button>
@@ -47,10 +50,12 @@ export function ToolCard({ tool }: ToolCardProps) {
)}
>
<IconComponent className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">{tool.name}</span>
<span className="text-sm font-medium text-foreground">
{getToolName(t, tool.id, tool.name)}
</span>
{tool.experimental && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-600 font-medium">
Experimental
{t.common.experimental}
</span>
)}
{aiStatus === "not_installed" && <Download className="h-3.5 w-3.5 text-muted-foreground" />}
@@ -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 */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border shrink-0">
<Link className="h-5 w-5 text-primary" />
<h2 className="text-sm font-semibold text-foreground flex-1">Import from URLs</h2>
<h2 className="text-sm font-semibold text-foreground flex-1">{t.urlImport.title}</h2>
<button
type="button"
onClick={handleClose}
@@ -130,9 +133,7 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
}
className="w-full min-h-[120px] max-h-[240px] resize-y rounded-lg border border-border bg-muted px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
<p className="text-xs text-muted-foreground">
Supports plain URLs, bulleted lists, numbered lists, and markdown links
</p>
<p className="text-xs text-muted-foreground">{t.urlImport.placeholder}</p>
{/* Progress list */}
{hasResults && (
@@ -167,7 +168,9 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
{/* Footer */}
<div className="flex items-center justify-between px-4 py-3 border-t border-border shrink-0">
<span className="text-xs text-muted-foreground">
{hasResults && !importing ? `${readyCount} of ${entries.length} ready` : ""}
{hasResults && !importing
? format(t.urlImport.readyCount, { ready: readyCount, total: entries.length })
: ""}
</span>
<div className="flex items-center gap-2">
{hasResults && !importing ? (
@@ -188,12 +191,10 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
{adding ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Adding...
{t.urlImport.adding}
</>
) : (
<>
Add {readyCount} Image{readyCount !== 1 ? "s" : ""}
</>
format(t.urlImport.addButton, { count: readyCount })
)}
</button>
</>
@@ -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}
</button>
<button
type="button"
@@ -215,10 +216,10 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
{importing ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Importing...
{t.urlImport.adding}
</>
) : (
"Import"
t.urlImport.importButton
)}
</button>
</>
@@ -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",
)}
@@ -820,7 +820,7 @@ export function AutosaveRecoveryBanner({
<div className="flex items-center gap-3 px-4 py-2 bg-yellow-500/10 border-b border-yellow-500/30 text-xs">
<Save size={14} className="text-yellow-600 shrink-0" />
<span className="text-foreground">Recovered unsaved work from {timeStr}.</span>
<div className="flex items-center gap-2 ml-auto">
<div className="flex items-center gap-2 ms-auto">
<button
type="button"
onClick={onRestore}
@@ -45,7 +45,7 @@ export function SliderRow({
onChange(Math.max(min, Math.min(max, v)));
}
}}
className="w-14 px-1 py-0.5 text-xs text-right bg-muted border border-border rounded text-foreground"
className="w-14 px-1 py-0.5 text-xs text-end bg-muted border border-border rounded text-foreground"
/>
</div>
);
@@ -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() {
}`}
>
<div className="text-center">
<h2 className="text-xl font-semibold text-foreground mb-1">Image Editor</h2>
<p className="text-sm text-muted-foreground">Drop an image here to get started</p>
<h2 className="text-xl font-semibold text-foreground mb-1">
{t.editor.welcome.heading}
</h2>
<p className="text-sm text-muted-foreground">{t.editor.welcome.dropDescription}</p>
</div>
<div className="flex flex-col gap-2 w-full">
@@ -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"
>
<ImagePlus size={20} />
<span className="text-sm font-medium">Open Image</span>
<span className="text-sm font-medium">{t.editor.welcome.openImageButton}</span>
</button>
<button
@@ -90,11 +94,11 @@ export function WelcomeScreen() {
className="flex items-center gap-3 w-full px-4 py-3 bg-muted text-foreground rounded-lg hover:bg-muted/80 transition-colors"
>
<FilePlus size={20} />
<span className="text-sm font-medium">New Document</span>
<span className="text-sm font-medium">{t.editor.welcome.newDocumentButton}</span>
</button>
</div>
<p className="text-xs text-muted-foreground">Or paste from clipboard (Ctrl+V)</p>
<p className="text-xs text-muted-foreground">{t.editor.welcome.pasteHint}</p>
</div>
</section>
@@ -386,11 +386,11 @@ function MenuItemRow({ item, onClose }: { item: MenuItem; onClose: () => void })
data-testid={`menu-item-${toTestId(item.label)}`}
>
<span>{item.label}</span>
<ChevronRight size={12} className="ml-4 text-muted-foreground" />
<ChevronRight size={12} className="ms-4 text-muted-foreground" />
</div>
{submenuOpen && (
<div
className="absolute left-full top-0 ml-0.5 min-w-[180px] bg-card border border-border rounded-md shadow-lg py-1 z-[60]"
className="absolute left-full top-0 ms-0.5 min-w-[180px] bg-card border border-border rounded-md shadow-lg py-1 z-[60]"
role="menu"
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
@@ -410,7 +410,7 @@ function MenuItemRow({ item, onClose }: { item: MenuItem; onClose: () => void })
<button
type="button"
className={cn(
"flex items-center justify-between w-full px-3 py-1 text-xs cursor-default select-none rounded-sm text-left",
"flex items-center justify-between w-full px-3 py-1 text-xs cursor-default select-none rounded-sm text-start",
item.disabled
? "text-muted-foreground/50 pointer-events-none"
: "text-foreground hover:bg-accent hover:text-accent-foreground",
@@ -429,7 +429,7 @@ function MenuItemRow({ item, onClose }: { item: MenuItem; onClose: () => void })
{item.label}
</span>
{item.shortcut && (
<span className="ml-6 text-[10px] text-muted-foreground">{item.shortcut}</span>
<span className="ms-6 text-[10px] text-muted-foreground">{item.shortcut}</span>
)}
</button>
{item.dividerAfter && <div className="my-1 border-t border-border" />}
@@ -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}
@@ -33,7 +33,7 @@ export function EyedropperOptions({
<div className="flex items-center gap-3">
{/* Sample size dropdown */}
<div className="relative">
<span className="text-xs text-muted-foreground mr-1.5">Sample:</span>
<span className="text-xs text-muted-foreground me-1.5">Sample:</span>
<button
type="button"
onClick={() => setOpen(!open)}
@@ -84,7 +84,7 @@ export function EyedropperOptions({
setOpen(false);
}}
className={cn(
"w-full text-left px-3 py-1.5 text-xs transition-colors",
"w-full text-start px-3 py-1.5 text-xs transition-colors",
s.value === sampleSize
? "bg-primary text-primary-foreground"
: "text-foreground hover:bg-muted",
@@ -77,7 +77,7 @@ export function MoveOptions() {
return (
<div className="flex items-center gap-1">
<span className="mr-2 text-xs text-muted-foreground">Align:</span>
<span className="me-2 text-xs text-muted-foreground">Align:</span>
<OptionButton
icon={AlignStartHorizontal}
@@ -121,7 +121,7 @@ export function MoveOptions() {
<div className="mx-1 h-4 w-px bg-border" />
<span className="mr-1 text-xs text-muted-foreground">Distribute:</span>
<span className="me-1 text-xs text-muted-foreground">Distribute:</span>
<OptionButton
icon={ArrowLeftRight}
@@ -86,13 +86,13 @@ export function SelectionOptions() {
{/* Selection type toggle */}
{!isMagicWand && (
<div className="flex items-center gap-1">
<span className="mr-1 text-xs text-muted-foreground">Type:</span>
<span className="me-1 text-xs text-muted-foreground">Type:</span>
<ToggleButton
active={selectionType === "rect" && isMarquee}
onClick={() => handleTypeChange("rect")}
label="Rectangular"
>
<Square className="mr-1 h-3.5 w-3.5" />
<Square className="me-1 h-3.5 w-3.5" />
Rect
</ToggleButton>
<ToggleButton
@@ -100,11 +100,11 @@ export function SelectionOptions() {
onClick={() => handleTypeChange("ellipse")}
label="Elliptical"
>
<Circle className="mr-1 h-3.5 w-3.5" />
<Circle className="me-1 h-3.5 w-3.5" />
Ellipse
</ToggleButton>
<ToggleButton active={isLasso} onClick={() => handleTypeChange("lasso")} label="Lasso">
<PenTool className="mr-1 h-3.5 w-3.5" />
<PenTool className="me-1 h-3.5 w-3.5" />
Lasso
</ToggleButton>
</div>
@@ -121,7 +121,7 @@ export function SelectionOptions() {
{/* Selection mode buttons */}
<div className="flex items-center gap-1">
<span className="mr-1 text-xs text-muted-foreground">Mode:</span>
<span className="me-1 text-xs text-muted-foreground">Mode:</span>
<ToggleButton
active={selectionMode === "new"}
onClick={() => handleModeChange("new")}
@@ -134,7 +134,7 @@ export function SelectionOptions() {
onClick={() => handleModeChange("add")}
label="Add to Selection"
>
<Plus className="mr-0.5 h-3 w-3" />
<Plus className="me-0.5 h-3 w-3" />
Add
</ToggleButton>
<ToggleButton
@@ -142,7 +142,7 @@ export function SelectionOptions() {
onClick={() => handleModeChange("subtract")}
label="Subtract from Selection"
>
<Minus className="mr-0.5 h-3 w-3" />
<Minus className="me-0.5 h-3 w-3" />
Sub
</ToggleButton>
</div>
@@ -191,7 +191,7 @@ function FontDropdown({ value, onChange }: { value: string; onChange: (name: str
key={name}
onClick={() => handleSelect(name)}
className={cn(
"w-full text-left px-3 py-1.5 text-sm hover:bg-muted transition-colors",
"w-full text-start px-3 py-1.5 text-sm hover:bg-muted transition-colors",
value === name && "bg-muted font-medium",
)}
style={{ fontFamily: name }}
@@ -212,7 +212,7 @@ function FontDropdown({ value, onChange }: { value: string; onChange: (name: str
key={name}
onClick={() => handleSelect(name)}
className={cn(
"w-full text-left px-3 py-1.5 text-sm hover:bg-muted transition-colors",
"w-full text-start px-3 py-1.5 text-sm hover:bg-muted transition-colors",
value === name && "bg-muted font-medium",
)}
style={{ fontFamily: name }}
@@ -190,7 +190,7 @@ export function HistoryPanel() {
>
<Redo2 size={14} />
</button>
<span className="ml-auto text-[10px] text-muted-foreground">{pastLength} / 50</span>
<span className="ms-auto text-[10px] text-muted-foreground">{pastLength} / 50</span>
</div>
{/* 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 &&
@@ -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({
<button
type="button"
className={cn(
"block text-xs truncate text-left bg-transparent border-0 p-0 w-full cursor-pointer",
"block text-xs truncate text-start bg-transparent border-0 p-0 w-full cursor-pointer",
isActive ? "text-foreground font-medium" : "text-muted-foreground",
)}
onClick={handleNameClick}
@@ -645,7 +645,7 @@ function LayerContextMenu({
item.action();
}}
className={cn(
"flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left transition-colors",
"flex items-center gap-2 w-full px-3 py-1.5 text-xs text-start transition-colors",
item.disabled
? "text-muted-foreground/40 cursor-not-allowed"
: "text-foreground hover:bg-muted",
@@ -1000,7 +1000,7 @@ function EffectSlider({
onChange={(e) => onChange(Number(e.target.value))}
className="flex-1 min-w-0"
/>
<span className="text-[10px] font-mono text-foreground tabular-nums w-11 text-right shrink-0">
<span className="text-[10px] font-mono text-foreground tabular-nums w-11 text-end shrink-0">
{value}
{suffix}
</span>
@@ -266,7 +266,7 @@ export function NavigatorPanel() {
>
<Plus size={12} />
</button>
<span className="text-[10px] text-muted-foreground w-9 text-right tabular-nums">
<span className="text-[10px] text-muted-foreground w-9 text-end tabular-nums">
{zoomPercent}%
</span>
</div>
@@ -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 (
<div className="flex flex-col items-center justify-center h-full gap-4 text-center px-4">
<Download className="h-16 w-16 text-muted-foreground" />
<h2 className="text-xl font-semibold text-foreground">Feature Not Enabled</h2>
<p className="text-muted-foreground max-w-md">
This feature is not enabled. Ask your administrator to enable it in Settings.
</p>
<h2 className="text-xl font-semibold text-foreground">{t.features.notEnabledTitle}</h2>
<p className="text-muted-foreground max-w-md">{t.features.notEnabledDescription}</p>
</div>
);
}
@@ -112,21 +113,21 @@ export function FeatureInstallPrompt({
<h2 className="text-xl font-semibold text-foreground">{displayName}</h2>
<p className="text-muted-foreground max-w-md">{displayDescription}</p>
<p className="text-sm text-muted-foreground">
This feature requires an additional download (~{bundle.estimatedSize})
{format(t.features.requiresDownload, { size: bundle.estimatedSize })}
</p>
</div>
{error && (
<div className="flex items-center gap-2 bg-destructive/10 text-destructive rounded-lg px-4 py-3 max-w-md w-full">
<AlertCircle className="h-4 w-4 shrink-0" />
<span className="text-sm flex-1 text-left">{error}</span>
<span className="text-sm flex-1 text-start">{error}</span>
<button
type="button"
onClick={handleInstall}
className="flex items-center gap-1 text-sm font-medium hover:opacity-80"
>
<RotateCcw className="h-3.5 w-3.5" />
Retry
{t.features.retryButton}
</button>
</div>
)}
@@ -144,7 +145,7 @@ export function FeatureInstallPrompt({
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
<span className="italic truncate">{PROGRESS_MESSAGES[messageIndex]}</span>
</div>
{eta && <p className="text-xs text-muted-foreground shrink-0 ml-2">{eta}</p>}
{eta && <p className="text-xs text-muted-foreground shrink-0 ms-2">{eta}</p>}
</div>
</div>
)}
@@ -152,7 +153,7 @@ export function FeatureInstallPrompt({
{isQueued && (
<div className="flex items-center gap-2 text-muted-foreground">
<Clock className="h-5 w-5" />
<span className="text-sm font-medium">Queued for installation...</span>
<span className="text-sm font-medium">{t.features.queued}</span>
</div>
)}
@@ -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 })}
</button>
)}
</div>
@@ -195,7 +195,7 @@ function DetailRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between items-start gap-2 px-3 py-2">
<span className="text-xs text-muted-foreground shrink-0">{label}</span>
<span className="text-xs text-foreground text-right break-all">{value}</span>
<span className="text-xs text-foreground text-end break-all">{value}</span>
</div>
);
}
@@ -80,12 +80,12 @@ export function FileListItem({ file }: FileListItemProps) {
</span>
{/* Size */}
<span className="hidden sm:block text-xs text-muted-foreground shrink-0 w-16 text-right">
<span className="hidden sm:block text-xs text-muted-foreground shrink-0 w-16 text-end">
{formatSize(file.size)}
</span>
{/* Date */}
<span className="hidden lg:block text-xs text-muted-foreground shrink-0 w-24 text-right">
<span className="hidden lg:block text-xs text-muted-foreground shrink-0 w-24 text-end">
{formatDate(file.createdAt)}
</span>
</div>
+1 -1
View File
@@ -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"
/>
</div>
</div>
+10 -8
View File
@@ -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) {
<div className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-lg max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border shrink-0">
<h2 className="text-lg font-semibold text-foreground">Help</h2>
<h2 className="text-lg font-semibold text-foreground">{t.help.heading}</h2>
<button
type="button"
onClick={onClose}
@@ -61,7 +63,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
<section className="space-y-2">
<div className="flex items-center gap-2 text-foreground">
<BookOpen className="h-4 w-4" />
<h3 className="text-sm font-semibold">Getting Started</h3>
<h3 className="text-sm font-semibold">{t.help.gettingStarted.heading}</h3>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
Select a tool from the sidebar or search for one with <Kbd keys="mod+k" />. Upload an
@@ -74,7 +76,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
<section className="space-y-3">
<div className="flex items-center gap-2 text-foreground">
<Keyboard className="h-4 w-4" />
<h3 className="text-sm font-semibold">Keyboard Shortcuts</h3>
<h3 className="text-sm font-semibold">{t.help.keyboardShortcuts.heading}</h3>
</div>
<div className="rounded-lg border border-border overflow-hidden">
{SHORTCUTS.map((s, i) => (
@@ -95,7 +97,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
<section className="space-y-2">
<div className="flex items-center gap-2 text-foreground">
<Github className="h-4 w-4" />
<h3 className="text-sm font-semibold">Resources</h3>
<h3 className="text-sm font-semibold">{t.help.resources.heading}</h3>
</div>
<div className="flex flex-col gap-1.5">
<a
@@ -104,7 +106,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm text-primary hover:underline"
>
GitHub Repository
{t.help.resources.githubLink}
<ExternalLink className="h-3 w-3" />
</a>
<a
@@ -113,7 +115,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm text-primary hover:underline"
>
Report an Issue
{t.help.resources.reportIssueLink}
<ExternalLink className="h-3 w-3" />
</a>
<a
@@ -122,7 +124,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm text-primary hover:underline"
>
Documentation
{t.help.resources.docsLink}
<ExternalLink className="h-3 w-3" />
</a>
<a
@@ -131,7 +133,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm text-primary hover:underline"
>
API Reference (Swagger)
{t.help.resources.apiRefLink}
<ExternalLink className="h-3 w-3" />
</a>
</div>
+33 -7
View File
@@ -1,6 +1,15 @@
import { FolderOpen, LayoutGrid, Menu, Settings as SettingsIcon, Workflow, X } from "lucide-react";
import {
FolderOpen,
Globe,
LayoutGrid,
Menu,
Settings as SettingsIcon,
Workflow,
X,
} from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "@/contexts/i18n-context";
import { useMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { useConnectionStore } from "@/stores/connection-store";
@@ -30,6 +39,7 @@ export function AppLayout({
const [settingsOpen, setSettingsOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const { t, locale, setLocale, supportedLocales } = useTranslation();
const isMobile = useMobile();
const connectionStatus = useConnectionStore((s) => s.status);
const bannerVisible = connectionStatus !== "connected";
@@ -85,6 +95,22 @@ export function AppLayout({
onNavClick={() => setMobileSidebarOpen(false)}
expanded
/>
<div className="border-t border-border p-3">
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<Globe className="h-4 w-4" />
<select
value={locale}
onChange={(e) => setLocale(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground border-none outline-none"
>
{supportedLocales.map((l) => (
<option key={l.code} value={l.code}>
{l.nativeName}
</option>
))}
</select>
</label>
</div>
</div>
</>
)}
@@ -122,7 +148,7 @@ export function AppLayout({
{!isMobile && (
<div className="text-center text-xs text-muted-foreground py-2 border-t border-border">
<Link to="/privacy" className="hover:text-foreground transition-colors">
Privacy Policy
{t.common.privacyPolicy}
</Link>
</div>
)}
@@ -133,17 +159,17 @@ export function AppLayout({
{/* Mobile bottom nav */}
{isMobile && (
<nav className="fixed bottom-0 left-0 right-0 z-30 bg-background/95 backdrop-blur-sm border-t border-border flex items-center justify-around px-2 py-1.5">
<MobileNavItem icon={LayoutGrid} label="Tools" href="/" />
<MobileNavItem icon={Workflow} label="Automate" href="/automate" />
<MobileNavItem icon={ImageEditIcon} label="Editor" href="/editor" />
<MobileNavItem icon={FolderOpen} label="Files" href="/files" />
<MobileNavItem icon={LayoutGrid} label={t.appLayout.mobileNavTools} href="/" />
<MobileNavItem icon={Workflow} label={t.appLayout.mobileNavAutomate} href="/automate" />
<MobileNavItem icon={ImageEditIcon} label={t.appLayout.mobileNavEditor} href="/editor" />
<MobileNavItem icon={FolderOpen} label={t.appLayout.mobileNavFiles} href="/files" />
<button
type="button"
onClick={() => setSettingsOpen(true)}
className="flex flex-col items-center gap-0.5 px-3 py-1 text-muted-foreground"
>
<SettingsIcon className="h-5 w-5" />
<span className="text-[10px]">Settings</span>
<span className="text-[10px]">{t.appLayout.mobileNavSettings}</span>
</button>
</nav>
)}
+65 -8
View File
@@ -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<HTMLDivElement>(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 (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-card border border-border hover:bg-muted transition-colors text-sm"
title="Language"
>
<Globe className="h-4 w-4" />
{current?.nativeName ?? "English"}
</button>
{open && (
<div className="absolute bottom-full mb-1 right-0 w-56 max-h-72 overflow-y-auto rounded-lg border border-border bg-card shadow-lg z-50">
{supportedLocales.map((l) => (
<button
key={l.code}
type="button"
onClick={() => {
setLocale(l.code);
setOpen(false);
}}
className="w-full text-start px-3 py-2 text-sm hover:bg-muted flex items-center justify-between transition-colors"
>
<span className={l.code === locale ? "font-medium" : ""}>{l.nativeName}</span>
{l.code === locale && (
<svg
className="h-4 w-4 text-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
role="img"
aria-label="Selected"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</button>
))}
</div>
)}
</div>
);
}
export function Footer() {
const { resolvedTheme, toggleTheme } = useTheme();
@@ -14,14 +78,7 @@ export function Footer() {
>
{resolvedTheme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
<button
type="button"
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-card border border-border hover:bg-muted transition-colors text-sm"
title="Language"
>
<Globe className="h-4 w-4" />
English
</button>
<LanguageSelector />
</div>
);
}
+19 -14
View File
@@ -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({
</div>
);
if (item.label === "Settings") {
if (item === bottomItems[1]) {
return (
<button key={item.label} type="button" onClick={onSettingsClick} className="w-full">
{content}
</button>
);
}
if (item.label === "Help") {
if (item === bottomItems[0]) {
return (
<button
key={item.label}
@@ -1,11 +1,14 @@
import { CATEGORIES, TOOLS } from "@snapotter/shared";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { getCategoryName } from "@/lib/tool-i18n";
import { useFeaturesStore } from "@/stores/features-store";
import { useSettingsStore } from "@/stores/settings-store";
import { SearchBar } from "../common/search-bar";
import { ToolCard } from "../common/tool-card";
export function ToolPanel() {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const { disabledTools, experimentalEnabled, loaded, fetch } = useSettingsStore();
const fetchFeatures = useFeaturesStore((s) => s.fetch);
@@ -54,7 +57,7 @@ export function ToolPanel() {
{CATEGORIES.filter((cat) => groupedTools.has(cat.id)).map((category) => (
<div key={category.id} className="mb-4">
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
{category.name}
{getCategoryName(t, category.id, category.name)}
</h3>
<div className="space-y-0.5">
{groupedTools.get(category.id)?.map((tool) => (
@@ -1,7 +1,9 @@
import type { FeatureBundleState } from "@snapotter/shared";
import { Clock, Download, Loader2, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { apiGet } from "@/lib/api";
import { format } from "@/lib/format";
import { useFeaturesStore } from "@/stores/features-store";
function formatBytes(bytes: number): string {
@@ -52,6 +54,7 @@ const PROGRESS_MESSAGES = [
];
export function AiFeaturesSection() {
const { t } = useTranslation();
const {
bundles,
fetch,
@@ -95,10 +98,8 @@ export function AiFeaturesSection() {
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-foreground">AI Features</h3>
<p className="text-sm text-muted-foreground mt-1">
Manage AI model bundles for advanced image processing.
</p>
<h3 className="text-lg font-semibold text-foreground">{t.settings.aiFeatures.title}</h3>
<p className="text-sm text-muted-foreground mt-1">{t.settings.aiFeatures.description}</p>
</div>
<button
type="button"
@@ -107,7 +108,7 @@ export function AiFeaturesSection() {
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"
>
<Download className="h-4 w-4" />
Install All
{t.settings.aiFeatures.installAll}
</button>
</div>
@@ -130,7 +131,7 @@ export function AiFeaturesSection() {
{diskUsage !== null && (
<p className="text-xs text-muted-foreground pt-2 border-t border-border">
Disk usage: {formatBytes(diskUsage)}
{format(t.settings.aiFeatures.diskUsage, { size: formatBytes(diskUsage) })}
</p>
)}
</div>
@@ -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})
</p>
</div>
<div className="flex items-center gap-3 shrink-0 ml-4">
<div className="flex items-center gap-3 shrink-0 ms-4">
<div className="flex items-center gap-1.5">
{status === "installed" && (
<>
<span className="bg-green-500 rounded-full h-2 w-2" />
<span className="text-xs text-muted-foreground">Installed</span>
<span className="text-xs text-muted-foreground">
{t.settings.aiFeatures.installed}
</span>
</>
)}
{status === "not_installed" && !error && (
<>
<span className="bg-muted-foreground rounded-full h-2 w-2" />
<span className="text-xs text-muted-foreground">Not installed</span>
<span className="text-xs text-muted-foreground">
{t.settings.aiFeatures.notInstalled}
</span>
</>
)}
{status === "queued" && (
<>
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Queued</span>
<span className="text-xs text-muted-foreground">
{t.settings.aiFeatures.queued}
</span>
</>
)}
{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"
>
<Download className="h-3.5 w-3.5" />
Install
{t.settings.aiFeatures.install}
</button>
)}
{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"
>
<RefreshCw className="h-3.5 w-3.5" />
Repair
{t.settings.aiFeatures.repair}
</button>
<button
type="button"
@@ -259,7 +267,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-destructive/10 hover:text-destructive transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
Uninstall
{t.settings.aiFeatures.uninstall}
</button>
</div>
)}
@@ -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"
>
<Trash2 className="h-3.5 w-3.5" />
Confirm
{t.common.confirm}
</button>
<button
type="button"
onClick={() => setConfirming(false)}
className="px-3 py-1.5 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors"
>
Cancel
{t.common.cancel}
</button>
</div>
)}
@@ -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"
>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Installing...
{t.settings.aiFeatures.installing}
</button>
)}
{(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"
>
<RotateCcw className="h-3.5 w-3.5" />
Retry
{t.common.retry}
</button>
)}
</div>
@@ -319,7 +327,7 @@ function BundleCard({
<p className="text-xs text-muted-foreground italic">
{PROGRESS_MESSAGES[messageIndex]}
</p>
{eta && <p className="text-xs text-muted-foreground shrink-0 ml-2">{eta}</p>}
{eta && <p className="text-xs text-muted-foreground shrink-0 ms-2">{eta}</p>}
</div>
</div>
)}
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,9 @@
import { Download } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type Tier = "fast" | "balanced" | "high";
@@ -22,6 +24,7 @@ const EXTEND_PRESETS = [
];
export function AiCanvasExpandSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("ai-canvas-expand");
@@ -211,7 +214,7 @@ export function AiCanvasExpandSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Extending canvas"
label={t.toolSettings["ai-canvas-expand"].progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
@@ -223,7 +226,9 @@ export function AiCanvasExpandSettings() {
disabled={!hasFile || !hasExtension || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Extend (${files.length} files)` : "Extend Canvas"}
{files.length > 1
? format(t.toolSettings["ai-canvas-expand"].submitBatch, { count: files.length })
: t.toolSettings["ai-canvas-expand"].submit}
</button>
)}
@@ -1,7 +1,9 @@
import { Check, Copy, Download, Search } 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 } from "@/lib/utils";
import { useFileStore } from "@/stores/file-store";
@@ -106,6 +108,7 @@ function scanOneFile(
}
export function BarcodeReadSettings() {
const { t } = useTranslation();
const { files, processing, error, setProcessing, setError } = useFileStore();
const [tryHarder, setTryHarder] = useState(false);
@@ -858,7 +858,7 @@ export function BeautifyControls({
<button
type="button"
onClick={() => handleRemoveStop(i)}
className="ml-auto p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
className="ms-auto p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
@@ -1,6 +1,7 @@
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 { useFileStore } from "@/stores/file-store";
@@ -10,6 +11,7 @@ export interface BlurFacesControlsProps {
}
export function BlurFacesControls({ settings: initialSettings, onChange }: BlurFacesControlsProps) {
const { t } = useTranslation();
const [blurRadius, setBlurRadius] = useState(30);
const [sensitivity, setSensitivity] = useState(50);
@@ -37,7 +39,7 @@ export function BlurFacesControls({ settings: initialSettings, onChange }: BlurF
<div>
<div className="flex justify-between items-center">
<label htmlFor="blur-faces-blur-radius" className="text-xs text-muted-foreground">
Blur Radius
{t.toolSettings["blur-faces"].blurRadius}
</label>
<span className="text-xs font-mono text-foreground">{blurRadius}</span>
</div>
@@ -51,8 +53,8 @@ export function BlurFacesControls({ settings: initialSettings, onChange }: BlurF
className="w-full mt-1"
/>
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span>Light</span>
<span>Heavy</span>
<span>{t.toolSettings["blur-faces"].blurLight}</span>
<span>{t.toolSettings["blur-faces"].blurHeavy}</span>
</div>
</div>
@@ -60,7 +62,7 @@ export function BlurFacesControls({ settings: initialSettings, onChange }: BlurF
<div>
<div className="flex justify-between items-center">
<label htmlFor="blur-faces-sensitivity" className="text-xs text-muted-foreground">
Detection Sensitivity
{t.toolSettings["blur-faces"].detectionSensitivity}
</label>
<span className="text-xs font-mono text-foreground">{sensitivity}%</span>
</div>
@@ -74,8 +76,8 @@ export function BlurFacesControls({ settings: initialSettings, onChange }: BlurF
className="w-full mt-1"
/>
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span>More faces</span>
<span>Fewer faces</span>
<span>{t.toolSettings["blur-faces"].moreFaces}</span>
<span>{t.toolSettings["blur-faces"].fewerFaces}</span>
</div>
</div>
</div>
@@ -83,6 +85,7 @@ export function BlurFacesControls({ settings: initialSettings, onChange }: BlurF
}
export function BlurFacesSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -128,7 +131,7 @@ export function BlurFacesSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Blurring faces"
label={t.toolSettings["blur-faces"].progressLabel}
percent={progress.percent}
elapsed={progress.elapsed}
/>
@@ -153,7 +156,7 @@ export function BlurFacesSettings() {
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
{t.common.download}
</a>
)}
</div>
@@ -2,7 +2,9 @@ import { Download } from "lucide-react";
import type React from "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";
// ── Presets ──────────────────────────────────────────────────────────
@@ -225,6 +227,7 @@ export function BorderControls({
onChange,
onImageStyle,
}: BorderControlsProps) {
const { t } = useTranslation();
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
const [borderWidth, setBorderWidth] = useState(10);
const [borderColor, setBorderColor] = useState("#000000");
@@ -1,8 +1,11 @@
import { Download, 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 { useFileStore } from "@/stores/file-store";
export function BulkRenameSettings() {
const { t } = useTranslation();
const { files, processing, error, setProcessing, setError } = useFileStore();
const [pattern, setPattern] = useState("image-{{index}}");
const [startIndex, setStartIndex] = useState(1);
@@ -69,7 +72,7 @@ export function BulkRenameSettings() {
<div className="space-y-4">
<div>
<label htmlFor="bulk-rename-pattern" className="text-xs text-muted-foreground">
Pattern
{t.toolSettings["bulk-rename"].pattern}
</label>
<input
id="bulk-rename-pattern"
@@ -85,7 +88,7 @@ export function BulkRenameSettings() {
<div>
<label htmlFor="bulk-rename-start-index" className="text-xs text-muted-foreground">
Start Index
{t.toolSettings["bulk-rename"].startIndex}
</label>
<input
id="bulk-rename-start-index"
@@ -99,7 +102,7 @@ export function BulkRenameSettings() {
{previewNames.length > 0 && (
<div>
<p className="text-xs text-muted-foreground">Preview</p>
<p className="text-xs text-muted-foreground">{t.toolSettings["bulk-rename"].preview}</p>
<div className="mt-1 space-y-0.5">
{previewNames.map((name) => (
<div
@@ -126,12 +129,14 @@ export function BulkRenameSettings() {
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Renaming..." : `Rename ${files.length} Files`}
{processing
? t.toolSettings["bulk-rename"].renaming
: format(t.toolSettings["bulk-rename"].submit, { count: files.length })}
</button>
{downloadReady && (
<p className="text-xs text-green-600 flex items-center gap-1">
<Download className="h-3 w-3" /> ZIP downloaded successfully
<Download className="h-3 w-3" /> {t.toolSettings["bulk-rename"].zipDownloaded}
</p>
)}
</div>
@@ -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"
/>
<span className="text-white text-xs font-mono w-8 text-right shrink-0">
<span className="text-white text-xs font-mono w-8 text-end shrink-0">
{transform.zoom.toFixed(1)}x
</span>
<button
@@ -1,6 +1,7 @@
import { Download, Loader2 } from "lucide-react";
import { useCallback } from "react";
import { CollapsibleSection } from "@/components/common/collapsible-section";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import {
COLLAGE_TEMPLATES,
@@ -37,6 +38,7 @@ const BG_PRESETS = [
];
export function CollageSettings() {
const { t } = useTranslation();
const store = useCollageStore();
const {
images,
@@ -1,7 +1,9 @@
import { Download } from "lucide-react";
import { 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 SIMULATION_TYPES = [
@@ -65,6 +67,7 @@ const SIMULATION_TYPES = [
const TYPE_MAP = new Map(SIMULATION_TYPES.flatMap((g) => g.types.map((t) => [t.value, t])));
export function ColorBlindnessSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -95,7 +98,7 @@ export function ColorBlindnessSettings() {
<div className="space-y-4">
<div>
<label htmlFor="cb-simulation-type" className="text-xs text-muted-foreground">
Simulation Type
{t.toolSettings["color-blindness"].simulationType}
</label>
<select
id="cb-simulation-type"
@@ -131,7 +134,7 @@ export function ColorBlindnessSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Simulating color blindness"
label={t.toolSettings["color-blindness"].progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
@@ -144,7 +147,9 @@ export function ColorBlindnessSettings() {
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Simulate (${files.length} files)` : "Simulate"}
{files.length > 1
? format(t.toolSettings["color-blindness"].submitBatch, { count: files.length })
: t.toolSettings["color-blindness"].submit}
</button>
)}
@@ -156,7 +161,7 @@ export function ColorBlindnessSettings() {
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
{t.common.download}
</a>
)}
</div>
@@ -1,9 +1,11 @@
import { Check, Copy, Loader2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { copyToClipboard } from "@/lib/utils";
import { useFileStore } from "@/stores/file-store";
export function ColorPaletteSettings() {
const { t } = useTranslation();
const { files, processing, error, setProcessing, setError } = useFileStore();
const [colors, setColors] = useState<string[]>([]);
const [copiedIdx, setCopiedIdx] = useState<number | null>(null);
@@ -81,7 +83,7 @@ export function ColorPaletteSettings() {
className="w-6 h-6 rounded border border-border shrink-0"
style={{ backgroundColor: color }}
/>
<span className="text-xs font-mono text-foreground flex-1 text-left">{color}</span>
<span className="text-xs font-mono text-foreground flex-1 text-start">{color}</span>
{copiedIdx === i ? (
<Check className="h-3 w-3 text-green-500 shrink-0" />
) : (
@@ -275,10 +275,10 @@ export function ColorControls({
>
{channelsOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
Color Channels
{hasChannelChanges && <span className="ml-auto text-primary text-[10px]">modified</span>}
{hasChannelChanges && <span className="ms-auto text-primary text-[10px]">modified</span>}
</button>
{channelsOpen && (
<div className="space-y-2 pl-1">
<div className="space-y-2 ps-1">
<SliderControl
label="Red"
value={red}
@@ -457,11 +457,9 @@ function SliderControl({
<div className="flex justify-between items-center">
<label htmlFor={id} className={`text-xs ${color || "text-muted-foreground"}`}>
{label}
{hint && <span className="text-[10px] text-muted-foreground/60 ml-1">({hint})</span>}
{hint && <span className="text-[10px] text-muted-foreground/60 ms-1">({hint})</span>}
</label>
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-right">
{value}
</span>
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-end">{value}</span>
</div>
<input
id={id}
@@ -1,7 +1,9 @@
import { Download } from "lucide-react";
import { 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 Model = "auto" | "ddcolor" | "opencv";
@@ -13,6 +15,7 @@ const MODEL_OPTIONS: { value: Model; label: string; desc: string }[] = [
];
export function ColorizeSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -83,7 +86,7 @@ export function ColorizeSettings() {
? "Natural"
: "Vivid"}
</span>
<span className="text-xs font-mono text-foreground tabular-nums w-10 text-right">
<span className="text-xs font-mono text-foreground tabular-nums w-10 text-end">
{intensity}%
</span>
</div>
@@ -126,7 +129,9 @@ export function ColorizeSettings() {
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 ? `Colorize (${files.length} files)` : "Colorize"}
{hasMultiple
? format(t.toolSettings.colorize.submitBatch, { count: files.length })
: t.toolSettings.colorize.submit}
</button>
)}
@@ -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<File | null>(null);
const [similarity, setSimilarity] = useState<number | null>(null);
@@ -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<File | null>(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 && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Processing..." : "Compose"}
{processing ? "Processing..." : t.toolSettings.compose.submit}
</button>
{downloadUrl && (
@@ -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<CompressMode>("targetSize");
const [quality, setQuality] = useState(75);
const [targetSizeValue, setTargetSizeValue] = useState("");
@@ -47,21 +50,23 @@ export function CompressControls({ settings: initialSettings, onChange }: Compre
<div className="space-y-4">
{/* Mode toggle */}
<div>
<p className="text-sm font-medium text-muted-foreground">Compression Mode</p>
<p className="text-sm font-medium text-muted-foreground">
{t.toolSettings.compress.compressionMode}
</p>
<div className="flex gap-1 mt-1">
<button
type="button"
onClick={() => setMode("targetSize")}
className={`flex-1 text-xs py-1.5 rounded ${mode === "targetSize" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Target Size
{t.toolSettings.compress.targetSize}
</button>
<button
type="button"
onClick={() => setMode("quality")}
className={`flex-1 text-xs py-1.5 rounded ${mode === "quality" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Quality
{t.toolSettings.compress.quality}
</button>
</div>
</div>
@@ -69,7 +74,7 @@ export function CompressControls({ settings: initialSettings, onChange }: Compre
{mode === "targetSize" ? (
<div>
<label htmlFor="compress-target-size" className="text-xs text-muted-foreground">
Target Size
{t.toolSettings.compress.targetSize}
</label>
<div className="flex gap-1.5 mt-0.5">
<input
@@ -127,8 +132,8 @@ export function CompressControls({ settings: initialSettings, onChange }: Compre
</button>
</div>
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span>Smallest file</span>
<span>Best quality</span>
<span>{t.toolSettings.compress.smallestFile}</span>
<span>{t.toolSettings.compress.bestQuality}</span>
</div>
</div>
)}
@@ -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 && (
<div className="text-xs text-muted-foreground space-y-0.5">
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
<p>
{format(t.toolSettings.compress.original, { size: (originalSize / 1024).toFixed(1) })}
</p>
<p>
{format(t.toolSettings.compress.processed, { size: (processedSize / 1024).toFixed(1) })}
</p>
<p className="font-medium text-foreground">
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",
})}
</p>
</div>
)}
@@ -191,7 +204,7 @@ export function CompressSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Compressing"
label={t.toolSettings.compress.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
@@ -203,7 +216,9 @@ export function CompressSettings() {
disabled={!hasFile || !canProcess || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Compress (${files.length} files)` : "Compress"}
{files.length > 1
? format(t.toolSettings.compress.submitBatch, { count: files.length })
: t.toolSettings.compress.submit}
</button>
)}
@@ -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 className="h-4 w-4" />
Download
{t.common.download}
</a>
)}
</form>
@@ -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() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Content-aware resizing"
label={t.toolSettings["content-aware-resize"].progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
@@ -212,7 +215,9 @@ export function ContentAwareResizeSettings() {
disabled={!canProcess}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Resize (${files.length} files)` : "Resize"}
{files.length > 1
? format(t.toolSettings["content-aware-resize"].submitBatch, { count: files.length })
: t.toolSettings["content-aware-resize"].submit}
</button>
)}
@@ -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<string>("png");
const [quality, setQuality] = useState(85);
@@ -74,7 +77,7 @@ export function ConvertControls({ settings: initialSettings, onChange }: Convert
{/* Target format */}
<div>
<label htmlFor="convert-target-format" className="text-xs text-muted-foreground">
Target Format
{t.toolSettings.convert.targetFormat}
</label>
<select
id="convert-target-format"
@@ -115,6 +118,7 @@ export function ConvertControls({ settings: initialSettings, onChange }: Convert
}
export function ConvertSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -154,7 +158,7 @@ export function ConvertSettings() {
{/* Source format */}
{hasFile && (
<div>
<p className="text-xs text-muted-foreground">Source Format</p>
<p className="text-xs text-muted-foreground">{t.toolSettings.convert.sourceFormat}</p>
<div className="mt-0.5 px-2 py-1.5 rounded bg-muted text-sm text-foreground uppercase font-mono">
{sourceExt}
</div>
@@ -179,7 +183,7 @@ export function ConvertSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Converting"
label={t.toolSettings.convert.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
@@ -191,7 +195,9 @@ export function ConvertSettings() {
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Convert (${files.length} files)` : "Convert"}
{files.length > 1
? format(t.toolSettings.convert.submitBatch, { count: files.length })
: t.toolSettings.convert.submit}
</button>
)}
@@ -204,7 +210,7 @@ export function ConvertSettings() {
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
{t.common.download}
</a>
)}
</form>
@@ -2,7 +2,9 @@ import { ArrowLeftRight, Download, Grid3x3 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { Crop } from "react-image-crop";
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 ASPECT_PRESETS = [
@@ -34,6 +36,7 @@ export function CropSettings({
onAspectChange,
onGridToggle,
}: CropSettingsProps) {
const { t } = useTranslation();
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("crop");
@@ -216,7 +219,7 @@ export function CropSettings({
{/* Aspect Ratio */}
<div>
<div className="flex items-center justify-between mb-1">
<p className="text-xs text-muted-foreground">Aspect Ratio</p>
<p className="text-xs text-muted-foreground">{t.toolSettings.crop.aspectRatio}</p>
{aspect !== undefined && (
<button
type="button"
@@ -288,7 +291,7 @@ export function CropSettings({
{/* Position & Size */}
<div>
<p className="text-xs text-muted-foreground">Position & Size</p>
<p className="text-xs text-muted-foreground">{t.toolSettings.crop.positionAndSize}</p>
<div className="grid grid-cols-2 gap-2 mt-1">
<div>
<label htmlFor="crop-x" className="text-[10px] text-muted-foreground">
@@ -358,7 +361,7 @@ export function CropSettings({
className="accent-primary h-3.5 w-3.5"
/>
<Grid3x3 className="h-3.5 w-3.5" />
Rule of Thirds
{t.toolSettings.crop.ruleOfThirds}
</label>
{/* Error */}
@@ -369,7 +372,7 @@ export function CropSettings({
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Cropping"
label={t.toolSettings.crop.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
@@ -381,7 +384,9 @@ export function CropSettings({
disabled={!canSubmit}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Crop (${files.length} files)` : "Crop"}
{files.length > 1
? format(t.toolSettings.crop.submitBatch, { count: files.length })
: t.toolSettings.crop.submit}
</button>
)}
@@ -394,7 +399,7 @@ export function CropSettings({
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
{t.common.download}
</a>
)}
</form>
@@ -409,6 +414,7 @@ export interface CropControlsProps {
}
export function CropControls({ settings: initialSettings, onChange }: CropControlsProps) {
const { t } = useTranslation();
const [left, setLeft] = useState(0);
const [top, setTop] = useState(0);
const [width, setWidth] = useState("");
@@ -443,7 +449,7 @@ export function CropControls({ settings: initialSettings, onChange }: CropContro
<div className="grid grid-cols-2 gap-2">
<div>
<label htmlFor="pipeline-crop-left" className="text-xs text-muted-foreground">
Left offset (px)
{t.toolSettings.crop.leftOffsetPx}
</label>
<input
id="pipeline-crop-left"
@@ -456,7 +462,7 @@ export function CropControls({ settings: initialSettings, onChange }: CropContro
</div>
<div>
<label htmlFor="pipeline-crop-top" className="text-xs text-muted-foreground">
Top offset (px)
{t.toolSettings.crop.topOffsetPx}
</label>
<input
id="pipeline-crop-top"
@@ -776,7 +776,7 @@ export function EditMetadataSettings() {
<button
type="button"
onClick={() => 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}
</button>
@@ -110,7 +110,7 @@ export function EnhanceFacesControls({
/>
<span className="text-sm text-foreground">Only enhance main face</span>
</label>
<p className="text-[11px] text-muted-foreground/70 ml-6 mt-0.5">
<p className="text-[11px] text-muted-foreground/70 ms-6 mt-0.5">
For portraits - ignores background faces
</p>
</div>
@@ -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({
<div>
<div className="flex justify-between items-center">
<label htmlFor="eraser-brush-size" className="text-xs text-muted-foreground">
Brush Size
{t.toolSettings["erase-object"].brushSize}
</label>
<span className="text-xs font-mono text-foreground">{brushSize}px</span>
</div>
@@ -400,8 +403,8 @@ export function EraseObjectSettings({
className="w-full mt-1"
/>
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span>Fine</span>
<span>Wide</span>
<span>{t.toolSettings["erase-object"].fine}</span>
<span>{t.toolSettings["erase-object"].wide}</span>
</div>
</div>
@@ -493,7 +496,7 @@ export function EraseObjectSettings({
<ProgressCard
active={processing}
phase={progressPhase === "idle" ? "uploading" : progressPhase}
label={progressStage || "Erasing object"}
label={progressStage || t.toolSettings["erase-object"].progressLabel}
percent={progressPercent}
elapsed={elapsed}
/>
@@ -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}
</button>
)}
@@ -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<string | null>(null);
const [busy, setBusy] = useState(false);
@@ -128,13 +131,14 @@ export function FaviconSettings() {
return (
<div className="space-y-4">
<p className="text-xs text-muted-foreground">
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 })}
</p>
<div>
<p className="text-xs font-medium text-muted-foreground">Generated Sizes (per image)</p>
<p className="text-xs font-medium text-muted-foreground">
{t.toolSettings.favicon.generatedSizes}
</p>
<div className="mt-1 space-y-0.5">
{SIZES.map((s) => (
<div key={s.name} className="flex justify-between text-xs text-foreground">
@@ -143,7 +147,9 @@ export function FaviconSettings() {
</div>
))}
</div>
<p className="text-[10px] text-muted-foreground mt-1">+ manifest.json + HTML snippet</p>
<p className="text-[10px] text-muted-foreground mt-1">
{t.toolSettings.favicon.plusManifest}
</p>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
@@ -152,7 +158,7 @@ export function FaviconSettings() {
<ProgressCard
active={busy}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Generating Favicons"
label={t.toolSettings.favicon.progressLabel}
stage={
progress.phase === "uploading"
? "Uploading images..."
@@ -169,7 +175,9 @@ export function FaviconSettings() {
disabled={!hasFiles || busy}
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"
>
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 })}
</button>
)}
@@ -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"
>
<div className="flex justify-between items-center mb-2.5">
<div className="flex items-center gap-2">
@@ -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"}
>
<div
@@ -219,18 +220,16 @@ function DetailComparison() {
<p className="font-medium text-foreground truncate">{file.filename}</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<span className="text-muted-foreground">Dimensions</span>
<span className="text-foreground text-right">
<span className="text-foreground text-end">
{file.width} x {file.height}
</span>
<span className="text-muted-foreground">File size</span>
<span className="text-foreground text-right">
{formatFileSize(file.fileSize)}
</span>
<span className="text-foreground text-end">{formatFileSize(file.fileSize)}</span>
<span className="text-muted-foreground">Format</span>
<span className="text-foreground text-right">{file.format.toUpperCase()}</span>
<span className="text-foreground text-end">{file.format.toUpperCase()}</span>
<span className="text-muted-foreground">Similarity</span>
<span
className={`text-right font-medium ${file.similarity === 100 ? "text-green-500" : "text-yellow-500"}`}
className={`text-end font-medium ${file.similarity === 100 ? "text-green-500" : "text-yellow-500"}`}
>
{file.similarity}%
</span>
@@ -252,6 +251,7 @@ function DetailComparison() {
}
export function FindDuplicatesResults() {
const { t } = useTranslation();
const { results, scanning, viewMode } = useDuplicateStore();
if (scanning) {
@@ -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<Preset, string> = {
};
export function FindDuplicatesSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
results,
@@ -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() {
<p>
Processed: {(processedSize / 1024).toFixed(1)} KB
{originalSize > 0 && (
<span className="ml-1">
<span className="ms-1">
({Math.round(((processedSize - originalSize) / originalSize) * 100)}%)
</span>
)}
@@ -690,7 +694,7 @@ export function GifToolsSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Processing GIF"
label={t.toolSettings["gif-tools"].progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
@@ -703,7 +707,9 @@ export function GifToolsSettings() {
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Process (${files.length} files)` : "Process"}
{files.length > 1
? format(t.toolSettings["gif-tools"].submitBatch, { count: files.length })
: "Process"}
</button>
)}
@@ -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<EnhancementMode>("auto");
const [intensity, setIntensity] = useState(50);
@@ -283,7 +286,7 @@ export function ImageEnhancementControls({
{/* Mode selector */}
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
Enhancement Mode
{t.toolSettings.imageEnhancement.enhancementMode}
</p>
<div className="grid grid-cols-3 gap-1">
{MODES.map(({ value, label, icon: Icon }) => (
@@ -307,7 +310,7 @@ export function ImageEnhancementControls({
<div className="pt-1">
<div className="flex justify-between items-center">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
Intensity
{t.toolSettings.imageEnhancement.intensity}
</p>
<span className="text-xs font-mono text-foreground tabular-nums">{intensity}%</span>
</div>
@@ -326,7 +329,7 @@ export function ImageEnhancementControls({
<div className="flex items-center gap-2">
<Wand2 className="h-3.5 w-3.5 text-muted-foreground" />
<div>
<p className="text-xs font-medium">Deep Enhance (AI)</p>
<p className="text-xs font-medium">{t.toolSettings.imageEnhancement.deepEnhance}</p>
<p className="text-[10px] text-muted-foreground">
Removes noise and artifacts using AI
</p>
@@ -358,7 +361,7 @@ export function ImageEnhancementControls({
{analysis && !analyzing && (
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
Detected Issues
{t.toolSettings.imageEnhancement.detectedIssues}
</p>
{analysis.issues.length === 0 ? (
<p className="text-xs text-muted-foreground">
@@ -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();
@@ -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();
@@ -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<ImageInfoData | null>(null);
@@ -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")}
/>
</div>
@@ -183,7 +183,7 @@ function TemplateGallery() {
)}
>
{cat.label}
<span className="ml-1 opacity-70">({categoryCounts[cat.id] ?? 0})</span>
<span className="ms-1 opacity-70">({categoryCounts[cat.id] ?? 0})</span>
</button>
))}
</div>
@@ -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",
@@ -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 <GallerySettings />;
@@ -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}
</button>
)}
+18 -7
View File
@@ -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<OcrQuality>("balanced");
@@ -219,7 +222,7 @@ export function OcrSettings() {
return (
<div className="space-y-3">
{/* Quality selector */}
<SectionLabel>Quality</SectionLabel>
<SectionLabel>{t.toolSettings.ocr.quality}</SectionLabel>
<div className="grid grid-cols-3 gap-1.5">
{QUALITY_OPTIONS.map((opt) => (
<button
@@ -245,7 +248,9 @@ export function OcrSettings() {
onChange={(e) => handleEnhanceToggle(e.target.checked)}
className="rounded border-border accent-primary"
/>
<span className="text-sm text-muted-foreground">Enhance before scanning</span>
<span className="text-sm text-muted-foreground">
{t.toolSettings.ocr.enhanceBeforeScanning}
</span>
<span
title="Automatically deskews, enhances contrast, removes noise, and upscales the image before scanning for better accuracy."
className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-muted-foreground/40 text-muted-foreground/60 text-[10px] cursor-help"
@@ -263,7 +268,7 @@ export function OcrSettings() {
>
{langOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
Language
<span className="ml-auto text-primary text-[10px] normal-case font-normal">
<span className="ms-auto text-primary text-[10px] normal-case font-normal">
{langLabel}
</span>
</button>
@@ -290,7 +295,7 @@ export function OcrSettings() {
<ProgressCard
active={processing}
phase={progressPhase === "idle" ? "uploading" : progressPhase}
label="Extracting text"
label={t.toolSettings.ocr.progressLabel}
stage={progressStage}
percent={progressPercent}
elapsed={elapsed}
@@ -303,7 +308,9 @@ export function OcrSettings() {
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Extract Text (${files.length} files)` : "Extract Text"}
{files.length > 1
? format(t.toolSettings.ocr.submitBatch, { count: files.length })
: t.toolSettings.ocr.submit}
</button>
)}
@@ -311,7 +318,9 @@ export function OcrSettings() {
{text !== null && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Extracted Text</span>
<span className="text-xs font-medium text-muted-foreground">
{t.toolSettings.ocr.extractedText}
</span>
<div className="flex items-center gap-3">
{text.length > 0 && (
<button
@@ -342,7 +351,9 @@ export function OcrSettings() {
rows={Math.min(16, Math.max(8, text.split("\n").length + 2))}
className="w-full px-2 py-1.5 rounded border border-border bg-muted text-xs text-foreground font-mono resize-y"
/>
<p className="text-[10px] text-muted-foreground">{text.length} characters</p>
<p className="text-[10px] text-muted-foreground">
{format(t.toolSettings.ocr.characters, { count: text.length })}
</p>
</>
) : (
<p className="text-xs text-muted-foreground italic py-4 text-center">
@@ -330,7 +330,7 @@ export function OptimizeForWebSettings() {
{preview.processedSize != null && (
<div className="text-xs text-muted-foreground">
Optimized: {formatSize(preview.processedSize)}
<span className="ml-1 font-medium uppercase text-[10px]">
<span className="ms-1 font-medium uppercase text-[10px]">
{FORMAT_LABELS[format]}
</span>
</div>
@@ -20,6 +20,7 @@ import {
import { useCallback, useEffect, useRef, useState } from "react";
import { create } from "zustand";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { formatHeaders } from "@/lib/api";
import { useFileStore } from "@/stores/file-store";
@@ -324,7 +325,7 @@ function CountryOption({
}`}
>
<span>{spec.flag}</span>
<span className="flex-1 text-left">{spec.name}</span>
<span className="flex-1 text-start">{spec.name}</span>
<span className="text-muted-foreground/60 tabular-nums text-[10px]">
{formatDimensions(doc)}
</span>
@@ -336,6 +337,7 @@ function CountryOption({
// ── Settings panel (left side) ─────────────────────────────────────
export function PassportPhotoSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { error } = useToolProcessor("passport-photo");
@@ -574,9 +576,9 @@ export function PassportPhotoSettings() {
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground hover:border-primary/50 transition-colors"
>
<span>{selectedSpec.flag}</span>
<span className="flex-1 text-left truncate">
<span className="flex-1 text-start truncate">
{selectedSpec.name}
<span className="text-muted-foreground ml-1.5 text-xs">
<span className="text-muted-foreground ms-1.5 text-xs">
{formatDimensions(docSpec)}
</span>
</span>
@@ -605,7 +607,7 @@ export function PassportPhotoSettings() {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search countries..."
className="w-full pl-7 pr-2 py-1.5 rounded border border-border bg-background text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary"
className="w-full ps-7 pe-2 py-1.5 rounded border border-border bg-background text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary"
/>
</div>
</div>
@@ -626,7 +628,7 @@ export function PassportPhotoSettings() {
}`}
>
<span>{"\u2699\uFE0F"}</span>
<span className="flex-1 text-left">Custom Dimensions</span>
<span className="flex-1 text-start">Custom Dimensions</span>
{isCustom && <Check className="h-3 w-3 text-primary shrink-0" />}
</button>
)}
@@ -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() {
<RotateCcw className="h-3.5 w-3.5" />
</button>
)}
<span className="text-[10px] text-muted-foreground ml-auto">
<span className="text-[10px] text-muted-foreground ms-auto">
{pxDims.w}x{pxDims.h}px
</span>
</div>
@@ -82,7 +82,7 @@ export function PdfToImagePreview() {
{store.results.length} page
{store.results.length !== 1 ? "s" : ""} converted
{totalSize > 0 && (
<span className="text-muted-foreground font-normal ml-1">
<span className="text-muted-foreground font-normal ms-1">
({formatSize(totalSize)})
</span>
)}
@@ -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"
@@ -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<HTMLInputElement>(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"
>
<FileUp className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Drop a PDF here or click to select</p>
<p className="text-sm text-muted-foreground">{t.toolSettings["pdf-to-image"].dropPdf}</p>
<input
ref={fileInputRef}
type="file"
@@ -115,7 +118,9 @@ export function PdfToImageSettings() {
{/* Output Format - grid buttons */}
<div>
<p className="text-xs text-muted-foreground mb-1.5">Output Format</p>
<p className="text-xs text-muted-foreground mb-1.5">
{t.toolSettings["pdf-to-image"].outputFormat}
</p>
<div className="grid grid-cols-4 gap-1">
{FORMAT_OPTIONS.map((opt) => (
<button
@@ -138,7 +143,9 @@ export function PdfToImageSettings() {
{isLossy && (
<div>
<div className="flex justify-between items-center">
<p className="text-xs text-muted-foreground">Quality</p>
<p className="text-xs text-muted-foreground">
{t.toolSettings["pdf-to-image"].quality}
</p>
<span className="text-xs font-mono text-foreground">{store.quality}</span>
</div>
<input
@@ -154,7 +161,9 @@ export function PdfToImageSettings() {
{/* DPI presets + custom */}
<div>
<p className="text-xs text-muted-foreground mb-1.5">Resolution (DPI)</p>
<p className="text-xs text-muted-foreground mb-1.5">
{t.toolSettings["pdf-to-image"].resolutionDpi}
</p>
<div className="grid grid-cols-5 gap-1">
{DPI_PRESETS.map((opt) => (
<button
@@ -204,7 +213,9 @@ export function PdfToImageSettings() {
{/* Color Mode */}
<div>
<p className="text-xs text-muted-foreground mb-1.5">Color Mode</p>
<p className="text-xs text-muted-foreground mb-1.5">
{t.toolSettings["pdf-to-image"].colorMode}
</p>
<div className="grid grid-cols-3 gap-1">
{COLOR_MODE_OPTIONS.map((opt) => (
<button
@@ -226,7 +237,7 @@ export function PdfToImageSettings() {
{/* Page range input */}
<div>
<label htmlFor="pdf-pages" className="text-xs text-muted-foreground">
Pages
{t.toolSettings["pdf-to-image"].pages}
</label>
<input
id="pdf-pages"
@@ -256,7 +267,7 @@ export function PdfToImageSettings() {
>
{store.processing && <Loader2 className="h-4 w-4 animate-spin" />}
{store.processing
? "Converting..."
? t.toolSettings["pdf-to-image"].converting
: `Convert ${selectedCount} page${selectedCount !== 1 ? "s" : ""}`}
</button>
@@ -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 */}
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium text-foreground">{tool.name}</span>
<span className="text-sm font-medium text-foreground">
{getToolName(t, tool.id, tool.name)}
</span>
{/* Settings summary when collapsed */}
{!isExpanded && summary && (
<span className="text-xs text-muted-foreground truncate ml-1">{summary}</span>
<span className="text-xs text-muted-foreground truncate ms-1">{summary}</span>
)}
<span className="flex-1" />
@@ -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<HTMLInputElement>(null);
@@ -515,7 +517,7 @@ export function QrGenerateSettings() {
</label>
{store.dotGradientEnabled && (
<div className="space-y-2 pl-2 border-l-2 border-primary/20 ml-1">
<div className="space-y-2 ps-2 border-s-2 border-primary/20 ms-1">
<div className="flex gap-2">
<div className="flex-1">
<label htmlFor="qr-gradient-from" className="text-[10px] text-muted-foreground">
@@ -764,7 +766,7 @@ export function QrGenerateSettings() {
key={value}
type="button"
onClick={() => store.setDownloadFormat(value)}
className={`text-left px-2 py-1.5 rounded transition-colors ${
className={`text-start px-2 py-1.5 rounded transition-colors ${
store.downloadFormat === value
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground"
@@ -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 LOSSY_FORMATS = new Set(["jpeg", "webp", "avif", "jxl"]);
@@ -140,6 +142,7 @@ export function RedEyeRemovalControls({
}
export function RedEyeRemovalSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -196,7 +199,9 @@ export function RedEyeRemovalSettings() {
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 ? `Fix Red Eye (${files.length} files)` : "Fix Red Eye"}
{hasMultiple
? format(t.toolSettings["red-eye-removal"].submitBatch, { count: files.length })
: t.toolSettings["red-eye-removal"].submit}
</button>
)}
@@ -9,8 +9,10 @@ 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 { formatHeaders } from "@/lib/api";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type SubjectType = "people" | "products" | "general";
@@ -84,6 +86,7 @@ export interface RemoveBgControlsProps {
}
export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgControlsProps) {
const { t } = useTranslation();
const [subject, setSubject] = useState<SubjectType>("people");
const [quality, setQuality] = useState<Quality>("balanced");
const [isPassport, setIsPassport] = useState(true);
@@ -167,7 +170,7 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
return (
<div className="space-y-3">
{/* Subject type */}
<SectionLabel>Subject</SectionLabel>
<SectionLabel>{t.toolSettings["remove-background"].subject}</SectionLabel>
<div className="grid grid-cols-3 gap-1.5">
{SUBJECT_OPTIONS.map((opt) => {
const Icon = opt.icon;
@@ -202,12 +205,14 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
onChange={(e) => setIsPassport(e.target.checked)}
className="rounded border-border accent-primary"
/>
<span className="text-sm text-muted-foreground">Passport / ID photo</span>
<span className="text-sm text-muted-foreground">
{t.toolSettings["remove-background"].passportIdPhoto}
</span>
</label>
)}
{/* Quality */}
<SectionLabel>Quality</SectionLabel>
<SectionLabel>{t.toolSettings["remove-background"].quality}</SectionLabel>
<div className={`grid gap-1.5 ${qualityOptions.length > 3 ? "grid-cols-4" : "grid-cols-3"}`}>
{qualityOptions.map((opt) => (
<button
@@ -226,7 +231,7 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
</div>
{/* Background */}
<SectionLabel>Background</SectionLabel>
<SectionLabel>{t.toolSettings["remove-background"].background}</SectionLabel>
<div className="space-y-2">
{/* Type buttons */}
<div className="flex gap-1.5 flex-wrap">
@@ -258,7 +263,7 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
{/* Color options */}
{bgType === "color" && (
<div className="space-y-2 pl-1">
<div className="space-y-2 ps-1">
<div className="flex gap-1.5 flex-wrap">
{COLOR_PRESETS.map((preset) => (
<button
@@ -293,7 +298,7 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
{/* Gradient options */}
{bgType === "gradient" && (
<div className="space-y-2 pl-1">
<div className="space-y-2 ps-1">
<div className="flex gap-1.5 flex-wrap">
{GRADIENT_PRESETS.map((preset) => (
<button
@@ -351,7 +356,7 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
{/* Image upload */}
{bgType === "image" && (
<div className="pl-1">
<div className="ps-1">
{bgImageFile ? (
<div className="flex items-center gap-2 text-xs">
<span className="text-foreground truncate flex-1">{bgImageFile.name}</span>
@@ -391,12 +396,12 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
{effectsOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
Effects
{(blurEnabled || shadowEnabled) && (
<span className="ml-auto text-primary text-[10px] normal-case font-normal">active</span>
<span className="ms-auto text-primary text-[10px] normal-case font-normal">active</span>
)}
</button>
{effectsOpen && (
<div className="space-y-3 pl-1">
<div className="space-y-3 ps-1">
{/* Blur */}
<div>
<label className="flex items-center gap-2 cursor-pointer">
@@ -406,13 +411,15 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
onChange={(e) => setBlurEnabled(e.target.checked)}
className="rounded border-border accent-primary"
/>
<span className="text-xs text-muted-foreground">Blur Background</span>
<span className="text-xs text-muted-foreground">
{t.toolSettings["remove-background"].blurBackground}
</span>
</label>
{blurEnabled && (
<div className="mt-1.5 pl-5">
<div className="mt-1.5 ps-5">
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Intensity</span>
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-right">
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-end">
{blurIntensity}
</span>
</div>
@@ -437,13 +444,15 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
onChange={(e) => setShadowEnabled(e.target.checked)}
className="rounded border-border accent-primary"
/>
<span className="text-xs text-muted-foreground">Add Shadow</span>
<span className="text-xs text-muted-foreground">
{t.toolSettings["remove-background"].addShadow}
</span>
</label>
{shadowEnabled && (
<div className="mt-1.5 pl-5">
<div className="mt-1.5 ps-5">
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Opacity</span>
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-right">
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-end">
{shadowOpacity}
</span>
</div>
@@ -526,6 +535,7 @@ interface RemoveBgSettingsProps {
}
export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -796,7 +806,7 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Removing background"
label={t.toolSettings["remove-background"].progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
@@ -809,7 +819,9 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Remove Background (${files.length} files)` : "Remove Background"}
{files.length > 1
? format(t.toolSettings["remove-background"].submitBatch, { count: files.length })
: t.toolSettings["remove-background"].submit}
</button>
) : null}
@@ -825,7 +837,7 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
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"
>
<Download className="h-4 w-4" />
{applyingEffects ? "Rendering..." : "Download"}
{applyingEffects ? t.toolSettings["remove-background"].rendering : "Download"}
</button>
) : (
<a
@@ -2,17 +2,15 @@ import { SOCIAL_MEDIA_PRESETS } from "@snapotter/shared";
import { Download, Info, Link, Unlink } 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";
type ResizeTab = "presets" | "custom" | "scale" | "content-aware";
type FitMode = "cover" | "contain" | "fill";
const FIT_LABELS: Record<FitMode, string> = {
cover: "Crop to fit",
contain: "Fit inside",
fill: "Stretch",
};
const FIT_MODES: FitMode[] = ["cover", "contain", "fill"];
// Group presets by platform
const platforms = [...new Set(SOCIAL_MEDIA_PRESETS.map((p) => p.platform))];
@@ -34,6 +32,7 @@ export interface ResizeControlsProps {
}
export function ResizeControls({ settings: initialSettings, onChange }: ResizeControlsProps) {
const { t } = useTranslation();
const [tab, setTab] = useState<ResizeTab>("custom");
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
const [width, setWidth] = useState<string>("");
@@ -127,7 +126,7 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
<div className="flex items-end gap-2">
<div className="flex-1">
<label htmlFor="resize-width" className="text-xs text-muted-foreground">
Width (px)
{t.toolSettings.resize.widthPx}
</label>
<input
id="resize-width"
@@ -149,7 +148,7 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
</button>
<div className="flex-1">
<label htmlFor="resize-height" className="text-xs text-muted-foreground">
Height (px)
{t.toolSettings.resize.heightPx}
</label>
<input
id="resize-height"
@@ -172,8 +171,8 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
onChange={(e) => setWithoutEnlargement(e.target.checked)}
className="rounded"
/>
<span>Limit to original size</span>
<HintIcon text="If your image is already smaller than the target, keep it as-is instead of scaling it up" />
<span>{t.toolSettings.resize.limitToOriginalSize}</span>
<HintIcon text={t.toolSettings.resize.limitToOriginalSizeHint} />
</label>
);
@@ -183,27 +182,27 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
<div>
<div className="flex gap-1">
<button type="button" onClick={() => setTab("custom")} className={tabClass("custom")}>
Custom Size
{t.toolSettings.resize.customSize}
</button>
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
Scale
{t.toolSettings.resize.scale}
</button>
<button type="button" onClick={() => setTab("presets")} className={tabClass("presets")}>
Presets
{t.toolSettings.resize.presets}
</button>
<button
type="button"
onClick={() => setTab("content-aware")}
className={tabClass("content-aware")}
>
Content-Aware
{t.toolSettings.resize.contentAware}
</button>
</div>
</div>
{/* Presets tab */}
{tab === "presets" && (
<div className="space-y-3 max-h-[50vh] overflow-y-auto pr-1">
<div className="space-y-3 max-h-[50vh] overflow-y-auto pe-1">
{platforms.map((platform) => (
<div key={platform}>
<p className="text-xs font-medium text-muted-foreground mb-1.5">{platform}</p>
@@ -244,16 +243,20 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
{/* Fit mode */}
<div>
<p className="text-xs text-muted-foreground">Fit Mode</p>
<p className="text-xs text-muted-foreground">{t.toolSettings.resize.fitMode}</p>
<div className="flex gap-1 mt-1">
{(Object.keys(FIT_LABELS) as FitMode[]).map((f) => (
{FIT_MODES.map((f) => (
<button
key={f}
type="button"
onClick={() => setFit(f)}
className={`flex-1 text-xs py-1.5 rounded ${fit === f ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
{FIT_LABELS[f]}
{f === "cover"
? t.toolSettings.resize.cropToFit
: f === "contain"
? t.toolSettings.resize.fitInside
: t.toolSettings.resize.stretch}
</button>
))}
</div>
@@ -311,7 +314,7 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
onChange={(e) => setSquareMode(e.target.checked)}
className="rounded"
/>
Resize to square
{t.toolSettings.resize.resizeToSquare}
</label>
{/* Face protection */}
@@ -322,7 +325,7 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
onChange={(e) => setProtectFaces(e.target.checked)}
className="rounded"
/>
Protect faces
{t.toolSettings.resize.protectFaces}
</label>
{/* Blur radius */}
@@ -369,6 +372,7 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
}
export function ResizeSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const standardResize = useToolProcessor("resize");
const contentAwareResize = useToolProcessor("content-aware-resize");
@@ -420,7 +424,7 @@ export function ResizeSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Resizing"
label={t.toolSettings.resize.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
@@ -432,7 +436,9 @@ export function ResizeSettings() {
disabled={!canProcess}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Resize (${files.length} files)` : "Resize"}
{files.length > 1
? format(t.toolSettings.resize.submitBatch, { count: files.length })
: t.toolSettings.resize.submit}
</button>
)}
@@ -445,7 +451,7 @@ export function ResizeSettings() {
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
{t.common.download}
</a>
)}
</form>
@@ -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";
export interface RestorePhotoControlsProps {
@@ -13,6 +15,7 @@ export function RestorePhotoControls({
settings: initialSettings,
onChange,
}: RestorePhotoControlsProps) {
const { t } = useTranslation();
const [scratchRemoval, setScratchRemoval] = useState(true);
const [faceEnhancement, setFaceEnhancement] = useState(true);
const [fidelity, setFidelity] = useState(70);
@@ -103,7 +106,7 @@ export function RestorePhotoControls({
{/* Fidelity slider (only when face enhancement is on) */}
{faceEnhancement && (
<div className="pl-2 border-l-2 border-primary/20">
<div className="ps-2 border-s-2 border-primary/20">
<div className="flex justify-between items-center">
<p className="text-xs text-muted-foreground">Face Fidelity</p>
<span className="text-xs font-mono tabular-nums">{fidelity}%</span>
@@ -142,7 +145,7 @@ export function RestorePhotoControls({
{/* Denoise strength slider */}
{denoise && (
<div className="pl-2 border-l-2 border-primary/20">
<div className="ps-2 border-s-2 border-primary/20">
<div className="flex justify-between items-center">
<p className="text-xs text-muted-foreground">Denoise Strength</p>
<span className="text-xs font-mono tabular-nums">{denoiseStrength}</span>
@@ -183,7 +186,7 @@ export function RestorePhotoControls({
{/* Colorize strength slider */}
{colorize && (
<div className="pl-2 border-l-2 border-primary/20">
<div className="ps-2 border-s-2 border-primary/20">
<div className="flex justify-between items-center">
<p className="text-xs text-muted-foreground">Colorize Strength</p>
<span className="text-xs font-mono tabular-nums">{colorizeStrength}%</span>
@@ -209,6 +212,7 @@ export function RestorePhotoControls({
}
export function RestorePhotoSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -265,7 +269,9 @@ export function RestorePhotoSettings() {
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 ? `Restore Photos (${files.length})` : "Restore Photo"}
{hasMultiple
? format(t.toolSettings["restore-photo"].submitBatch, { count: files.length })
: t.toolSettings["restore-photo"].submit}
</button>
)}
@@ -162,7 +162,7 @@ export function RotateControls({
commitAngleInput();
}
}}
className="w-16 text-center text-sm font-mono font-medium tabular-nums py-1.5 rounded-md bg-background border border-border focus:outline-none focus:ring-2 focus:ring-primary/50 pr-4"
className="w-16 text-center text-sm font-mono font-medium tabular-nums py-1.5 rounded-md bg-background border border-border focus:outline-none focus:ring-2 focus:ring-primary/50 pe-4"
/>
<span className="absolute right-2 text-sm font-mono text-muted-foreground pointer-events-none">
°
@@ -2,7 +2,9 @@ import { ChevronDown, ChevronRight, Download } from "lucide-react";
import type React from "react";
import { 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 Method = "adaptive" | "unsharp-mask" | "high-pass";
@@ -29,6 +31,7 @@ const PRESETS: Preset[] = [
];
export function SharpeningSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -103,7 +106,7 @@ export function SharpeningSettings() {
return (
<form onSubmit={handleSubmit} className="space-y-3">
{/* Method selector */}
<SectionLabel>Method</SectionLabel>
<SectionLabel>{t.toolSettings.sharpening.method}</SectionLabel>
<div className="grid grid-cols-3 gap-1">
{(["adaptive", "unsharp-mask", "high-pass"] as const).map((m) => (
<button
@@ -127,7 +130,7 @@ export function SharpeningSettings() {
{/* Presets (adaptive only) */}
{method === "adaptive" && (
<>
<SectionLabel>Presets</SectionLabel>
<SectionLabel>{t.toolSettings.sharpening.presets}</SectionLabel>
<div className="grid grid-cols-4 gap-1">
{PRESETS.map((p) => (
<button
@@ -199,7 +202,7 @@ export function SharpeningSettings() {
</div>
{/* Noise reduction */}
<SectionLabel>Noise Reduction</SectionLabel>
<SectionLabel>{t.toolSettings.sharpening.noiseReduction}</SectionLabel>
<div className="grid grid-cols-4 gap-1">
{(["off", "light", "medium", "strong"] as const).map((d) => (
<button
@@ -228,7 +231,7 @@ export function SharpeningSettings() {
</button>
{advancedOpen && (
<div className="space-y-2 pl-1">
<div className="space-y-2 ps-1">
{method === "adaptive" && (
<>
<SliderControl
@@ -359,7 +362,7 @@ export function SharpeningSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Sharpening"
label={t.toolSettings.sharpening.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
@@ -371,7 +374,9 @@ export function SharpeningSettings() {
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Sharpen (${files.length} files)` : "Sharpen"}
{files.length > 1
? format(t.toolSettings.sharpening.submitBatch, { count: files.length })
: t.toolSettings.sharpening.submit}
</button>
)}
@@ -424,9 +429,9 @@ function SliderControl({
<div className="flex justify-between items-center">
<label htmlFor={id} className={`text-xs ${color || "text-muted-foreground"}`}>
{label}
{hint && <span className="text-[10px] text-muted-foreground/60 ml-1">({hint})</span>}
{hint && <span className="text-[10px] text-muted-foreground/60 ms-1">({hint})</span>}
</label>
<span className="text-xs font-mono text-foreground tabular-nums w-10 text-right">
<span className="text-xs font-mono text-foreground tabular-nums w-10 text-end">
{displayValue}
</span>
</div>
@@ -2,7 +2,9 @@ import { SMART_CROP_FACE_PRESETS, SOCIAL_MEDIA_PRESETS } from "@snapotter/shared
import { ArrowLeftRight, Info } 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 CropMode = "subject" | "face" | "trim";
@@ -36,6 +38,7 @@ export interface SmartCropControlsProps {
}
export function SmartCropControls({ settings: initialSettings, onChange }: SmartCropControlsProps) {
const { t } = useTranslation();
const [mode, setMode] = useState<CropMode>("subject");
const [subjectTab, setSubjectTab] = useState<SubjectTab>("custom");
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
@@ -261,13 +264,13 @@ export function SmartCropControls({ settings: initialSettings, onChange }: Smart
onClick={() => setMode("subject")}
className={modeTabClass("subject")}
>
Subject Focus
{t.toolSettings["smart-crop"].subjectFocus}
</button>
<button type="button" onClick={() => setMode("face")} className={modeTabClass("face")}>
Face Focus
{t.toolSettings["smart-crop"].faceFocus}
</button>
<button type="button" onClick={() => setMode("trim")} className={modeTabClass("trim")}>
Auto Trim
{t.toolSettings["smart-crop"].autoTrim}
</button>
</div>
@@ -293,7 +296,7 @@ export function SmartCropControls({ settings: initialSettings, onChange }: Smart
</div>
{subjectTab === "presets" ? (
<div className="space-y-3 max-h-[50vh] overflow-y-auto pr-1">
<div className="space-y-3 max-h-[50vh] overflow-y-auto pe-1">
{platforms.map((platform) => (
<div key={platform}>
<p className="text-xs font-medium text-muted-foreground mb-1.5">{platform}</p>
@@ -333,7 +336,9 @@ export function SmartCropControls({ settings: initialSettings, onChange }: Smart
{/* Strategy toggle */}
<div>
<div className="flex items-center gap-1.5 mb-1">
<span className="text-xs text-muted-foreground">Detection Strategy</span>
<span className="text-xs text-muted-foreground">
{t.toolSettings["smart-crop"].detectionStrategy}
</span>
<HintIcon text="Attention finds the most visually salient region. Entropy finds the area with most detail and information." />
</div>
<div className="flex gap-1">
@@ -546,6 +551,7 @@ export function SmartCropControls({ settings: initialSettings, onChange }: Smart
}
export function SmartCropSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("smart-crop");
@@ -1,6 +1,7 @@
import { Download, Loader2, PackageOpen } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { CollapsibleSection } from "@/components/common/collapsible-section";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { useFileStore } from "@/stores/file-store";
import type { SplitMode } from "@/stores/split-store";
@@ -35,6 +36,7 @@ const OUTPUT_FORMATS = [
const LOSSY_FORMATS = new Set(["jpg", "webp", "avif", "jxl"]);
export function SplitSettings() {
const { t } = useTranslation();
const { files, processing: fileStoreProcessing } = useFileStore();
const {
mode,
@@ -1,5 +1,6 @@
import { Download, Loader2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { useFileStore } from "@/stores/file-store";
@@ -9,6 +10,7 @@ type Alignment = "start" | "center" | "end";
type OutputFormat = "png" | "jpeg" | "webp" | "avif" | "jxl";
export function StitchSettings() {
const { t } = useTranslation();
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
useFileStore();
@@ -143,7 +143,7 @@ export function StripMetadataControls({
/>
Strip EXIF (camera info, date, exposure)
{hasExif && !stripAll && (
<span className="ml-auto text-[10px] text-muted-foreground">
<span className="ms-auto text-[10px] text-muted-foreground">
{Object.keys(metadata?.exif ?? {}).filter((k) => !SKIP_KEYS.has(k)).length} fields
</span>
)}
@@ -161,7 +161,7 @@ export function StripMetadataControls({
/>
Strip GPS (location data)
{hasGps && !stripAll && (
<span className="ml-auto text-[10px] text-amber-500">location found</span>
<span className="ms-auto text-[10px] text-amber-500">location found</span>
)}
</label>
@@ -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";
export interface TextOverlayControlsProps {
@@ -155,6 +157,7 @@ export function TextOverlayControls({
}
export function TextOverlaySettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
+10 -4
View File
@@ -2,8 +2,10 @@ import { CATEGORIES, TOOLS } from "@snapotter/shared";
import { FileImage, Plus } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { SearchBar } from "@/components/common/search-bar";
import { useTranslation } from "@/contexts/i18n-context";
import { apiGet } from "@/lib/api";
import { ICON_MAP } from "@/lib/icon-map";
import { getCategoryName, getToolDescription, getToolName } from "@/lib/tool-i18n";
import { cn } from "@/lib/utils";
const EXCLUDED_TOOLS = new Set(["pipeline", "compare", "find-duplicates", "collage", "compose"]);
@@ -14,6 +16,7 @@ interface ToolPaletteProps {
}
export function ToolPalette({ onAddStep, className }: ToolPaletteProps) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [disabledTools, setDisabledTools] = useState<string[]>([]);
const [experimentalEnabled, setExperimentalEnabled] = useState(false);
@@ -87,7 +90,7 @@ export function ToolPalette({ onAddStep, className }: ToolPaletteProps) {
<div className="flex items-center gap-1.5 mb-1.5 px-1">
<CatIcon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold uppercase text-muted-foreground tracking-wider">
{cat.name}
{getCategoryName(t, cat.id, cat.name)}
</span>
</div>
<div className="space-y-0.5">
@@ -111,21 +114,24 @@ interface ToolItemProps {
}
function ToolItem({ tool, onAdd }: ToolItemProps) {
const { t } = useTranslation();
const Icon = (ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage;
return (
<button
type="button"
onClick={() => onAdd(tool.id)}
className="flex items-center gap-2.5 w-full px-2.5 py-2 rounded-lg hover:bg-muted text-left transition-colors group"
className="flex items-center gap-2.5 w-full px-2.5 py-2 rounded-lg hover:bg-muted text-start transition-colors group"
>
<div className="p-1.5 rounded-md bg-muted group-hover:bg-primary/10 transition-colors shrink-0">
<Icon className="h-3.5 w-3.5 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground leading-tight">{tool.name}</div>
<div className="text-sm font-medium text-foreground leading-tight">
{getToolName(t, tool.id, tool.name)}
</div>
<div className="text-[11px] text-muted-foreground truncate leading-tight">
{tool.description}
{getToolDescription(t, tool.id, tool.description)}
</div>
</div>
<Plus className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
@@ -1,7 +1,9 @@
import { ChevronDown, ChevronRight, Droplets } 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 OutputFormat = "png" | "webp";
@@ -17,6 +19,7 @@ export function TransparencyFixerControls({
settings: _settings,
onChange,
}: TransparencyFixerControlsProps) {
const { t } = useTranslation();
const [defringe, setDefringe] = useState(30);
const [outputFormat, setOutputFormat] = useState<OutputFormat>("png");
const [removeWatermark, setRemoveWatermark] = useState(false);
@@ -76,12 +79,12 @@ export function TransparencyFixerControls({
</button>
{advancedOpen && (
<div className="space-y-3 pl-1">
<div className="space-y-3 ps-1">
{/* Defringe slider */}
<div>
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Defringe</span>
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-right">
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-end">
{defringe}
</span>
</div>
@@ -118,6 +121,7 @@ export function TransparencyFixerControls({
// ── Standalone tool page wrapper ──
export function TransparencyFixerSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("transparency-fixer");
@@ -160,7 +164,9 @@ export function TransparencyFixerSettings() {
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"
>
{hasMultiple ? `Fix Transparency (${files.length} files)` : "Fix Transparency"}
{hasMultiple
? format(t.toolSettings["transparency-fixer"].submitBatch, { count: files.length })
: t.toolSettings["transparency-fixer"].submit}
</button>
)}
</div>
@@ -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 QUICK_SCALES = [2, 3, 4, 6, 8];
@@ -29,6 +31,7 @@ export interface UpscaleControlsProps {
}
export function UpscaleControls({ settings: initialSettings, onChange }: UpscaleControlsProps) {
const { t } = useTranslation();
const [scale, setScale] = useState(2);
const [model, setModel] = useState<"auto" | "realesrgan" | "lanczos">("auto");
const [faceEnhance, setFaceEnhance] = useState(false);
@@ -70,7 +73,9 @@ export function UpscaleControls({ settings: initialSettings, onChange }: Upscale
{/* Scale factor */}
<div>
<div className="flex justify-between items-center">
<p className="text-sm font-medium text-muted-foreground">Scale Factor</p>
<p className="text-sm font-medium text-muted-foreground">
{t.toolSettings.upscale.scaleFactor}
</p>
<span className="text-sm font-mono font-medium">{scale}x</span>
</div>
<div className="flex gap-1 mt-1.5">
@@ -130,14 +135,16 @@ export function UpscaleControls({ settings: initialSettings, onChange }: Upscale
onChange={(e) => setFaceEnhance(e.target.checked)}
className="rounded border-border"
/>
<span className="text-sm text-foreground">Enhance faces</span>
<span className="text-sm text-foreground">{t.toolSettings.upscale.enhanceFaces}</span>
</label>
)}
{/* Noise Reduction */}
<div>
<div className="flex justify-between items-center">
<p className="text-sm font-medium text-muted-foreground">Noise Reduction</p>
<p className="text-sm font-medium text-muted-foreground">
{t.toolSettings.upscale.noiseReduction}
</p>
<span className="text-sm font-mono font-medium">
{denoise === 0 ? "Off" : denoise.toFixed(1)}
</span>
@@ -198,6 +205,7 @@ export function UpscaleControls({ settings: initialSettings, onChange }: Upscale
}
export function UpscaleSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -242,7 +250,7 @@ export function UpscaleSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={hasMultiple ? `Upscaling ${files.length} images` : "Upscaling image"}
label={t.toolSettings.upscale.progressLabel}
percent={progress.percent}
elapsed={progress.elapsed}
/>
@@ -255,8 +263,11 @@ export function UpscaleSettings() {
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
? `Upscale ${(settings.scale as number) ?? 2}x (${files.length} files)`
: `Upscale ${(settings.scale as number) ?? 2}x`}
? format(t.toolSettings.upscale.submitBatch, {
scale: (settings.scale as number) ?? 2,
count: files.length,
})
: format(t.toolSettings.upscale.submit, { scale: (settings.scale as number) ?? 2 })}
</button>
)}
@@ -1,7 +1,9 @@
import { Download } from "lucide-react";
import { 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 ColorMode = "bw" | "color";
@@ -72,6 +74,7 @@ function speckleToDetail(speckle: number): Detail {
}
export function VectorizeSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -353,7 +356,7 @@ export function VectorizeSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Vectorizing"
label={t.toolSettings.vectorize.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
@@ -366,7 +369,9 @@ export function VectorizeSettings() {
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Vectorize (${files.length} files)` : "Vectorize"}
{files.length > 1
? format(t.toolSettings.vectorize.submitBatch, { count: files.length })
: t.toolSettings.vectorize.submit}
</button>
)}
@@ -1,10 +1,13 @@
import { Download, Loader2, Upload } from "lucide-react";
import { useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right";
export function WatermarkImageSettings() {
const { t } = useTranslation();
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
useFileStore();
const [position, setPosition] = useState<Position>("bottom-right");
@@ -198,8 +201,8 @@ export function WatermarkImageSettings() {
{processing
? "Processing..."
: files.length > 1
? `Apply Watermark (${files.length} files)`
: "Apply Watermark"}
? format(t.toolSettings["watermark-image"].submitBatch, { count: files.length })
: t.toolSettings["watermark-image"].submit}
</button>
{downloadUrl && (
@@ -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 Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right" | "tiled";
@@ -149,6 +151,7 @@ export function WatermarkTextControls({
}
export function WatermarkTextSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -190,7 +193,7 @@ export function WatermarkTextSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Adding watermark"
label={t.toolSettings["watermark-text"].progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
+152
View File
@@ -0,0 +1,152 @@
import type { SupportedLocale, TranslationKeys } from "@snapotter/shared";
import { en, SUPPORTED_LOCALES } from "@snapotter/shared";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
type LocaleModule = Record<string, TranslationKeys>;
const localeLoaders: Record<string, () => Promise<LocaleModule>> = {
"zh-CN": () => import("@snapotter/shared/i18n/zh-CN.js") as Promise<LocaleModule>,
"zh-TW": () => import("@snapotter/shared/i18n/zh-TW.js") as Promise<LocaleModule>,
ja: () => import("@snapotter/shared/i18n/ja.js") as Promise<LocaleModule>,
ko: () => import("@snapotter/shared/i18n/ko.js") as Promise<LocaleModule>,
es: () => import("@snapotter/shared/i18n/es.js") as Promise<LocaleModule>,
fr: () => import("@snapotter/shared/i18n/fr.js") as Promise<LocaleModule>,
it: () => import("@snapotter/shared/i18n/it.js") as Promise<LocaleModule>,
"pt-BR": () => import("@snapotter/shared/i18n/pt-BR.js") as Promise<LocaleModule>,
de: () => import("@snapotter/shared/i18n/de.js") as Promise<LocaleModule>,
nl: () => import("@snapotter/shared/i18n/nl.js") as Promise<LocaleModule>,
sv: () => import("@snapotter/shared/i18n/sv.js") as Promise<LocaleModule>,
ru: () => import("@snapotter/shared/i18n/ru.js") as Promise<LocaleModule>,
pl: () => import("@snapotter/shared/i18n/pl.js") as Promise<LocaleModule>,
uk: () => import("@snapotter/shared/i18n/uk.js") as Promise<LocaleModule>,
ar: () => import("@snapotter/shared/i18n/ar.js") as Promise<LocaleModule>,
tr: () => import("@snapotter/shared/i18n/tr.js") as Promise<LocaleModule>,
hi: () => import("@snapotter/shared/i18n/hi.js") as Promise<LocaleModule>,
vi: () => import("@snapotter/shared/i18n/vi.js") as Promise<LocaleModule>,
id: () => import("@snapotter/shared/i18n/id.js") as Promise<LocaleModule>,
th: () => import("@snapotter/shared/i18n/th.js") as Promise<LocaleModule>,
};
async function loadLocale(code: string): Promise<TranslationKeys> {
if (code === "en") return en;
const loader = localeLoaders[code];
if (!loader) return en;
try {
const mod = await loader();
const camelCode = code.replace(/-([a-zA-Z])/g, (_, c: string) => c.toUpperCase());
return (mod[camelCode] ?? mod[code] ?? en) as TranslationKeys;
} catch {
return en;
}
}
type I18nContextValue = {
t: TranslationKeys;
locale: string;
dir: "ltr" | "rtl";
setLocale: (code: string) => void;
supportedLocales: SupportedLocale[];
};
const I18nContext = createContext<I18nContextValue>({
t: en,
locale: "en",
dir: "ltr",
setLocale: () => {},
supportedLocales: SUPPORTED_LOCALES,
});
const LOCALE_STORAGE_KEY = "snapotter-locale";
function detectLocale(): string {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored) return stored;
const codes = SUPPORTED_LOCALES.map((l) => l.code);
for (const browserLang of navigator.languages) {
if (codes.includes(browserLang)) return browserLang;
const prefix = browserLang.split("-")[0];
const match = codes.find((c) => c === prefix || c.startsWith(`${prefix}-`));
if (match) return match;
}
return "en";
}
async function fetchInstanceDefault(): Promise<string> {
try {
const res = await fetch("/api/v1/config/locale");
if (res.ok) {
const data = await res.json();
if (data.defaultLocale && data.defaultLocale !== "en") {
return data.defaultLocale;
}
}
} catch {
// Server unreachable
}
return "en";
}
function getDir(code: string): "ltr" | "rtl" {
return SUPPORTED_LOCALES.find((l) => l.code === code)?.dir ?? "ltr";
}
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [locale, setLocaleState] = useState(() => detectLocale());
const [translations, setTranslations] = useState<TranslationKeys>(en);
const dir = getDir(locale);
useEffect(() => {
const hasExplicitChoice = localStorage.getItem(LOCALE_STORAGE_KEY);
if (hasExplicitChoice) return;
if (detectLocale() !== "en") return;
let cancelled = false;
fetchInstanceDefault().then((instanceLocale) => {
if (!cancelled && instanceLocale !== "en") {
setLocaleState(instanceLocale);
}
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
loadLocale(locale).then((t) => {
if (!cancelled) setTranslations(t);
});
return () => {
cancelled = true;
};
}, [locale]);
useEffect(() => {
document.documentElement.lang = locale;
document.documentElement.dir = dir;
}, [locale, dir]);
const setLocale = useCallback((code: string) => {
localStorage.setItem(LOCALE_STORAGE_KEY, code);
setLocaleState(code);
}, []);
return (
<I18nContext.Provider
value={{
t: translations,
locale,
dir,
setLocale,
supportedLocales: SUPPORTED_LOCALES,
}}
>
{children}
</I18nContext.Provider>
);
}
export const useTranslation = () => useContext(I18nContext);
+24
View File
@@ -0,0 +1,24 @@
import type { TranslationKeys } from "@snapotter/shared";
const API_ERROR_MAP: Record<string, string> = {
"Authentication required": "authRequired",
"Invalid credentials": "invalidCredentials",
"Invalid username or password": "invalidCredentials",
"Current password is incorrect": "currentPasswordIncorrect",
"No valid files uploaded": "noValidFiles",
"File too large": "fileTooLarge",
"Rate limit exceeded": "rateLimitExceeded",
"Processing failed": "processingFailed",
"Request timed out": "timeout",
"Connection error": "connectionError",
"Permission denied": "permissionDenied",
"Not found": "notFound",
};
export function translateApiError(apiMessage: string, t: TranslationKeys): string {
const key = API_ERROR_MAP[apiMessage];
if (key && key in t.errors) {
return t.errors[key as keyof typeof t.errors];
}
return apiMessage;
}
+27
View File
@@ -0,0 +1,27 @@
export function format(template: string, values: Record<string, string | number>): string {
return template.replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? `{${key}}`));
}
export function plural(count: number, one: string, other: string): string {
return count === 1 ? one : other;
}
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
export function formatDate(date: Date | string, locale: string): string {
return new Date(date).toLocaleDateString(locale, {
year: "numeric",
month: "short",
day: "numeric",
});
}
export function formatDateTime(date: Date | string, locale: string): string {
const d = new Date(date);
return `${d.toLocaleDateString(locale, { year: "numeric", month: "short", day: "numeric" })} ${d.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" })}`;
}
+15
View File
@@ -0,0 +1,15 @@
import type { TranslationKeys } from "@snapotter/shared";
export function getToolName(t: TranslationKeys, toolId: string, fallback: string): string {
const entry = (t.tools as Record<string, { name?: string }>)[toolId];
return entry?.name ?? fallback;
}
export function getToolDescription(t: TranslationKeys, toolId: string, fallback: string): string {
const entry = (t.tools as Record<string, { description?: string }>)[toolId];
return entry?.description ?? fallback;
}
export function getCategoryName(t: TranslationKeys, categoryId: string, fallback: string): string {
return (t.categories as Record<string, string>)[categoryId] ?? fallback;
}

Some files were not shown because too many files have changed in this diff Show More