mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" })}`;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user