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 { apiKeyRoutes } from "./routes/api-keys.js";
|
||||||
import { auditLogRoutes } from "./routes/audit-log.js";
|
import { auditLogRoutes } from "./routes/audit-log.js";
|
||||||
import { registerBatchRoutes } from "./routes/batch.js";
|
import { registerBatchRoutes } from "./routes/batch.js";
|
||||||
|
import { configRoutes } from "./routes/config.js";
|
||||||
import { docsRoutes } from "./routes/docs.js";
|
import { docsRoutes } from "./routes/docs.js";
|
||||||
import { registerFeatureRoutes } from "./routes/features.js";
|
import { registerFeatureRoutes } from "./routes/features.js";
|
||||||
import { registerFetchUrlsRoute } from "./routes/fetch-urls.js";
|
import { registerFetchUrlsRoute } from "./routes/fetch-urls.js";
|
||||||
@@ -172,6 +173,9 @@ await app.register(cookie, {
|
|||||||
hook: "onRequest",
|
hook: "onRequest",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Public config routes (no auth required)
|
||||||
|
await configRoutes(app);
|
||||||
|
|
||||||
// Auth middleware (must be registered before routes it protects)
|
// Auth middleware (must be registered before routes it protects)
|
||||||
await authMiddleware(app);
|
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
|
# 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
|
## 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
|
## 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`)
|
- The language name and locale code (e.g., German / `de`)
|
||||||
- Any specific strings or sections you want translated
|
- Any specific strings or sections you want translated
|
||||||
- If you have a translation ready, paste the translated strings directly in the issue
|
- 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)
|
## 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
|
### 1. Copy the reference file
|
||||||
|
|
||||||
```bash
|
```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
|
### 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
|
```ts
|
||||||
// packages/shared/src/i18n/de.ts
|
import type { TranslationKeys } from "./en.js";
|
||||||
export const de = {
|
|
||||||
|
export const xx: TranslationKeys = {
|
||||||
common: {
|
common: {
|
||||||
upload: "Vom Computer hochladen",
|
upload: "Your translation here",
|
||||||
process: "Verarbeiten",
|
|
||||||
download: "Herunterladen",
|
|
||||||
cancel: "Abbrechen",
|
|
||||||
// ... translate all entries
|
// ... translate all entries
|
||||||
},
|
},
|
||||||
tools: {
|
// ... translate all sections
|
||||||
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
|
|
||||||
} as const;
|
} 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.
|
### 3. Register the locale
|
||||||
- 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. Export the new language
|
Add your locale to `SUPPORTED_LOCALES` in `packages/shared/src/i18n/index.ts`:
|
||||||
|
|
||||||
Edit `packages/shared/src/i18n/index.ts` to include your language:
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export type { TranslationKeys } from "./en.js";
|
{ code: "xx", name: "Language Name", nativeName: "Native Name", dir: "ltr" },
|
||||||
export { en } from "./en.js";
|
|
||||||
export { de } from "./de.js";
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Verify
|
### 4. Verify
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm typecheck # catches missing or mistyped keys
|
pnpm typecheck # catches missing or mistyped keys
|
||||||
|
pnpm lint # formatting check
|
||||||
pnpm dev # manually verify strings appear correctly
|
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:
|
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.
|
1. Add the new keys to `en.ts` first (the reference file)
|
||||||
2. Run `pnpm typecheck` to make sure all language files still satisfy the `TranslationKeys` type.
|
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 reference
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `packages/shared/src/i18n/en.ts` | English strings (reference locale) |
|
| `packages/shared/src/i18n/en.ts` | English strings (reference locale, ~1500 keys) |
|
||||||
| `packages/shared/src/i18n/index.ts` | Exports all locales and the `TranslationKeys` type |
|
| `packages/shared/src/i18n/index.ts` | `SUPPORTED_LOCALES`, `loadTranslations()`, type exports |
|
||||||
| `packages/shared/src/constants.ts` | Tool registry (names/descriptions also live here) |
|
| `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 { Component, type ErrorInfo, lazy, type ReactNode, Suspense, useEffect } from "react";
|
||||||
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { ConnectionMonitor } from "./components/common/connection-monitor";
|
import { ConnectionMonitor } from "./components/common/connection-monitor";
|
||||||
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
|
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
|
||||||
|
import { I18nProvider } from "./contexts/i18n-context";
|
||||||
import { useAuth } from "./hooks/use-auth";
|
import { useAuth } from "./hooks/use-auth";
|
||||||
import { identify, initAnalytics, setAnalyticsConsent } from "./lib/analytics";
|
import { identify, initAnalytics, setAnalyticsConsent } from "./lib/analytics";
|
||||||
import { useAnalyticsStore } from "./stores/analytics-store";
|
import { useAnalyticsStore } from "./stores/analytics-store";
|
||||||
@@ -55,9 +56,9 @@ class ErrorBoundary extends Component<
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center bg-background text-foreground">
|
<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">
|
<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">
|
<p className="text-sm text-muted-foreground">
|
||||||
{this.state.error?.message || "An unexpected error occurred."}
|
{this.state.error?.message || en.common.unexpectedError}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium"
|
||||||
>
|
>
|
||||||
Go Home
|
{en.common.goHome}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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="flex h-screen items-center justify-center bg-background text-foreground">
|
||||||
<div className="text-center space-y-3">
|
<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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -205,36 +206,41 @@ export function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ConnectionMonitor />
|
<I18nProvider>
|
||||||
<Toaster position="bottom-right" />
|
<ConnectionMonitor />
|
||||||
<BrowserRouter>
|
<Toaster position="bottom-right" />
|
||||||
<KeyboardShortcutProvider>
|
<BrowserRouter>
|
||||||
<AuthGuard>
|
<KeyboardShortcutProvider>
|
||||||
<Suspense fallback={<PageLoader />}>
|
<AuthGuard>
|
||||||
<Routes>
|
<Suspense fallback={<PageLoader />}>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Routes>
|
||||||
<Route path="/change-password" element={<ChangePasswordPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/automate" element={<AutomatePage />} />
|
<Route path="/change-password" element={<ChangePasswordPage />} />
|
||||||
<Route path="/files" element={<FilesPage />} />
|
<Route path="/automate" element={<AutomatePage />} />
|
||||||
<Route path="/fullscreen" element={<FullscreenGridPage />} />
|
<Route path="/files" element={<FilesPage />} />
|
||||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
<Route path="/fullscreen" element={<FullscreenGridPage />} />
|
||||||
{/* Redirects: old color tools consolidated into adjust-colors */}
|
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||||
<Route
|
{/* Redirects: old color tools consolidated into adjust-colors */}
|
||||||
path="/brightness-contrast"
|
<Route
|
||||||
element={<Navigate to="/adjust-colors" replace />}
|
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="/saturation" element={<Navigate to="/adjust-colors" replace />} />
|
||||||
<Route path="/color-effects" element={<Navigate to="/adjust-colors" replace />} />
|
<Route
|
||||||
<Route path="/analytics-consent" element={<AnalyticsConsentPage />} />
|
path="/color-channels"
|
||||||
<Route path="/editor" element={<EditorPage />} />
|
element={<Navigate to="/adjust-colors" replace />}
|
||||||
<Route path="/:toolId" element={<ToolPage />} />
|
/>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/color-effects" element={<Navigate to="/adjust-colors" replace />} />
|
||||||
</Routes>
|
<Route path="/analytics-consent" element={<AnalyticsConsentPage />} />
|
||||||
</Suspense>
|
<Route path="/editor" element={<EditorPage />} />
|
||||||
</AuthGuard>
|
<Route path="/:toolId" element={<ToolPage />} />
|
||||||
</KeyboardShortcutProvider>
|
<Route path="/" element={<HomePage />} />
|
||||||
</BrowserRouter>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
</AuthGuard>
|
||||||
|
</KeyboardShortcutProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</I18nProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { type PointerEvent, useCallback, useEffect, useRef, useState } from "react";
|
import { type PointerEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
|
|
||||||
interface BeforeAfterSliderProps {
|
interface BeforeAfterSliderProps {
|
||||||
/** URL or data URL of original image. */
|
/** URL or data URL of original image. */
|
||||||
@@ -33,6 +35,7 @@ export function BeforeAfterSlider({
|
|||||||
afterSize,
|
afterSize,
|
||||||
initialPosition = 50,
|
initialPosition = 50,
|
||||||
}: BeforeAfterSliderProps) {
|
}: BeforeAfterSliderProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [position, setPosition] = useState(initialPosition); // percentage 0-100
|
const [position, setPosition] = useState(initialPosition); // percentage 0-100
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
@@ -160,10 +163,10 @@ export function BeforeAfterSlider({
|
|||||||
|
|
||||||
{/* Labels */}
|
{/* 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">
|
<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>
|
||||||
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -176,10 +179,10 @@ export function BeforeAfterSlider({
|
|||||||
<span className="px-2 py-1 rounded bg-primary/10 text-primary font-medium">
|
<span className="px-2 py-1 rounded bg-primary/10 text-primary font-medium">
|
||||||
Processed: {formatSize(afterSize)}
|
Processed: {formatSize(afterSize)}
|
||||||
{savingsPercent !== null && Number(savingsPercent) > 0 && (
|
{savingsPercent !== null && Number(savingsPercent) > 0 && (
|
||||||
<span className="ml-1">({savingsPercent}% smaller)</span>
|
<span className="ms-1">({savingsPercent}% smaller)</span>
|
||||||
)}
|
)}
|
||||||
{savingsPercent !== null && Number(savingsPercent) < 0 && (
|
{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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function CollapsibleSection({
|
|||||||
) : (
|
) : (
|
||||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
<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" />}
|
{warning && <AlertTriangle className="h-3 w-3 text-amber-500 shrink-0" />}
|
||||||
{badge && (
|
{badge && (
|
||||||
<span className="px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-[10px]">
|
<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 { CheckCircle2, Loader2, WifiOff } from "lucide-react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useConnectionStore } from "@/stores/connection-store";
|
import { useConnectionStore } from "@/stores/connection-store";
|
||||||
|
|
||||||
export function ConnectionBanner() {
|
export function ConnectionBanner() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const status = useConnectionStore((s) => s.status);
|
const status = useConnectionStore((s) => s.status);
|
||||||
|
|
||||||
if (status === "connected") return null;
|
if (status === "connected") return null;
|
||||||
@@ -11,19 +13,19 @@ export function ConnectionBanner() {
|
|||||||
bg: "bg-amber-500 dark:bg-amber-600",
|
bg: "bg-amber-500 dark:bg-amber-600",
|
||||||
text: "text-amber-950 dark:text-amber-50",
|
text: "text-amber-950 dark:text-amber-50",
|
||||||
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
||||||
message: "Reconnecting to server\u2026",
|
message: t.errors.reconnecting,
|
||||||
},
|
},
|
||||||
offline: {
|
offline: {
|
||||||
bg: "bg-amber-500 dark:bg-amber-600",
|
bg: "bg-amber-500 dark:bg-amber-600",
|
||||||
text: "text-amber-950 dark:text-amber-50",
|
text: "text-amber-950 dark:text-amber-50",
|
||||||
icon: <WifiOff className="h-4 w-4" />,
|
icon: <WifiOff className="h-4 w-4" />,
|
||||||
message: "You\u2019re offline",
|
message: t.errors.offline,
|
||||||
},
|
},
|
||||||
reconnected: {
|
reconnected: {
|
||||||
bg: "bg-emerald-500 dark:bg-emerald-600",
|
bg: "bg-emerald-500 dark:bg-emerald-600",
|
||||||
text: "text-emerald-950 dark:text-emerald-50",
|
text: "text-emerald-950 dark:text-emerald-50",
|
||||||
icon: <CheckCircle2 className="h-4 w-4" />,
|
icon: <CheckCircle2 className="h-4 w-4" />,
|
||||||
message: "Connected",
|
message: t.errors.connected,
|
||||||
},
|
},
|
||||||
}[status];
|
}[status];
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FileImage, ImageUp, Upload } from "lucide-react";
|
import { FileImage, ImageUp, Upload } from "lucide-react";
|
||||||
import { type DragEvent, useCallback, useEffect, useState } from "react";
|
import { type DragEvent, useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useUrlImport } from "@/hooks/use-url-import";
|
import { useUrlImport } from "@/hooks/use-url-import";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { UrlImportModal } from "./url-import-modal";
|
import { UrlImportModal } from "./url-import-modal";
|
||||||
@@ -111,6 +112,7 @@ export function Dropzone({
|
|||||||
fileFilter,
|
fileFilter,
|
||||||
acceptDescription,
|
acceptDescription,
|
||||||
}: DropzoneProps) {
|
}: DropzoneProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const checkFile = fileFilter ?? isImageFile;
|
const checkFile = fileFilter ?? isImageFile;
|
||||||
const resolvedAccept = expandAccept(accept);
|
const resolvedAccept = expandAccept(accept);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
@@ -129,13 +131,13 @@ export function Dropzone({
|
|||||||
const file = await importSingleUrl(url);
|
const file = await importSingleUrl(url);
|
||||||
if (file) {
|
if (file) {
|
||||||
if (!checkFile(file)) {
|
if (!checkFile(file)) {
|
||||||
setUrlError(acceptDescription ?? "This file type is not supported by this tool");
|
setUrlError(acceptDescription ?? t.dropzone.unsupportedFileType);
|
||||||
} else {
|
} else {
|
||||||
setUrlInput("");
|
setUrlInput("");
|
||||||
onUrlImport?.(file);
|
onUrlImport?.(file);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setUrlError("Could not fetch image from URL");
|
setUrlError(t.dropzone.urlFetchFailed);
|
||||||
}
|
}
|
||||||
setUrlLoading(false);
|
setUrlLoading(false);
|
||||||
}, [urlInput, importSingleUrl, onUrlImport, checkFile, acceptDescription]);
|
}, [urlInput, importSingleUrl, onUrlImport, checkFile, acceptDescription]);
|
||||||
@@ -235,11 +237,9 @@ export function Dropzone({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-1.5">
|
<div className="flex flex-col items-center gap-1.5">
|
||||||
<p className={cn("font-medium", compact ? "text-sm" : "text-base", "text-foreground/80")}>
|
<p className={cn("font-medium", compact ? "text-sm" : "text-base", "text-foreground/80")}>
|
||||||
Drop your images here
|
{t.dropzone.dropPrompt}
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground/70">
|
|
||||||
click anywhere to browse, or paste from clipboard
|
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground/70">{t.dropzone.browseOrPaste}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -254,10 +254,10 @@ export function Dropzone({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Upload className="h-4 w-4" />
|
<Upload className="h-4 w-4" />
|
||||||
Upload
|
{t.common.upload}
|
||||||
</button>
|
</button>
|
||||||
<p className="text-xs text-muted-foreground/50">
|
<p className="text-xs text-muted-foreground/50">
|
||||||
{acceptDescription ?? "PNG, JPG, WebP, HEIC, RAW, PSD, and 65+ formats"}
|
{acceptDescription ?? t.dropzone.defaultFormats}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{!compact && onUrlImport && (
|
{!compact && onUrlImport && (
|
||||||
@@ -282,7 +282,7 @@ export function Dropzone({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
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"
|
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}
|
disabled={urlLoading}
|
||||||
/>
|
/>
|
||||||
@@ -307,7 +307,7 @@ export function Dropzone({
|
|||||||
}}
|
}}
|
||||||
className="text-xs text-primary hover:text-primary/80"
|
className="text-xs text-primary hover:text-primary/80"
|
||||||
>
|
>
|
||||||
Import multiple URLs...
|
{t.dropzone.importMultipleUrls}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -325,7 +325,7 @@ export function Dropzone({
|
|||||||
className="flex items-center justify-between text-xs text-muted-foreground px-2 py-0.5"
|
className="flex items-center justify-between text-xs text-muted-foreground px-2 py-0.5"
|
||||||
>
|
>
|
||||||
<span className="truncate">{f.name}</span>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export function FileLibraryModal({ open, onClose, onImport }: FileLibraryModalPr
|
|||||||
placeholder="Search files..."
|
placeholder="Search files..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={handleSearchChange}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ export function ImageViewer({
|
|||||||
|
|
||||||
{/* Info bar */}
|
{/* 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">
|
<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">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
{(originalWidth || naturalWidth) != null && (originalHeight || naturalHeight) != null && (
|
{(originalWidth || naturalWidth) != null && (originalHeight || naturalHeight) != null && (
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { formatFileSize, triggerDownload } from "@/lib/download";
|
import { formatFileSize, triggerDownload } from "@/lib/download";
|
||||||
import { ICON_MAP } from "@/lib/icon-map";
|
import { ICON_MAP } from "@/lib/icon-map";
|
||||||
import { getSuggestedTools } from "@/lib/suggested-tools";
|
import { getSuggestedTools } from "@/lib/suggested-tools";
|
||||||
@@ -33,6 +34,7 @@ export function ReviewPanel({
|
|||||||
onUndo,
|
onUndo,
|
||||||
currentToolId,
|
currentToolId,
|
||||||
}: ReviewPanelProps) {
|
}: ReviewPanelProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isExpanded, setIsExpanded] = useState(true);
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
const [isSuggestionsExpanded, setIsSuggestionsExpanded] = useState(true);
|
const [isSuggestionsExpanded, setIsSuggestionsExpanded] = useState(true);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -66,7 +68,7 @@ export function ReviewPanel({
|
|||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
className="flex items-center justify-between w-full text-sm font-medium text-muted-foreground hover:text-foreground"
|
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" />}
|
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
</button>
|
</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"
|
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" />
|
<Undo2 className="h-3.5 w-3.5" />
|
||||||
Undo
|
{t.reviewPanel.undoButton}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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 className="h-3.5 w-3.5" />
|
||||||
Download
|
{t.common.download}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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"
|
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" />
|
<PenTool className="h-3.5 w-3.5" />
|
||||||
Open in Editor
|
{t.reviewPanel.openInEditor}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Suggested tools */}
|
{/* Suggested tools */}
|
||||||
@@ -129,7 +131,7 @@ export function ReviewPanel({
|
|||||||
onClick={() => setIsSuggestionsExpanded(!isSuggestionsExpanded)}
|
onClick={() => setIsSuggestionsExpanded(!isSuggestionsExpanded)}
|
||||||
className="flex items-center justify-between w-full text-xs font-medium text-muted-foreground hover:text-foreground"
|
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 ? (
|
{isSuggestionsExpanded ? (
|
||||||
<ChevronDown className="h-3.5 w-3.5" />
|
<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"
|
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" />
|
<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" />
|
<ArrowRight className="h-3 w-3 opacity-0 group-hover:opacity-100 shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -6,7 +7,9 @@ interface SearchBarProps {
|
|||||||
placeholder?: string;
|
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 (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<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}
|
tabIndex={0}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={resolvedPlaceholder}
|
||||||
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"
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
|
|
||||||
interface SideBySideComparisonProps {
|
interface SideBySideComparisonProps {
|
||||||
beforeSrc: string;
|
beforeSrc: string;
|
||||||
@@ -19,6 +21,7 @@ export function SideBySideComparison({
|
|||||||
beforeSize,
|
beforeSize,
|
||||||
afterSize,
|
afterSize,
|
||||||
}: SideBySideComparisonProps) {
|
}: SideBySideComparisonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [beforeDims, setBeforeDims] = useState<{ w: number; h: number } | null>(null);
|
const [beforeDims, setBeforeDims] = useState<{ w: number; h: number } | null>(null);
|
||||||
const [afterDims, setAfterDims] = useState<{ w: number; h: number } | null>(null);
|
const [afterDims, setAfterDims] = useState<{ w: number; h: number } | null>(null);
|
||||||
|
|
||||||
@@ -34,7 +37,7 @@ export function SideBySideComparison({
|
|||||||
{/* Original */}
|
{/* Original */}
|
||||||
<div className="flex-1 flex flex-col items-center gap-2">
|
<div className="flex-1 flex flex-col items-center gap-2">
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
Original
|
{t.comparison.original}
|
||||||
</span>
|
</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]">
|
<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
|
<img
|
||||||
@@ -61,7 +64,7 @@ export function SideBySideComparison({
|
|||||||
{/* Processed */}
|
{/* Processed */}
|
||||||
<div className="flex-1 flex flex-col items-center gap-2">
|
<div className="flex-1 flex flex-col items-center gap-2">
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
Processed
|
{t.comparison.processed}
|
||||||
</span>
|
</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]">
|
<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
|
<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"}`}
|
className={`text-sm font-medium ${Number(savingsPercent) > 0 ? "text-green-600 dark:text-green-400" : "text-red-500"}`}
|
||||||
>
|
>
|
||||||
{Number(savingsPercent) > 0
|
{Number(savingsPercent) > 0
|
||||||
? `${savingsPercent}% smaller`
|
? format(t.toolSettings["optimize-for-web"].smaller, { percent: savingsPercent })
|
||||||
: `${Math.abs(Number(savingsPercent))}% larger`}
|
: format(t.toolSettings["optimize-for-web"].larger, {
|
||||||
|
percent: Math.abs(Number(savingsPercent)),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 { Clock, Download, FileImage, Loader2, Star } from "lucide-react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { ICON_MAP } from "@/lib/icon-map";
|
import { ICON_MAP } from "@/lib/icon-map";
|
||||||
|
import { getToolName } from "@/lib/tool-i18n";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useFeaturesStore } from "@/stores/features-store";
|
import { useFeaturesStore } from "@/stores/features-store";
|
||||||
|
|
||||||
@@ -12,6 +14,7 @@ interface ToolCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ToolCard({ tool }: ToolCardProps) {
|
export function ToolCard({ tool }: ToolCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const IconComponent =
|
const IconComponent =
|
||||||
(ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage;
|
(ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage;
|
||||||
|
|
||||||
@@ -34,7 +37,7 @@ export function ToolCard({ tool }: ToolCardProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity absolute -left-5"
|
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" />
|
<Star className="h-3 w-3 text-muted-foreground hover:text-yellow-500" />
|
||||||
</button>
|
</button>
|
||||||
@@ -47,10 +50,12 @@ export function ToolCard({ tool }: ToolCardProps) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<IconComponent className="h-5 w-5 text-muted-foreground" />
|
<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 && (
|
{tool.experimental && (
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-600 font-medium">
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-600 font-medium">
|
||||||
Experimental
|
{t.common.experimental}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{aiStatus === "not_installed" && <Download className="h-3.5 w-3.5 text-muted-foreground" />}
|
{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 { AlertCircle, Check, Clock, Link, Loader2, RotateCw, X } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { type UrlImportEntry, useUrlImport } from "@/hooks/use-url-import";
|
import { type UrlImportEntry, useUrlImport } from "@/hooks/use-url-import";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { extractUrls } from "@/lib/url-parser";
|
import { extractUrls } from "@/lib/url-parser";
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────
|
||||||
@@ -42,6 +44,7 @@ function filenameFromUrl(url: string): string {
|
|||||||
// ── Component ──────────────────────────────────────────────────
|
// ── Component ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
|
export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
|
|
||||||
@@ -108,7 +111,7 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-border shrink-0">
|
<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" />
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClose}
|
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"
|
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">
|
<p className="text-xs text-muted-foreground">{t.urlImport.placeholder}</p>
|
||||||
Supports plain URLs, bulleted lists, numbered lists, and markdown links
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Progress list */}
|
{/* Progress list */}
|
||||||
{hasResults && (
|
{hasResults && (
|
||||||
@@ -167,7 +168,9 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border shrink-0">
|
<div className="flex items-center justify-between px-4 py-3 border-t border-border shrink-0">
|
||||||
<span className="text-xs text-muted-foreground">
|
<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>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{hasResults && !importing ? (
|
{hasResults && !importing ? (
|
||||||
@@ -188,12 +191,10 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
|
|||||||
{adding ? (
|
{adding ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Adding...
|
{t.urlImport.adding}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
format(t.urlImport.addButton, { count: readyCount })
|
||||||
Add {readyCount} Image{readyCount !== 1 ? "s" : ""}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -204,7 +205,7 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
|
|||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="px-4 py-2 text-sm rounded-lg border border-border text-foreground hover:bg-muted"
|
className="px-4 py-2 text-sm rounded-lg border border-border text-foreground hover:bg-muted"
|
||||||
>
|
>
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -215,10 +216,10 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
|
|||||||
{importing ? (
|
{importing ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Importing...
|
{t.urlImport.adding}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Import"
|
t.urlImport.importButton
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ export function ContextMenu({
|
|||||||
onClick={item.action}
|
onClick={item.action}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
className={cn(
|
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",
|
"text-foreground hover:bg-muted transition-colors",
|
||||||
"disabled:cursor-not-allowed disabled:opacity-40",
|
"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">
|
<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" />
|
<Save size={14} className="text-yellow-600 shrink-0" />
|
||||||
<span className="text-foreground">Recovered unsaved work from {timeStr}.</span>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRestore}
|
onClick={onRestore}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function SliderRow({
|
|||||||
onChange(Math.max(min, Math.min(max, v)));
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
import { FilePlus, ImagePlus } from "lucide-react";
|
import { FilePlus, ImagePlus } from "lucide-react";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useEditorStore } from "@/stores/editor-store";
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
import { NewDocumentDialog } from "./new-document-dialog";
|
import { NewDocumentDialog } from "./new-document-dialog";
|
||||||
|
|
||||||
const ACCEPTED_TYPES = ".png,.jpg,.jpeg,.webp,.gif,.bmp,.tiff,.svg,.avif,.svgz";
|
const ACCEPTED_TYPES = ".png,.jpg,.jpeg,.webp,.gif,.bmp,.tiff,.svg,.avif,.svgz";
|
||||||
|
|
||||||
export function WelcomeScreen() {
|
export function WelcomeScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [showNewDoc, setShowNewDoc] = useState(false);
|
const [showNewDoc, setShowNewDoc] = useState(false);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const loadImage = useEditorStore((s) => s.loadImage);
|
const loadImage = useEditorStore((s) => s.loadImage);
|
||||||
@@ -70,8 +72,10 @@ export function WelcomeScreen() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-xl font-semibold text-foreground mb-1">Image Editor</h2>
|
<h2 className="text-xl font-semibold text-foreground mb-1">
|
||||||
<p className="text-sm text-muted-foreground">Drop an image here to get started</p>
|
{t.editor.welcome.heading}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{t.editor.welcome.dropDescription}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 w-full">
|
<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"
|
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} />
|
<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>
|
||||||
|
|
||||||
<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"
|
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} />
|
<FilePlus size={20} />
|
||||||
<span className="text-sm font-medium">New Document</span>
|
<span className="text-sm font-medium">{t.editor.welcome.newDocumentButton}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -386,11 +386,11 @@ function MenuItemRow({ item, onClose }: { item: MenuItem; onClose: () => void })
|
|||||||
data-testid={`menu-item-${toTestId(item.label)}`}
|
data-testid={`menu-item-${toTestId(item.label)}`}
|
||||||
>
|
>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
<ChevronRight size={12} className="ml-4 text-muted-foreground" />
|
<ChevronRight size={12} className="ms-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
{submenuOpen && (
|
{submenuOpen && (
|
||||||
<div
|
<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"
|
role="menu"
|
||||||
onMouseEnter={handleEnter}
|
onMouseEnter={handleEnter}
|
||||||
onMouseLeave={handleLeave}
|
onMouseLeave={handleLeave}
|
||||||
@@ -410,7 +410,7 @@ function MenuItemRow({ item, onClose }: { item: MenuItem; onClose: () => void })
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
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
|
item.disabled
|
||||||
? "text-muted-foreground/50 pointer-events-none"
|
? "text-muted-foreground/50 pointer-events-none"
|
||||||
: "text-foreground hover:bg-accent hover:text-accent-foreground",
|
: "text-foreground hover:bg-accent hover:text-accent-foreground",
|
||||||
@@ -429,7 +429,7 @@ function MenuItemRow({ item, onClose }: { item: MenuItem; onClose: () => void })
|
|||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
{item.shortcut && (
|
{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>
|
</button>
|
||||||
{item.dividerAfter && <div className="my-1 border-t border-border" />}
|
{item.dividerAfter && <div className="my-1 border-t border-border" />}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function EditorStatusBar() {
|
|||||||
const val = Number.parseFloat(e.target.value);
|
const val = Number.parseFloat(e.target.value);
|
||||||
if (!Number.isNaN(val) && val > 0) setZoom(val / 100);
|
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}
|
min={0.01}
|
||||||
max={6400}
|
max={6400}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function EyedropperOptions({
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Sample size dropdown */}
|
{/* Sample size dropdown */}
|
||||||
<div className="relative">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
@@ -84,7 +84,7 @@ export function EyedropperOptions({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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
|
s.value === sampleSize
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "text-foreground hover:bg-muted",
|
: "text-foreground hover:bg-muted",
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function MoveOptions() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<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
|
<OptionButton
|
||||||
icon={AlignStartHorizontal}
|
icon={AlignStartHorizontal}
|
||||||
@@ -121,7 +121,7 @@ export function MoveOptions() {
|
|||||||
|
|
||||||
<div className="mx-1 h-4 w-px bg-border" />
|
<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
|
<OptionButton
|
||||||
icon={ArrowLeftRight}
|
icon={ArrowLeftRight}
|
||||||
|
|||||||
@@ -86,13 +86,13 @@ export function SelectionOptions() {
|
|||||||
{/* Selection type toggle */}
|
{/* Selection type toggle */}
|
||||||
{!isMagicWand && (
|
{!isMagicWand && (
|
||||||
<div className="flex items-center gap-1">
|
<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
|
<ToggleButton
|
||||||
active={selectionType === "rect" && isMarquee}
|
active={selectionType === "rect" && isMarquee}
|
||||||
onClick={() => handleTypeChange("rect")}
|
onClick={() => handleTypeChange("rect")}
|
||||||
label="Rectangular"
|
label="Rectangular"
|
||||||
>
|
>
|
||||||
<Square className="mr-1 h-3.5 w-3.5" />
|
<Square className="me-1 h-3.5 w-3.5" />
|
||||||
Rect
|
Rect
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
@@ -100,11 +100,11 @@ export function SelectionOptions() {
|
|||||||
onClick={() => handleTypeChange("ellipse")}
|
onClick={() => handleTypeChange("ellipse")}
|
||||||
label="Elliptical"
|
label="Elliptical"
|
||||||
>
|
>
|
||||||
<Circle className="mr-1 h-3.5 w-3.5" />
|
<Circle className="me-1 h-3.5 w-3.5" />
|
||||||
Ellipse
|
Ellipse
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton active={isLasso} onClick={() => handleTypeChange("lasso")} label="Lasso">
|
<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
|
Lasso
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,7 +121,7 @@ export function SelectionOptions() {
|
|||||||
|
|
||||||
{/* Selection mode buttons */}
|
{/* Selection mode buttons */}
|
||||||
<div className="flex items-center gap-1">
|
<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
|
<ToggleButton
|
||||||
active={selectionMode === "new"}
|
active={selectionMode === "new"}
|
||||||
onClick={() => handleModeChange("new")}
|
onClick={() => handleModeChange("new")}
|
||||||
@@ -134,7 +134,7 @@ export function SelectionOptions() {
|
|||||||
onClick={() => handleModeChange("add")}
|
onClick={() => handleModeChange("add")}
|
||||||
label="Add to Selection"
|
label="Add to Selection"
|
||||||
>
|
>
|
||||||
<Plus className="mr-0.5 h-3 w-3" />
|
<Plus className="me-0.5 h-3 w-3" />
|
||||||
Add
|
Add
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
@@ -142,7 +142,7 @@ export function SelectionOptions() {
|
|||||||
onClick={() => handleModeChange("subtract")}
|
onClick={() => handleModeChange("subtract")}
|
||||||
label="Subtract from Selection"
|
label="Subtract from Selection"
|
||||||
>
|
>
|
||||||
<Minus className="mr-0.5 h-3 w-3" />
|
<Minus className="me-0.5 h-3 w-3" />
|
||||||
Sub
|
Sub
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ function FontDropdown({ value, onChange }: { value: string; onChange: (name: str
|
|||||||
key={name}
|
key={name}
|
||||||
onClick={() => handleSelect(name)}
|
onClick={() => handleSelect(name)}
|
||||||
className={cn(
|
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",
|
value === name && "bg-muted font-medium",
|
||||||
)}
|
)}
|
||||||
style={{ fontFamily: name }}
|
style={{ fontFamily: name }}
|
||||||
@@ -212,7 +212,7 @@ function FontDropdown({ value, onChange }: { value: string; onChange: (name: str
|
|||||||
key={name}
|
key={name}
|
||||||
onClick={() => handleSelect(name)}
|
onClick={() => handleSelect(name)}
|
||||||
className={cn(
|
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",
|
value === name && "bg-muted font-medium",
|
||||||
)}
|
)}
|
||||||
style={{ fontFamily: name }}
|
style={{ fontFamily: name }}
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export function HistoryPanel() {
|
|||||||
>
|
>
|
||||||
<Redo2 size={14} />
|
<Redo2 size={14} />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* History list */}
|
{/* History list */}
|
||||||
@@ -206,7 +206,7 @@ export function HistoryPanel() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => jumpToState(entry)}
|
onClick={() => jumpToState(entry)}
|
||||||
className={cn(
|
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",
|
isCurrent && "bg-primary/10 text-foreground font-medium",
|
||||||
isFuture && "text-muted-foreground/40",
|
isFuture && "text-muted-foreground/40",
|
||||||
!isCurrent &&
|
!isCurrent &&
|
||||||
|
|||||||
@@ -467,8 +467,8 @@ function LayerRow({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 px-1.5 py-1 rounded cursor-pointer select-none group",
|
"flex items-center gap-1.5 px-1.5 py-1 rounded cursor-pointer select-none group",
|
||||||
"hover:bg-muted/50 transition-colors",
|
"hover:bg-muted/50 transition-colors",
|
||||||
isActive && "bg-primary/10 border-l-2 border-primary",
|
isActive && "bg-primary/10 border-s-2 border-primary",
|
||||||
!isActive && "border-l-2 border-transparent",
|
!isActive && "border-s-2 border-transparent",
|
||||||
)}
|
)}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={isActive}
|
aria-selected={isActive}
|
||||||
@@ -545,7 +545,7 @@ function LayerRow({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
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",
|
isActive ? "text-foreground font-medium" : "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
onClick={handleNameClick}
|
onClick={handleNameClick}
|
||||||
@@ -645,7 +645,7 @@ function LayerContextMenu({
|
|||||||
item.action();
|
item.action();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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
|
item.disabled
|
||||||
? "text-muted-foreground/40 cursor-not-allowed"
|
? "text-muted-foreground/40 cursor-not-allowed"
|
||||||
: "text-foreground hover:bg-muted",
|
: "text-foreground hover:bg-muted",
|
||||||
@@ -1000,7 +1000,7 @@ function EffectSlider({
|
|||||||
onChange={(e) => onChange(Number(e.target.value))}
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
className="flex-1 min-w-0"
|
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}
|
{value}
|
||||||
{suffix}
|
{suffix}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ export function NavigatorPanel() {
|
|||||||
>
|
>
|
||||||
<Plus size={12} />
|
<Plus size={12} />
|
||||||
</button>
|
</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}%
|
{zoomPercent}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { FeatureBundleState } from "@snapotter/shared";
|
import type { FeatureBundleState } from "@snapotter/shared";
|
||||||
import { AlertCircle, Clock, Download, Loader2, RotateCcw } from "lucide-react";
|
import { AlertCircle, Clock, Download, Loader2, RotateCcw } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFeaturesStore } from "@/stores/features-store";
|
import { useFeaturesStore } from "@/stores/features-store";
|
||||||
|
|
||||||
const PROGRESS_MESSAGES = [
|
const PROGRESS_MESSAGES = [
|
||||||
@@ -56,6 +58,7 @@ export function FeatureInstallPrompt({
|
|||||||
toolName,
|
toolName,
|
||||||
toolDescription,
|
toolDescription,
|
||||||
}: FeatureInstallPromptProps) {
|
}: FeatureInstallPromptProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { installBundle, clearError, installing, errors, startTimes, queued } = useFeaturesStore();
|
const { installBundle, clearError, installing, errors, startTimes, queued } = useFeaturesStore();
|
||||||
const progress = installing[bundle.id] ?? null;
|
const progress = installing[bundle.id] ?? null;
|
||||||
const error = errors[bundle.id] ?? null;
|
const error = errors[bundle.id] ?? null;
|
||||||
@@ -97,10 +100,8 @@ export function FeatureInstallPrompt({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-center px-4">
|
<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" />
|
<Download className="h-16 w-16 text-muted-foreground" />
|
||||||
<h2 className="text-xl font-semibold text-foreground">Feature Not Enabled</h2>
|
<h2 className="text-xl font-semibold text-foreground">{t.features.notEnabledTitle}</h2>
|
||||||
<p className="text-muted-foreground max-w-md">
|
<p className="text-muted-foreground max-w-md">{t.features.notEnabledDescription}</p>
|
||||||
This feature is not enabled. Ask your administrator to enable it in Settings.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -112,21 +113,21 @@ export function FeatureInstallPrompt({
|
|||||||
<h2 className="text-xl font-semibold text-foreground">{displayName}</h2>
|
<h2 className="text-xl font-semibold text-foreground">{displayName}</h2>
|
||||||
<p className="text-muted-foreground max-w-md">{displayDescription}</p>
|
<p className="text-muted-foreground max-w-md">{displayDescription}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
This feature requires an additional download (~{bundle.estimatedSize})
|
{format(t.features.requiresDownload, { size: bundle.estimatedSize })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-2 bg-destructive/10 text-destructive rounded-lg px-4 py-3 max-w-md w-full">
|
<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" />
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleInstall}
|
onClick={handleInstall}
|
||||||
className="flex items-center gap-1 text-sm font-medium hover:opacity-80"
|
className="flex items-center gap-1 text-sm font-medium hover:opacity-80"
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-3.5 w-3.5" />
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
Retry
|
{t.features.retryButton}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -144,7 +145,7 @@ export function FeatureInstallPrompt({
|
|||||||
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
|
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
|
||||||
<span className="italic truncate">{PROGRESS_MESSAGES[messageIndex]}</span>
|
<span className="italic truncate">{PROGRESS_MESSAGES[messageIndex]}</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -152,7 +153,7 @@ export function FeatureInstallPrompt({
|
|||||||
{isQueued && (
|
{isQueued && (
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
<Clock className="h-5 w-5" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -162,7 +163,7 @@ export function FeatureInstallPrompt({
|
|||||||
onClick={handleInstall}
|
onClick={handleInstall}
|
||||||
className="px-6 py-2.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 font-medium"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ function DetailRow({ label, value }: { label: string; value: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex justify-between items-start gap-2 px-3 py-2">
|
<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-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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,12 +80,12 @@ export function FileListItem({ file }: FileListItemProps) {
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Size */}
|
{/* 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)}
|
{formatSize(file.size)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Date */}
|
{/* 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)}
|
{formatDate(file.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function FileList() {
|
|||||||
placeholder="Search files..."
|
placeholder="Search files..."
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={handleSearchChange}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { APP_VERSION } from "@snapotter/shared";
|
import { APP_VERSION } from "@snapotter/shared";
|
||||||
import { BookOpen, ExternalLink, Github, Keyboard, X } from "lucide-react";
|
import { BookOpen, ExternalLink, Github, Keyboard, X } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { formatShortcut } from "@/hooks/use-keyboard-shortcuts";
|
import { formatShortcut } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
|
||||||
interface HelpDialogProps {
|
interface HelpDialogProps {
|
||||||
@@ -23,6 +24,7 @@ const SHORTCUTS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const handler = (e: KeyboardEvent) => {
|
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">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border shrink-0">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -61,7 +63,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
|||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-foreground">
|
<div className="flex items-center gap-2 text-foreground">
|
||||||
<BookOpen className="h-4 w-4" />
|
<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>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<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
|
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">
|
<section className="space-y-3">
|
||||||
<div className="flex items-center gap-2 text-foreground">
|
<div className="flex items-center gap-2 text-foreground">
|
||||||
<Keyboard className="h-4 w-4" />
|
<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>
|
||||||
<div className="rounded-lg border border-border overflow-hidden">
|
<div className="rounded-lg border border-border overflow-hidden">
|
||||||
{SHORTCUTS.map((s, i) => (
|
{SHORTCUTS.map((s, i) => (
|
||||||
@@ -95,7 +97,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
|||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-foreground">
|
<div className="flex items-center gap-2 text-foreground">
|
||||||
<Github className="h-4 w-4" />
|
<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>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<a
|
<a
|
||||||
@@ -104,7 +106,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1.5 text-sm text-primary hover:underline"
|
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" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
@@ -113,7 +115,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1.5 text-sm text-primary hover:underline"
|
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" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
@@ -122,7 +124,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1.5 text-sm text-primary hover:underline"
|
className="flex items-center gap-1.5 text-sm text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Documentation
|
{t.help.resources.docsLink}
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
@@ -131,7 +133,7 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1.5 text-sm text-primary hover:underline"
|
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" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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 { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useMobile } from "@/hooks/use-mobile";
|
import { useMobile } from "@/hooks/use-mobile";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useConnectionStore } from "@/stores/connection-store";
|
import { useConnectionStore } from "@/stores/connection-store";
|
||||||
@@ -30,6 +39,7 @@ export function AppLayout({
|
|||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
|
const { t, locale, setLocale, supportedLocales } = useTranslation();
|
||||||
const isMobile = useMobile();
|
const isMobile = useMobile();
|
||||||
const connectionStatus = useConnectionStore((s) => s.status);
|
const connectionStatus = useConnectionStore((s) => s.status);
|
||||||
const bannerVisible = connectionStatus !== "connected";
|
const bannerVisible = connectionStatus !== "connected";
|
||||||
@@ -85,6 +95,22 @@ export function AppLayout({
|
|||||||
onNavClick={() => setMobileSidebarOpen(false)}
|
onNavClick={() => setMobileSidebarOpen(false)}
|
||||||
expanded
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -122,7 +148,7 @@ export function AppLayout({
|
|||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="text-center text-xs text-muted-foreground py-2 border-t border-border">
|
<div className="text-center text-xs text-muted-foreground py-2 border-t border-border">
|
||||||
<Link to="/privacy" className="hover:text-foreground transition-colors">
|
<Link to="/privacy" className="hover:text-foreground transition-colors">
|
||||||
Privacy Policy
|
{t.common.privacyPolicy}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -133,17 +159,17 @@ export function AppLayout({
|
|||||||
{/* Mobile bottom nav */}
|
{/* Mobile bottom nav */}
|
||||||
{isMobile && (
|
{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">
|
<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={LayoutGrid} label={t.appLayout.mobileNavTools} href="/" />
|
||||||
<MobileNavItem icon={Workflow} label="Automate" href="/automate" />
|
<MobileNavItem icon={Workflow} label={t.appLayout.mobileNavAutomate} href="/automate" />
|
||||||
<MobileNavItem icon={ImageEditIcon} label="Editor" href="/editor" />
|
<MobileNavItem icon={ImageEditIcon} label={t.appLayout.mobileNavEditor} href="/editor" />
|
||||||
<MobileNavItem icon={FolderOpen} label="Files" href="/files" />
|
<MobileNavItem icon={FolderOpen} label={t.appLayout.mobileNavFiles} href="/files" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSettingsOpen(true)}
|
onClick={() => setSettingsOpen(true)}
|
||||||
className="flex flex-col items-center gap-0.5 px-3 py-1 text-muted-foreground"
|
className="flex flex-col items-center gap-0.5 px-3 py-1 text-muted-foreground"
|
||||||
>
|
>
|
||||||
<SettingsIcon className="h-5 w-5" />
|
<SettingsIcon className="h-5 w-5" />
|
||||||
<span className="text-[10px]">Settings</span>
|
<span className="text-[10px]">{t.appLayout.mobileNavSettings}</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,70 @@
|
|||||||
import { Globe, Moon, Sun } from "lucide-react";
|
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";
|
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() {
|
export function Footer() {
|
||||||
const { resolvedTheme, toggleTheme } = useTheme();
|
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" />}
|
{resolvedTheme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<LanguageSelector />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FolderOpen, Grid3x3, HelpCircle, LayoutGrid, Settings, Workflow } from "lucide-react";
|
import { FolderOpen, Grid3x3, HelpCircle, LayoutGrid, Settings, Workflow } from "lucide-react";
|
||||||
import type { ComponentType, SVGProps } from "react";
|
import type { ComponentType, SVGProps } from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ImageEditIcon } from "../common/image-edit-icon";
|
import { ImageEditIcon } from "../common/image-edit-icon";
|
||||||
import { OtterLogo } from "../common/otter-logo";
|
import { OtterLogo } from "../common/otter-logo";
|
||||||
@@ -11,18 +12,21 @@ interface SidebarItem {
|
|||||||
href?: string;
|
href?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const topItems: SidebarItem[] = [
|
function useNavItems() {
|
||||||
{ icon: LayoutGrid, label: "Tools", href: "/" },
|
const { t } = useTranslation();
|
||||||
{ icon: Grid3x3, label: "Grid", href: "/fullscreen" },
|
const topItems: SidebarItem[] = [
|
||||||
{ icon: Workflow, label: "Automate", href: "/automate" },
|
{ icon: LayoutGrid, label: t.sidebar.tools, href: "/" },
|
||||||
{ icon: ImageEditIcon, label: "Editor", href: "/editor" },
|
{ icon: Grid3x3, label: t.sidebar.grid, href: "/fullscreen" },
|
||||||
{ icon: FolderOpen, label: "Files", href: "/files" },
|
{ 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: "Help" },
|
const bottomItems: SidebarItem[] = [
|
||||||
{ icon: Settings, label: "Settings" },
|
{ icon: HelpCircle, label: t.sidebar.help },
|
||||||
];
|
{ icon: Settings, label: t.sidebar.settings },
|
||||||
|
];
|
||||||
|
return { topItems, bottomItems };
|
||||||
|
}
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
onSettingsClick: () => void;
|
onSettingsClick: () => void;
|
||||||
@@ -40,6 +44,7 @@ export function Sidebar({
|
|||||||
expanded = false,
|
expanded = false,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { topItems, bottomItems } = useNavItems();
|
||||||
|
|
||||||
const renderItem = (item: SidebarItem, isActive: boolean) => {
|
const renderItem = (item: SidebarItem, isActive: boolean) => {
|
||||||
const content = expanded ? (
|
const content = expanded ? (
|
||||||
@@ -68,14 +73,14 @@ export function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (item.label === "Settings") {
|
if (item === bottomItems[1]) {
|
||||||
return (
|
return (
|
||||||
<button key={item.label} type="button" onClick={onSettingsClick} className="w-full">
|
<button key={item.label} type="button" onClick={onSettingsClick} className="w-full">
|
||||||
{content}
|
{content}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (item.label === "Help") {
|
if (item === bottomItems[0]) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.label}
|
key={item.label}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { CATEGORIES, TOOLS } from "@snapotter/shared";
|
import { CATEGORIES, TOOLS } from "@snapotter/shared";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
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 { useFeaturesStore } from "@/stores/features-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { SearchBar } from "../common/search-bar";
|
import { SearchBar } from "../common/search-bar";
|
||||||
import { ToolCard } from "../common/tool-card";
|
import { ToolCard } from "../common/tool-card";
|
||||||
|
|
||||||
export function ToolPanel() {
|
export function ToolPanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const { disabledTools, experimentalEnabled, loaded, fetch } = useSettingsStore();
|
const { disabledTools, experimentalEnabled, loaded, fetch } = useSettingsStore();
|
||||||
const fetchFeatures = useFeaturesStore((s) => s.fetch);
|
const fetchFeatures = useFeaturesStore((s) => s.fetch);
|
||||||
@@ -54,7 +57,7 @@ export function ToolPanel() {
|
|||||||
{CATEGORIES.filter((cat) => groupedTools.has(cat.id)).map((category) => (
|
{CATEGORIES.filter((cat) => groupedTools.has(cat.id)).map((category) => (
|
||||||
<div key={category.id} className="mb-4">
|
<div key={category.id} className="mb-4">
|
||||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
|
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
|
||||||
{category.name}
|
{getCategoryName(t, category.id, category.name)}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{groupedTools.get(category.id)?.map((tool) => (
|
{groupedTools.get(category.id)?.map((tool) => (
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { FeatureBundleState } from "@snapotter/shared";
|
import type { FeatureBundleState } from "@snapotter/shared";
|
||||||
import { Clock, Download, Loader2, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
|
import { Clock, Download, Loader2, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { apiGet } from "@/lib/api";
|
import { apiGet } from "@/lib/api";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFeaturesStore } from "@/stores/features-store";
|
import { useFeaturesStore } from "@/stores/features-store";
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
@@ -52,6 +54,7 @@ const PROGRESS_MESSAGES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function AiFeaturesSection() {
|
export function AiFeaturesSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
bundles,
|
bundles,
|
||||||
fetch,
|
fetch,
|
||||||
@@ -95,10 +98,8 @@ export function AiFeaturesSection() {
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-foreground">AI Features</h3>
|
<h3 className="text-lg font-semibold text-foreground">{t.settings.aiFeatures.title}</h3>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">{t.settings.aiFeatures.description}</p>
|
||||||
Manage AI model bundles for advanced image processing.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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" />
|
<Download className="h-4 w-4" />
|
||||||
Install All
|
{t.settings.aiFeatures.installAll}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -130,7 +131,7 @@ export function AiFeaturesSection() {
|
|||||||
|
|
||||||
{diskUsage !== null && (
|
{diskUsage !== null && (
|
||||||
<p className="text-xs text-muted-foreground pt-2 border-t border-border">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -163,6 +164,7 @@ function BundleCard({
|
|||||||
isQueued: boolean;
|
isQueued: boolean;
|
||||||
startTime: number | null;
|
startTime: number | null;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
const [messageIndex, setMessageIndex] = useState(() =>
|
const [messageIndex, setMessageIndex] = useState(() =>
|
||||||
Math.floor(Math.random() * PROGRESS_MESSAGES.length),
|
Math.floor(Math.random() * PROGRESS_MESSAGES.length),
|
||||||
@@ -197,24 +199,30 @@ function BundleCard({
|
|||||||
{bundle.description} (~{bundle.estimatedSize})
|
{bundle.description} (~{bundle.estimatedSize})
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-1.5">
|
||||||
{status === "installed" && (
|
{status === "installed" && (
|
||||||
<>
|
<>
|
||||||
<span className="bg-green-500 rounded-full h-2 w-2" />
|
<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 && (
|
{status === "not_installed" && !error && (
|
||||||
<>
|
<>
|
||||||
<span className="bg-muted-foreground rounded-full h-2 w-2" />
|
<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" && (
|
{status === "queued" && (
|
||||||
<>
|
<>
|
||||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
<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 && (
|
{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"
|
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" />
|
<Download className="h-3.5 w-3.5" />
|
||||||
Install
|
{t.settings.aiFeatures.install}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{status === "installed" && !confirming && (
|
{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"
|
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" />
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
Repair
|
{t.settings.aiFeatures.repair}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
Uninstall
|
{t.settings.aiFeatures.uninstall}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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"
|
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" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
Confirm
|
{t.common.confirm}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setConfirming(false)}
|
onClick={() => setConfirming(false)}
|
||||||
className="px-3 py-1.5 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors"
|
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>
|
</button>
|
||||||
</div>
|
</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"
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium opacity-50"
|
||||||
>
|
>
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
Installing...
|
{t.settings.aiFeatures.installing}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{(status === "error" || error) && !isInstalling && !isQueued && (
|
{(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"
|
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" />
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
Retry
|
{t.common.retry}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -319,7 +327,7 @@ function BundleCard({
|
|||||||
<p className="text-xs text-muted-foreground italic">
|
<p className="text-xs text-muted-foreground italic">
|
||||||
{PROGRESS_MESSAGES[messageIndex]}
|
{PROGRESS_MESSAGES[messageIndex]}
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
|||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
type Tier = "fast" | "balanced" | "high";
|
type Tier = "fast" | "balanced" | "high";
|
||||||
@@ -22,6 +24,7 @@ const EXTEND_PRESETS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function AiCanvasExpandSettings() {
|
export function AiCanvasExpandSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||||
useToolProcessor("ai-canvas-expand");
|
useToolProcessor("ai-canvas-expand");
|
||||||
@@ -211,7 +214,7 @@ export function AiCanvasExpandSettings() {
|
|||||||
<ProgressCard
|
<ProgressCard
|
||||||
active={processing}
|
active={processing}
|
||||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||||
label="Extending canvas"
|
label={t.toolSettings["ai-canvas-expand"].progressLabel}
|
||||||
stage={progress.stage}
|
stage={progress.stage}
|
||||||
percent={progress.percent}
|
percent={progress.percent}
|
||||||
elapsed={progress.elapsed}
|
elapsed={progress.elapsed}
|
||||||
@@ -223,7 +226,9 @@ export function AiCanvasExpandSettings() {
|
|||||||
disabled={!hasFile || !hasExtension || processing}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Check, Copy, Download, Search } from "lucide-react";
|
import { Check, Copy, Download, Search } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { copyToClipboard } from "@/lib/utils";
|
import { copyToClipboard } from "@/lib/utils";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
@@ -106,6 +108,7 @@ function scanOneFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BarcodeReadSettings() {
|
export function BarcodeReadSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
const { files, processing, error, setProcessing, setError } = useFileStore();
|
||||||
|
|
||||||
const [tryHarder, setTryHarder] = useState(false);
|
const [tryHarder, setTryHarder] = useState(false);
|
||||||
|
|||||||
@@ -858,7 +858,7 @@ export function BeautifyControls({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRemoveStop(i)}
|
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" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ export interface BlurFacesControlsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BlurFacesControls({ settings: initialSettings, onChange }: BlurFacesControlsProps) {
|
export function BlurFacesControls({ settings: initialSettings, onChange }: BlurFacesControlsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [blurRadius, setBlurRadius] = useState(30);
|
const [blurRadius, setBlurRadius] = useState(30);
|
||||||
const [sensitivity, setSensitivity] = useState(50);
|
const [sensitivity, setSensitivity] = useState(50);
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@ export function BlurFacesControls({ settings: initialSettings, onChange }: BlurF
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<label htmlFor="blur-faces-blur-radius" className="text-xs text-muted-foreground">
|
<label htmlFor="blur-faces-blur-radius" className="text-xs text-muted-foreground">
|
||||||
Blur Radius
|
{t.toolSettings["blur-faces"].blurRadius}
|
||||||
</label>
|
</label>
|
||||||
<span className="text-xs font-mono text-foreground">{blurRadius}</span>
|
<span className="text-xs font-mono text-foreground">{blurRadius}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,8 +53,8 @@ export function BlurFacesControls({ settings: initialSettings, onChange }: BlurF
|
|||||||
className="w-full mt-1"
|
className="w-full mt-1"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
|
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
|
||||||
<span>Light</span>
|
<span>{t.toolSettings["blur-faces"].blurLight}</span>
|
||||||
<span>Heavy</span>
|
<span>{t.toolSettings["blur-faces"].blurHeavy}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -60,7 +62,7 @@ export function BlurFacesControls({ settings: initialSettings, onChange }: BlurF
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<label htmlFor="blur-faces-sensitivity" className="text-xs text-muted-foreground">
|
<label htmlFor="blur-faces-sensitivity" className="text-xs text-muted-foreground">
|
||||||
Detection Sensitivity
|
{t.toolSettings["blur-faces"].detectionSensitivity}
|
||||||
</label>
|
</label>
|
||||||
<span className="text-xs font-mono text-foreground">{sensitivity}%</span>
|
<span className="text-xs font-mono text-foreground">{sensitivity}%</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,8 +76,8 @@ export function BlurFacesControls({ settings: initialSettings, onChange }: BlurF
|
|||||||
className="w-full mt-1"
|
className="w-full mt-1"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
|
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
|
||||||
<span>More faces</span>
|
<span>{t.toolSettings["blur-faces"].moreFaces}</span>
|
||||||
<span>Fewer faces</span>
|
<span>{t.toolSettings["blur-faces"].fewerFaces}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,6 +85,7 @@ export function BlurFacesControls({ settings: initialSettings, onChange }: BlurF
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BlurFacesSettings() {
|
export function BlurFacesSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const {
|
const {
|
||||||
processFiles,
|
processFiles,
|
||||||
@@ -128,7 +131,7 @@ export function BlurFacesSettings() {
|
|||||||
<ProgressCard
|
<ProgressCard
|
||||||
active={processing}
|
active={processing}
|
||||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||||
label="Blurring faces"
|
label={t.toolSettings["blur-faces"].progressLabel}
|
||||||
percent={progress.percent}
|
percent={progress.percent}
|
||||||
elapsed={progress.elapsed}
|
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"
|
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 className="h-4 w-4" />
|
||||||
Download
|
{t.common.download}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { Download } from "lucide-react";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
// ── Presets ──────────────────────────────────────────────────────────
|
// ── Presets ──────────────────────────────────────────────────────────
|
||||||
@@ -225,6 +227,7 @@ export function BorderControls({
|
|||||||
onChange,
|
onChange,
|
||||||
onImageStyle,
|
onImageStyle,
|
||||||
}: BorderControlsProps) {
|
}: BorderControlsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
||||||
const [borderWidth, setBorderWidth] = useState(10);
|
const [borderWidth, setBorderWidth] = useState(10);
|
||||||
const [borderColor, setBorderColor] = useState("#000000");
|
const [borderColor, setBorderColor] = useState("#000000");
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Download, Loader2 } from "lucide-react";
|
import { Download, Loader2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
export function BulkRenameSettings() {
|
export function BulkRenameSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
const { files, processing, error, setProcessing, setError } = useFileStore();
|
||||||
const [pattern, setPattern] = useState("image-{{index}}");
|
const [pattern, setPattern] = useState("image-{{index}}");
|
||||||
const [startIndex, setStartIndex] = useState(1);
|
const [startIndex, setStartIndex] = useState(1);
|
||||||
@@ -69,7 +72,7 @@ export function BulkRenameSettings() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="bulk-rename-pattern" className="text-xs text-muted-foreground">
|
<label htmlFor="bulk-rename-pattern" className="text-xs text-muted-foreground">
|
||||||
Pattern
|
{t.toolSettings["bulk-rename"].pattern}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="bulk-rename-pattern"
|
id="bulk-rename-pattern"
|
||||||
@@ -85,7 +88,7 @@ export function BulkRenameSettings() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="bulk-rename-start-index" className="text-xs text-muted-foreground">
|
<label htmlFor="bulk-rename-start-index" className="text-xs text-muted-foreground">
|
||||||
Start Index
|
{t.toolSettings["bulk-rename"].startIndex}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="bulk-rename-start-index"
|
id="bulk-rename-start-index"
|
||||||
@@ -99,7 +102,7 @@ export function BulkRenameSettings() {
|
|||||||
|
|
||||||
{previewNames.length > 0 && (
|
{previewNames.length > 0 && (
|
||||||
<div>
|
<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">
|
<div className="mt-1 space-y-0.5">
|
||||||
{previewNames.map((name) => (
|
{previewNames.map((name) => (
|
||||||
<div
|
<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"
|
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
{processing ? "Renaming..." : `Rename ${files.length} Files`}
|
{processing
|
||||||
|
? t.toolSettings["bulk-rename"].renaming
|
||||||
|
: format(t.toolSettings["bulk-rename"].submit, { count: files.length })}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{downloadReady && (
|
{downloadReady && (
|
||||||
<p className="text-xs text-green-600 flex items-center gap-1">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type DragEvent, useCallback, useEffect, useRef, useState } from "react";
|
import { type DragEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { isImageFile } from "@/components/common/dropzone";
|
import { isImageFile } from "@/components/common/dropzone";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { type CollageTemplate, getTemplateById } from "@/lib/collage-templates";
|
import { type CollageTemplate, getTemplateById } from "@/lib/collage-templates";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CellTransform, CollageImage } from "@/stores/collage-store";
|
import type { CellTransform, CollageImage } from "@/stores/collage-store";
|
||||||
@@ -50,6 +51,7 @@ function displayUrl(img: CollageImage): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CollagePreview() {
|
export function CollagePreview() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const images = useCollageStore((s) => s.images);
|
const images = useCollageStore((s) => s.images);
|
||||||
const templateId = useCollageStore((s) => s.templateId);
|
const templateId = useCollageStore((s) => s.templateId);
|
||||||
const phase = useCollageStore((s) => s.phase);
|
const phase = useCollageStore((s) => s.phase);
|
||||||
@@ -570,7 +572,7 @@ function CollageCell({
|
|||||||
onChange={handleZoomSlider}
|
onChange={handleZoomSlider}
|
||||||
className="flex-1 h-1.5 accent-white cursor-pointer"
|
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
|
{transform.zoom.toFixed(1)}x
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Download, Loader2 } from "lucide-react";
|
import { Download, Loader2 } from "lucide-react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { CollapsibleSection } from "@/components/common/collapsible-section";
|
import { CollapsibleSection } from "@/components/common/collapsible-section";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
COLLAGE_TEMPLATES,
|
COLLAGE_TEMPLATES,
|
||||||
@@ -37,6 +38,7 @@ const BG_PRESETS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function CollageSettings() {
|
export function CollageSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const store = useCollageStore();
|
const store = useCollageStore();
|
||||||
const {
|
const {
|
||||||
images,
|
images,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
const SIMULATION_TYPES = [
|
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])));
|
const TYPE_MAP = new Map(SIMULATION_TYPES.flatMap((g) => g.types.map((t) => [t.value, t])));
|
||||||
|
|
||||||
export function ColorBlindnessSettings() {
|
export function ColorBlindnessSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const {
|
const {
|
||||||
processFiles,
|
processFiles,
|
||||||
@@ -95,7 +98,7 @@ export function ColorBlindnessSettings() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="cb-simulation-type" className="text-xs text-muted-foreground">
|
<label htmlFor="cb-simulation-type" className="text-xs text-muted-foreground">
|
||||||
Simulation Type
|
{t.toolSettings["color-blindness"].simulationType}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="cb-simulation-type"
|
id="cb-simulation-type"
|
||||||
@@ -131,7 +134,7 @@ export function ColorBlindnessSettings() {
|
|||||||
<ProgressCard
|
<ProgressCard
|
||||||
active={processing}
|
active={processing}
|
||||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||||
label="Simulating color blindness"
|
label={t.toolSettings["color-blindness"].progressLabel}
|
||||||
stage={progress.stage}
|
stage={progress.stage}
|
||||||
percent={progress.percent}
|
percent={progress.percent}
|
||||||
elapsed={progress.elapsed}
|
elapsed={progress.elapsed}
|
||||||
@@ -144,7 +147,9 @@ export function ColorBlindnessSettings() {
|
|||||||
disabled={!hasFile || processing}
|
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"
|
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>
|
</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"
|
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 className="h-4 w-4" />
|
||||||
Download
|
{t.common.download}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Check, Copy, Loader2 } from "lucide-react";
|
import { Check, Copy, Loader2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { copyToClipboard } from "@/lib/utils";
|
import { copyToClipboard } from "@/lib/utils";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
export function ColorPaletteSettings() {
|
export function ColorPaletteSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
const { files, processing, error, setProcessing, setError } = useFileStore();
|
||||||
const [colors, setColors] = useState<string[]>([]);
|
const [colors, setColors] = useState<string[]>([]);
|
||||||
const [copiedIdx, setCopiedIdx] = useState<number | null>(null);
|
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"
|
className="w-6 h-6 rounded border border-border shrink-0"
|
||||||
style={{ backgroundColor: color }}
|
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 ? (
|
{copiedIdx === i ? (
|
||||||
<Check className="h-3 w-3 text-green-500 shrink-0" />
|
<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" />}
|
{channelsOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||||
Color Channels
|
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>
|
</button>
|
||||||
{channelsOpen && (
|
{channelsOpen && (
|
||||||
<div className="space-y-2 pl-1">
|
<div className="space-y-2 ps-1">
|
||||||
<SliderControl
|
<SliderControl
|
||||||
label="Red"
|
label="Red"
|
||||||
value={red}
|
value={red}
|
||||||
@@ -457,11 +457,9 @@ function SliderControl({
|
|||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<label htmlFor={id} className={`text-xs ${color || "text-muted-foreground"}`}>
|
<label htmlFor={id} className={`text-xs ${color || "text-muted-foreground"}`}>
|
||||||
{label}
|
{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>
|
</label>
|
||||||
<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">{value}</span>
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
type Model = "auto" | "ddcolor" | "opencv";
|
type Model = "auto" | "ddcolor" | "opencv";
|
||||||
@@ -13,6 +15,7 @@ const MODEL_OPTIONS: { value: Model; label: string; desc: string }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function ColorizeSettings() {
|
export function ColorizeSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const {
|
const {
|
||||||
processFiles,
|
processFiles,
|
||||||
@@ -83,7 +86,7 @@ export function ColorizeSettings() {
|
|||||||
? "Natural"
|
? "Natural"
|
||||||
: "Vivid"}
|
: "Vivid"}
|
||||||
</span>
|
</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}%
|
{intensity}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,7 +129,9 @@ export function ColorizeSettings() {
|
|||||||
disabled={!hasFile || processing}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Download, Loader2, Upload } from "lucide-react";
|
import { Download, Loader2, Upload } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
export function CompareSettings() {
|
export function CompareSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files, processing, error, setProcessing, setError, setProcessedUrl } = useFileStore();
|
const { files, processing, error, setProcessing, setError, setProcessedUrl } = useFileStore();
|
||||||
const [secondFile, setSecondFile] = useState<File | null>(null);
|
const [secondFile, setSecondFile] = useState<File | null>(null);
|
||||||
const [similarity, setSimilarity] = useState<number | null>(null);
|
const [similarity, setSimilarity] = useState<number | null>(null);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Download, Loader2, Upload } from "lucide-react";
|
import { Download, Loader2, Upload } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
export function ComposeSettings() {
|
export function ComposeSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
||||||
useFileStore();
|
useFileStore();
|
||||||
const [overlayFile, setOverlayFile] = useState<File | null>(null);
|
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"
|
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
{processing ? "Processing..." : "Compose"}
|
{processing ? "Processing..." : t.toolSettings.compose.submit}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{downloadUrl && (
|
{downloadUrl && (
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Download, Minus, Plus } from "lucide-react";
|
import { Download, Minus, Plus } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
type CompressMode = "quality" | "targetSize";
|
type CompressMode = "quality" | "targetSize";
|
||||||
@@ -13,6 +15,7 @@ export interface CompressControlsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CompressControls({ settings: initialSettings, onChange }: CompressControlsProps) {
|
export function CompressControls({ settings: initialSettings, onChange }: CompressControlsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [mode, setMode] = useState<CompressMode>("targetSize");
|
const [mode, setMode] = useState<CompressMode>("targetSize");
|
||||||
const [quality, setQuality] = useState(75);
|
const [quality, setQuality] = useState(75);
|
||||||
const [targetSizeValue, setTargetSizeValue] = useState("");
|
const [targetSizeValue, setTargetSizeValue] = useState("");
|
||||||
@@ -47,21 +50,23 @@ export function CompressControls({ settings: initialSettings, onChange }: Compre
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Mode toggle */}
|
{/* Mode toggle */}
|
||||||
<div>
|
<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">
|
<div className="flex gap-1 mt-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMode("targetSize")}
|
onClick={() => setMode("targetSize")}
|
||||||
className={`flex-1 text-xs py-1.5 rounded ${mode === "targetSize" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMode("quality")}
|
onClick={() => setMode("quality")}
|
||||||
className={`flex-1 text-xs py-1.5 rounded ${mode === "quality" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,7 +74,7 @@ export function CompressControls({ settings: initialSettings, onChange }: Compre
|
|||||||
{mode === "targetSize" ? (
|
{mode === "targetSize" ? (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="compress-target-size" className="text-xs text-muted-foreground">
|
<label htmlFor="compress-target-size" className="text-xs text-muted-foreground">
|
||||||
Target Size
|
{t.toolSettings.compress.targetSize}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-1.5 mt-0.5">
|
<div className="flex gap-1.5 mt-0.5">
|
||||||
<input
|
<input
|
||||||
@@ -127,8 +132,8 @@ export function CompressControls({ settings: initialSettings, onChange }: Compre
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
|
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
|
||||||
<span>Smallest file</span>
|
<span>{t.toolSettings.compress.smallestFile}</span>
|
||||||
<span>Best quality</span>
|
<span>{t.toolSettings.compress.bestQuality}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -137,6 +142,7 @@ export function CompressControls({ settings: initialSettings, onChange }: Compre
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CompressSettings() {
|
export function CompressSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const {
|
const {
|
||||||
processFiles,
|
processFiles,
|
||||||
@@ -178,10 +184,17 @@ export function CompressSettings() {
|
|||||||
{/* Size info */}
|
{/* Size info */}
|
||||||
{originalSize != null && processedSize != null && (
|
{originalSize != null && processedSize != null && (
|
||||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||||
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
<p>
|
||||||
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -191,7 +204,7 @@ export function CompressSettings() {
|
|||||||
<ProgressCard
|
<ProgressCard
|
||||||
active={processing}
|
active={processing}
|
||||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||||
label="Compressing"
|
label={t.toolSettings.compress.progressLabel}
|
||||||
stage={progress.stage}
|
stage={progress.stage}
|
||||||
percent={progress.percent}
|
percent={progress.percent}
|
||||||
elapsed={progress.elapsed}
|
elapsed={progress.elapsed}
|
||||||
@@ -203,7 +216,9 @@ export function CompressSettings() {
|
|||||||
disabled={!hasFile || !canProcess || processing}
|
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"
|
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>
|
</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"
|
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 className="h-4 w-4" />
|
||||||
Download
|
{t.common.download}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Download, Info } from "lucide-react";
|
import { Download, Info } from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
function HintIcon({ text }: { text: string }) {
|
function HintIcon({ text }: { text: string }) {
|
||||||
@@ -162,6 +164,7 @@ export function ContentAwareResizeControls({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ContentAwareResizeSettings() {
|
export function ContentAwareResizeSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||||
useToolProcessor("content-aware-resize");
|
useToolProcessor("content-aware-resize");
|
||||||
@@ -200,7 +203,7 @@ export function ContentAwareResizeSettings() {
|
|||||||
<ProgressCard
|
<ProgressCard
|
||||||
active={processing}
|
active={processing}
|
||||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||||
label="Content-aware resizing"
|
label={t.toolSettings["content-aware-resize"].progressLabel}
|
||||||
stage={progress.stage}
|
stage={progress.stage}
|
||||||
percent={progress.percent}
|
percent={progress.percent}
|
||||||
elapsed={progress.elapsed}
|
elapsed={progress.elapsed}
|
||||||
@@ -212,7 +215,9 @@ export function ContentAwareResizeSettings() {
|
|||||||
disabled={!canProcess}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
const OUTPUT_FORMATS = [
|
const OUTPUT_FORMATS = [
|
||||||
@@ -43,6 +45,7 @@ export interface ConvertControlsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ConvertControls({ settings: initialSettings, onChange }: ConvertControlsProps) {
|
export function ConvertControls({ settings: initialSettings, onChange }: ConvertControlsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [format, setFormat] = useState<string>("png");
|
const [format, setFormat] = useState<string>("png");
|
||||||
const [quality, setQuality] = useState(85);
|
const [quality, setQuality] = useState(85);
|
||||||
|
|
||||||
@@ -74,7 +77,7 @@ export function ConvertControls({ settings: initialSettings, onChange }: Convert
|
|||||||
{/* Target format */}
|
{/* Target format */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="convert-target-format" className="text-xs text-muted-foreground">
|
<label htmlFor="convert-target-format" className="text-xs text-muted-foreground">
|
||||||
Target Format
|
{t.toolSettings.convert.targetFormat}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="convert-target-format"
|
id="convert-target-format"
|
||||||
@@ -115,6 +118,7 @@ export function ConvertControls({ settings: initialSettings, onChange }: Convert
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ConvertSettings() {
|
export function ConvertSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const {
|
const {
|
||||||
processFiles,
|
processFiles,
|
||||||
@@ -154,7 +158,7 @@ export function ConvertSettings() {
|
|||||||
{/* Source format */}
|
{/* Source format */}
|
||||||
{hasFile && (
|
{hasFile && (
|
||||||
<div>
|
<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">
|
<div className="mt-0.5 px-2 py-1.5 rounded bg-muted text-sm text-foreground uppercase font-mono">
|
||||||
{sourceExt}
|
{sourceExt}
|
||||||
</div>
|
</div>
|
||||||
@@ -179,7 +183,7 @@ export function ConvertSettings() {
|
|||||||
<ProgressCard
|
<ProgressCard
|
||||||
active={processing}
|
active={processing}
|
||||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||||
label="Converting"
|
label={t.toolSettings.convert.progressLabel}
|
||||||
stage={progress.stage}
|
stage={progress.stage}
|
||||||
percent={progress.percent}
|
percent={progress.percent}
|
||||||
elapsed={progress.elapsed}
|
elapsed={progress.elapsed}
|
||||||
@@ -191,7 +195,9 @@ export function ConvertSettings() {
|
|||||||
disabled={!hasFile || processing}
|
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"
|
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>
|
</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"
|
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 className="h-4 w-4" />
|
||||||
Download
|
{t.common.download}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { ArrowLeftRight, Download, Grid3x3 } from "lucide-react";
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { Crop } from "react-image-crop";
|
import type { Crop } from "react-image-crop";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
const ASPECT_PRESETS = [
|
const ASPECT_PRESETS = [
|
||||||
@@ -34,6 +36,7 @@ export function CropSettings({
|
|||||||
onAspectChange,
|
onAspectChange,
|
||||||
onGridToggle,
|
onGridToggle,
|
||||||
}: CropSettingsProps) {
|
}: CropSettingsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||||
useToolProcessor("crop");
|
useToolProcessor("crop");
|
||||||
@@ -216,7 +219,7 @@ export function CropSettings({
|
|||||||
{/* Aspect Ratio */}
|
{/* Aspect Ratio */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<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 && (
|
{aspect !== undefined && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -288,7 +291,7 @@ export function CropSettings({
|
|||||||
|
|
||||||
{/* Position & Size */}
|
{/* Position & Size */}
|
||||||
<div>
|
<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 className="grid grid-cols-2 gap-2 mt-1">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="crop-x" className="text-[10px] text-muted-foreground">
|
<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"
|
className="accent-primary h-3.5 w-3.5"
|
||||||
/>
|
/>
|
||||||
<Grid3x3 className="h-3.5 w-3.5" />
|
<Grid3x3 className="h-3.5 w-3.5" />
|
||||||
Rule of Thirds
|
{t.toolSettings.crop.ruleOfThirds}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
@@ -369,7 +372,7 @@ export function CropSettings({
|
|||||||
<ProgressCard
|
<ProgressCard
|
||||||
active={processing}
|
active={processing}
|
||||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||||
label="Cropping"
|
label={t.toolSettings.crop.progressLabel}
|
||||||
stage={progress.stage}
|
stage={progress.stage}
|
||||||
percent={progress.percent}
|
percent={progress.percent}
|
||||||
elapsed={progress.elapsed}
|
elapsed={progress.elapsed}
|
||||||
@@ -381,7 +384,9 @@ export function CropSettings({
|
|||||||
disabled={!canSubmit}
|
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"
|
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>
|
</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"
|
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 className="h-4 w-4" />
|
||||||
Download
|
{t.common.download}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
@@ -409,6 +414,7 @@ export interface CropControlsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CropControls({ settings: initialSettings, onChange }: CropControlsProps) {
|
export function CropControls({ settings: initialSettings, onChange }: CropControlsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [left, setLeft] = useState(0);
|
const [left, setLeft] = useState(0);
|
||||||
const [top, setTop] = useState(0);
|
const [top, setTop] = useState(0);
|
||||||
const [width, setWidth] = useState("");
|
const [width, setWidth] = useState("");
|
||||||
@@ -443,7 +449,7 @@ export function CropControls({ settings: initialSettings, onChange }: CropContro
|
|||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="pipeline-crop-left" className="text-xs text-muted-foreground">
|
<label htmlFor="pipeline-crop-left" className="text-xs text-muted-foreground">
|
||||||
Left offset (px)
|
{t.toolSettings.crop.leftOffsetPx}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="pipeline-crop-left"
|
id="pipeline-crop-left"
|
||||||
@@ -456,7 +462,7 @@ export function CropControls({ settings: initialSettings, onChange }: CropContro
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="pipeline-crop-top" className="text-xs text-muted-foreground">
|
<label htmlFor="pipeline-crop-top" className="text-xs text-muted-foreground">
|
||||||
Top offset (px)
|
{t.toolSettings.crop.topOffsetPx}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="pipeline-crop-top"
|
id="pipeline-crop-top"
|
||||||
|
|||||||
@@ -776,7 +776,7 @@ export function EditMetadataSettings() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => loadTemplate(t.name)}
|
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}
|
{t.name}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function EnhanceFacesControls({
|
|||||||
/>
|
/>
|
||||||
<span className="text-sm text-foreground">Only enhance main face</span>
|
<span className="text-sm text-foreground">Only enhance main face</span>
|
||||||
</label>
|
</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
|
For portraits - ignores background faces
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Download, Redo, Trash2 } from "lucide-react";
|
import { Download, Redo, Trash2 } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { generateId } from "@/lib/utils";
|
import { generateId } from "@/lib/utils";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
import type { EraserCanvasRef } from "./eraser-canvas";
|
import type { EraserCanvasRef } from "./eraser-canvas";
|
||||||
@@ -36,6 +38,7 @@ export function EraseObjectSettings({
|
|||||||
onMaskCenter,
|
onMaskCenter,
|
||||||
maskedFileCount,
|
maskedFileCount,
|
||||||
}: EraseObjectSettingsProps) {
|
}: EraseObjectSettingsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
files,
|
files,
|
||||||
entries,
|
entries,
|
||||||
@@ -386,7 +389,7 @@ export function EraseObjectSettings({
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<label htmlFor="eraser-brush-size" className="text-xs text-muted-foreground">
|
<label htmlFor="eraser-brush-size" className="text-xs text-muted-foreground">
|
||||||
Brush Size
|
{t.toolSettings["erase-object"].brushSize}
|
||||||
</label>
|
</label>
|
||||||
<span className="text-xs font-mono text-foreground">{brushSize}px</span>
|
<span className="text-xs font-mono text-foreground">{brushSize}px</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -400,8 +403,8 @@ export function EraseObjectSettings({
|
|||||||
className="w-full mt-1"
|
className="w-full mt-1"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
|
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
|
||||||
<span>Fine</span>
|
<span>{t.toolSettings["erase-object"].fine}</span>
|
||||||
<span>Wide</span>
|
<span>{t.toolSettings["erase-object"].wide}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -493,7 +496,7 @@ export function EraseObjectSettings({
|
|||||||
<ProgressCard
|
<ProgressCard
|
||||||
active={processing}
|
active={processing}
|
||||||
phase={progressPhase === "idle" ? "uploading" : progressPhase}
|
phase={progressPhase === "idle" ? "uploading" : progressPhase}
|
||||||
label={progressStage || "Erasing object"}
|
label={progressStage || t.toolSettings["erase-object"].progressLabel}
|
||||||
percent={progressPercent}
|
percent={progressPercent}
|
||||||
elapsed={elapsed}
|
elapsed={elapsed}
|
||||||
/>
|
/>
|
||||||
@@ -505,7 +508,9 @@ export function EraseObjectSettings({
|
|||||||
disabled={!hasFile || (!hasStrokes && maskedFileCount === 0) || processing}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { Download } from "lucide-react";
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { flushSync } from "react-dom";
|
import { flushSync } from "react-dom";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
const SIZES = [
|
const SIZES = [
|
||||||
@@ -16,6 +18,7 @@ const SIZES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function FaviconSettings() {
|
export function FaviconSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files, error, setProcessing, setError } = useFileStore();
|
const { files, error, setProcessing, setError } = useFileStore();
|
||||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -128,13 +131,14 @@ export function FaviconSettings() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Upload square images (recommended 512x512 or larger) to generate all favicon and app icon
|
{t.toolSettings.favicon.uploadHint}{" "}
|
||||||
sizes.{" "}
|
{files.length > 1 && format(t.toolSettings.favicon.multipleHint, { count: files.length })}
|
||||||
{files.length > 1 && `Each of the ${files.length} images gets its own folder in the ZIP.`}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div>
|
<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">
|
<div className="mt-1 space-y-0.5">
|
||||||
{SIZES.map((s) => (
|
{SIZES.map((s) => (
|
||||||
<div key={s.name} className="flex justify-between text-xs text-foreground">
|
<div key={s.name} className="flex justify-between text-xs text-foreground">
|
||||||
@@ -143,7 +147,9 @@ export function FaviconSettings() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
@@ -152,7 +158,7 @@ export function FaviconSettings() {
|
|||||||
<ProgressCard
|
<ProgressCard
|
||||||
active={busy}
|
active={busy}
|
||||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||||
label="Generating Favicons"
|
label={t.toolSettings.favicon.progressLabel}
|
||||||
stage={
|
stage={
|
||||||
progress.phase === "uploading"
|
progress.phase === "uploading"
|
||||||
? "Uploading images..."
|
? "Uploading images..."
|
||||||
@@ -169,7 +175,9 @@ export function FaviconSettings() {
|
|||||||
disabled={!hasFiles || busy}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ArrowLeft, ChevronLeft, ChevronRight, Crown, Search } from "lucide-react";
|
import { ArrowLeft, ChevronLeft, ChevronRight, Crown, Search } from "lucide-react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { formatFileSize } from "@/lib/download";
|
import { formatFileSize } from "@/lib/download";
|
||||||
import type { DuplicateFileInfo } from "@/stores/duplicate-store";
|
import type { DuplicateFileInfo } from "@/stores/duplicate-store";
|
||||||
import { useDuplicateStore } from "@/stores/duplicate-store";
|
import { useDuplicateStore } from "@/stores/duplicate-store";
|
||||||
@@ -63,7 +64,7 @@ function OverviewGrid() {
|
|||||||
key={group.groupId}
|
key={group.groupId}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedGroup(gi)}
|
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 justify-between items-center mb-2.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -190,7 +191,7 @@ function DetailComparison() {
|
|||||||
key={file.filename}
|
key={file.filename}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => overrideBest(selectedGroupIndex, fi)}
|
onClick={() => overrideBest(selectedGroupIndex, fi)}
|
||||||
className="text-left"
|
className="text-start"
|
||||||
title={isCurrentBest ? "Selected as best" : "Click to mark as best"}
|
title={isCurrentBest ? "Selected as best" : "Click to mark as best"}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -219,18 +220,16 @@ function DetailComparison() {
|
|||||||
<p className="font-medium text-foreground truncate">{file.filename}</p>
|
<p className="font-medium text-foreground truncate">{file.filename}</p>
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
|
||||||
<span className="text-muted-foreground">Dimensions</span>
|
<span className="text-muted-foreground">Dimensions</span>
|
||||||
<span className="text-foreground text-right">
|
<span className="text-foreground text-end">
|
||||||
{file.width} x {file.height}
|
{file.width} x {file.height}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground">File size</span>
|
<span className="text-muted-foreground">File size</span>
|
||||||
<span className="text-foreground text-right">
|
<span className="text-foreground text-end">{formatFileSize(file.fileSize)}</span>
|
||||||
{formatFileSize(file.fileSize)}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">Format</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-muted-foreground">Similarity</span>
|
||||||
<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}%
|
{file.similarity}%
|
||||||
</span>
|
</span>
|
||||||
@@ -252,6 +251,7 @@ function DetailComparison() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FindDuplicatesResults() {
|
export function FindDuplicatesResults() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { results, scanning, viewMode } = useDuplicateStore();
|
const { results, scanning, viewMode } = useDuplicateStore();
|
||||||
|
|
||||||
if (scanning) {
|
if (scanning) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Download, FolderArchive, Loader2 } from "lucide-react";
|
import { Download, FolderArchive, Loader2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { formatFileSize } from "@/lib/download";
|
import { formatFileSize } from "@/lib/download";
|
||||||
import type { DuplicateResult } from "@/stores/duplicate-store";
|
import type { DuplicateResult } from "@/stores/duplicate-store";
|
||||||
@@ -15,6 +16,7 @@ const PRESET_DESCRIPTIONS: Record<Preset, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function FindDuplicatesSettings() {
|
export function FindDuplicatesSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const {
|
const {
|
||||||
results,
|
results,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Download, FlipHorizontal2, FlipVertical2, Link, RotateCw, Unlink } from "lucide-react";
|
import { Download, FlipHorizontal2, FlipVertical2, Link, RotateCw, Unlink } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useGifInfo } from "@/hooks/use-gif-info";
|
import { useGifInfo } from "@/hooks/use-gif-info";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
type GifMode = "resize" | "optimize" | "speed" | "reverse" | "extract" | "rotate";
|
type GifMode = "resize" | "optimize" | "speed" | "reverse" | "extract" | "rotate";
|
||||||
@@ -25,6 +27,7 @@ export interface GifToolsControlsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GifToolsControls({ settings: initialSettings, onChange }: GifToolsControlsProps) {
|
export function GifToolsControls({ settings: initialSettings, onChange }: GifToolsControlsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { info, loading: infoLoading } = useGifInfo();
|
const { info, loading: infoLoading } = useGifInfo();
|
||||||
const isAnimated = (info?.pages ?? 0) > 1;
|
const isAnimated = (info?.pages ?? 0) > 1;
|
||||||
|
|
||||||
@@ -643,6 +646,7 @@ export function GifToolsControls({ settings: initialSettings, onChange }: GifToo
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GifToolsSettings() {
|
export function GifToolsSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const {
|
const {
|
||||||
processFiles,
|
processFiles,
|
||||||
@@ -678,7 +682,7 @@ export function GifToolsSettings() {
|
|||||||
<p>
|
<p>
|
||||||
Processed: {(processedSize / 1024).toFixed(1)} KB
|
Processed: {(processedSize / 1024).toFixed(1)} KB
|
||||||
{originalSize > 0 && (
|
{originalSize > 0 && (
|
||||||
<span className="ml-1">
|
<span className="ms-1">
|
||||||
({Math.round(((processedSize - originalSize) / originalSize) * 100)}%)
|
({Math.round(((processedSize - originalSize) / originalSize) * 100)}%)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -690,7 +694,7 @@ export function GifToolsSettings() {
|
|||||||
<ProgressCard
|
<ProgressCard
|
||||||
active={processing}
|
active={processing}
|
||||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||||
label="Processing GIF"
|
label={t.toolSettings["gif-tools"].progressLabel}
|
||||||
stage={progress.stage}
|
stage={progress.stage}
|
||||||
percent={progress.percent}
|
percent={progress.percent}
|
||||||
elapsed={progress.elapsed}
|
elapsed={progress.elapsed}
|
||||||
@@ -703,7 +707,9 @@ export function GifToolsSettings() {
|
|||||||
disabled={!hasFile || processing}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
type EnhancementMode = "auto" | "portrait" | "landscape" | "low-light" | "food" | "document";
|
type EnhancementMode = "auto" | "portrait" | "landscape" | "low-light" | "food" | "document";
|
||||||
@@ -144,6 +146,7 @@ export function ImageEnhancementControls({
|
|||||||
onChange,
|
onChange,
|
||||||
onPreviewFilter,
|
onPreviewFilter,
|
||||||
}: ImageEnhancementControlsProps) {
|
}: ImageEnhancementControlsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const [mode, setMode] = useState<EnhancementMode>("auto");
|
const [mode, setMode] = useState<EnhancementMode>("auto");
|
||||||
const [intensity, setIntensity] = useState(50);
|
const [intensity, setIntensity] = useState(50);
|
||||||
@@ -283,7 +286,7 @@ export function ImageEnhancementControls({
|
|||||||
|
|
||||||
{/* Mode selector */}
|
{/* Mode selector */}
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
|
||||||
Enhancement Mode
|
{t.toolSettings.imageEnhancement.enhancementMode}
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-3 gap-1">
|
<div className="grid grid-cols-3 gap-1">
|
||||||
{MODES.map(({ value, label, icon: Icon }) => (
|
{MODES.map(({ value, label, icon: Icon }) => (
|
||||||
@@ -307,7 +310,7 @@ export function ImageEnhancementControls({
|
|||||||
<div className="pt-1">
|
<div className="pt-1">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
|
||||||
Intensity
|
{t.toolSettings.imageEnhancement.intensity}
|
||||||
</p>
|
</p>
|
||||||
<span className="text-xs font-mono text-foreground tabular-nums">{intensity}%</span>
|
<span className="text-xs font-mono text-foreground tabular-nums">{intensity}%</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -326,7 +329,7 @@ export function ImageEnhancementControls({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Wand2 className="h-3.5 w-3.5 text-muted-foreground" />
|
<Wand2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<div>
|
<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">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
Removes noise and artifacts using AI
|
Removes noise and artifacts using AI
|
||||||
</p>
|
</p>
|
||||||
@@ -358,7 +361,7 @@ export function ImageEnhancementControls({
|
|||||||
{analysis && !analyzing && (
|
{analysis && !analyzing && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
|
||||||
Detected Issues
|
{t.toolSettings.imageEnhancement.detectedIssues}
|
||||||
</p>
|
</p>
|
||||||
{analysis.issues.length === 0 ? (
|
{analysis.issues.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Check, ClipboardCopy, Download, FileJson, FileText, Loader2 } from "lucide-react";
|
import { Check, ClipboardCopy, Download, FileJson, FileText, Loader2 } from "lucide-react";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import type { Base64Result } from "@/stores/base64-store";
|
import type { Base64Result } from "@/stores/base64-store";
|
||||||
import { useBase64Store } from "@/stores/base64-store";
|
import { useBase64Store } from "@/stores/base64-store";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
@@ -168,6 +169,7 @@ function FileResult({ result }: { result: Base64Result }) {
|
|||||||
// -- Main ResultsPanel ------------------------------------------------------
|
// -- Main ResultsPanel ------------------------------------------------------
|
||||||
|
|
||||||
export function ImageToBase64Results() {
|
export function ImageToBase64Results() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { results, errors, processing, progress } = useBase64Store();
|
const { results, errors, processing, progress } = useBase64Store();
|
||||||
const { entries, selectedIndex, originalBlobUrl, selectedFileName } = useFileStore();
|
const { entries, selectedIndex, originalBlobUrl, selectedFileName } = useFileStore();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useBase64Store } from "@/stores/base64-store";
|
import { useBase64Store } from "@/stores/base64-store";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ const OUTPUT_FORMATS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function ImageToBase64Settings() {
|
export function ImageToBase64Settings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { processing, setProcessing, setProgress, addResult, addError, reset } = useBase64Store();
|
const { processing, setProcessing, setProgress, addResult, addError, reset } = useBase64Store();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ interface ImageInfoData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function InfoSettings() {
|
export function InfoSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
const { files, processing, error, setProcessing, setError } = useFileStore();
|
||||||
const selectedIndex = useFileStore((s) => s.selectedIndex);
|
const selectedIndex = useFileStore((s) => s.selectedIndex);
|
||||||
const [info, setInfo] = useState<ImageInfoData | null>(null);
|
const [info, setInfo] = useState<ImageInfoData | null>(null);
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ function TemplateGallery() {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="Search templates..."
|
placeholder="Search templates..."
|
||||||
className={cn(INPUT_CLASS, "pl-8")}
|
className={cn(INPUT_CLASS, "ps-8")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ function TemplateGallery() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{cat.label}
|
{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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -281,7 +281,7 @@ function LayoutPicker() {
|
|||||||
data-testid={`layout-${key}`}
|
data-testid={`layout-${key}`}
|
||||||
onClick={() => setCustomLayout(key)}
|
onClick={() => setCustomLayout(key)}
|
||||||
className={cn(
|
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
|
selected === key
|
||||||
? "border-primary bg-primary/5"
|
? "border-primary bg-primary/5"
|
||||||
: "border-border hover:border-primary/40",
|
: "border-border hover:border-primary/40",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
FONT_OPTIONS,
|
FONT_OPTIONS,
|
||||||
@@ -327,6 +328,7 @@ function ResultSettings() {
|
|||||||
// ── Main Settings Component ─────────────────────────────────────────
|
// ── Main Settings Component ─────────────────────────────────────────
|
||||||
|
|
||||||
export function MemeGeneratorSettings() {
|
export function MemeGeneratorSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const phase = useMemeStore((s) => s.phase);
|
const phase = useMemeStore((s) => s.phase);
|
||||||
|
|
||||||
if (phase === "gallery") return <GallerySettings />;
|
if (phase === "gallery") return <GallerySettings />;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
type Tier = "quick" | "balanced" | "quality" | "maximum";
|
type Tier = "quick" | "balanced" | "quality" | "maximum";
|
||||||
@@ -203,6 +205,7 @@ export function NoiseRemovalControls({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function NoiseRemovalSettings() {
|
export function NoiseRemovalSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files, entries } = useFileStore();
|
const { files, entries } = useFileStore();
|
||||||
const {
|
const {
|
||||||
processFiles,
|
processFiles,
|
||||||
@@ -271,7 +274,9 @@ export function NoiseRemovalSettings() {
|
|||||||
disabled={!hasFile || processing}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Check, ChevronDown, ChevronRight, Copy, Download, Info } from "lucide-react";
|
import { Check, ChevronDown, ChevronRight, Copy, Download, Info } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { copyToClipboard, generateId } from "@/lib/utils";
|
import { copyToClipboard, generateId } from "@/lib/utils";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
@@ -100,6 +102,7 @@ function ocrOneFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function OcrSettings() {
|
export function OcrSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
const { files, processing, error, setProcessing, setError } = useFileStore();
|
||||||
|
|
||||||
const [quality, setQuality] = useState<OcrQuality>("balanced");
|
const [quality, setQuality] = useState<OcrQuality>("balanced");
|
||||||
@@ -219,7 +222,7 @@ export function OcrSettings() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Quality selector */}
|
{/* Quality selector */}
|
||||||
<SectionLabel>Quality</SectionLabel>
|
<SectionLabel>{t.toolSettings.ocr.quality}</SectionLabel>
|
||||||
<div className="grid grid-cols-3 gap-1.5">
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
{QUALITY_OPTIONS.map((opt) => (
|
{QUALITY_OPTIONS.map((opt) => (
|
||||||
<button
|
<button
|
||||||
@@ -245,7 +248,9 @@ export function OcrSettings() {
|
|||||||
onChange={(e) => handleEnhanceToggle(e.target.checked)}
|
onChange={(e) => handleEnhanceToggle(e.target.checked)}
|
||||||
className="rounded border-border accent-primary"
|
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
|
<span
|
||||||
title="Automatically deskews, enhances contrast, removes noise, and upscales the image before scanning for better accuracy."
|
title="Automatically deskews, enhances contrast, removes noise, and upscales the image before scanning for better accuracy."
|
||||||
className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-muted-foreground/40 text-muted-foreground/60 text-[10px] cursor-help"
|
className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-muted-foreground/40 text-muted-foreground/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" />}
|
{langOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||||
Language
|
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}
|
{langLabel}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -290,7 +295,7 @@ export function OcrSettings() {
|
|||||||
<ProgressCard
|
<ProgressCard
|
||||||
active={processing}
|
active={processing}
|
||||||
phase={progressPhase === "idle" ? "uploading" : progressPhase}
|
phase={progressPhase === "idle" ? "uploading" : progressPhase}
|
||||||
label="Extracting text"
|
label={t.toolSettings.ocr.progressLabel}
|
||||||
stage={progressStage}
|
stage={progressStage}
|
||||||
percent={progressPercent}
|
percent={progressPercent}
|
||||||
elapsed={elapsed}
|
elapsed={elapsed}
|
||||||
@@ -303,7 +308,9 @@ export function OcrSettings() {
|
|||||||
disabled={!hasFile || processing}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -311,7 +318,9 @@ export function OcrSettings() {
|
|||||||
{text !== null && (
|
{text !== null && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
{text.length > 0 && (
|
{text.length > 0 && (
|
||||||
<button
|
<button
|
||||||
@@ -342,7 +351,9 @@ export function OcrSettings() {
|
|||||||
rows={Math.min(16, Math.max(8, text.split("\n").length + 2))}
|
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"
|
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">
|
<p className="text-xs text-muted-foreground italic py-4 text-center">
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ export function OptimizeForWebSettings() {
|
|||||||
{preview.processedSize != null && (
|
{preview.processedSize != null && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Optimized: {formatSize(preview.processedSize)}
|
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]}
|
{FORMAT_LABELS[format]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
@@ -324,7 +325,7 @@ function CountryOption({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{spec.flag}</span>
|
<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]">
|
<span className="text-muted-foreground/60 tabular-nums text-[10px]">
|
||||||
{formatDimensions(doc)}
|
{formatDimensions(doc)}
|
||||||
</span>
|
</span>
|
||||||
@@ -336,6 +337,7 @@ function CountryOption({
|
|||||||
// ── Settings panel (left side) ─────────────────────────────────────
|
// ── Settings panel (left side) ─────────────────────────────────────
|
||||||
|
|
||||||
export function PassportPhotoSettings() {
|
export function PassportPhotoSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { error } = useToolProcessor("passport-photo");
|
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"
|
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>{selectedSpec.flag}</span>
|
||||||
<span className="flex-1 text-left truncate">
|
<span className="flex-1 text-start truncate">
|
||||||
{selectedSpec.name}
|
{selectedSpec.name}
|
||||||
<span className="text-muted-foreground ml-1.5 text-xs">
|
<span className="text-muted-foreground ms-1.5 text-xs">
|
||||||
{formatDimensions(docSpec)}
|
{formatDimensions(docSpec)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -605,7 +607,7 @@ export function PassportPhotoSettings() {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="Search countries..."
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -626,7 +628,7 @@ export function PassportPhotoSettings() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{"\u2699\uFE0F"}</span>
|
<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" />}
|
{isCustom && <Check className="h-3 w-3 text-primary shrink-0" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -894,6 +896,7 @@ export function PassportPhotoSettings() {
|
|||||||
// ── Preview panel (right side) ────────────────────────────────────
|
// ── Preview panel (right side) ────────────────────────────────────
|
||||||
|
|
||||||
export function PassportPhotoPreview() {
|
export function PassportPhotoPreview() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
analyzeResult,
|
analyzeResult,
|
||||||
countryCode,
|
countryCode,
|
||||||
@@ -1154,7 +1157,7 @@ export function PassportPhotoPreview() {
|
|||||||
<RotateCcw className="h-3.5 w-3.5" />
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
</button>
|
</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
|
{pxDims.w}x{pxDims.h}px
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function PdfToImagePreview() {
|
|||||||
{store.results.length} page
|
{store.results.length} page
|
||||||
{store.results.length !== 1 ? "s" : ""} converted
|
{store.results.length !== 1 ? "s" : ""} converted
|
||||||
{totalSize > 0 && (
|
{totalSize > 0 && (
|
||||||
<span className="text-muted-foreground font-normal ml-1">
|
<span className="text-muted-foreground font-normal ms-1">
|
||||||
({formatSize(totalSize)})
|
({formatSize(totalSize)})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -187,7 +187,7 @@ export function PdfToImagePreview() {
|
|||||||
key={thumb.page}
|
key={thumb.page}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => store.togglePage(thumb.page)}
|
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
|
isSelected
|
||||||
? "border-primary ring-1 ring-primary/30"
|
? "border-primary ring-1 ring-primary/30"
|
||||||
: "border-border opacity-50 hover:opacity-75"
|
: "border-border opacity-50 hover:opacity-75"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Download, FileUp, Loader2, X } from "lucide-react";
|
import { Download, FileUp, Loader2, X } from "lucide-react";
|
||||||
import { useCallback, useRef } from "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";
|
import { usePdfToImageStore } from "@/stores/pdf-to-image-store";
|
||||||
|
|
||||||
const FORMAT_OPTIONS = [
|
const FORMAT_OPTIONS = [
|
||||||
@@ -37,6 +39,7 @@ const COLOR_MODE_OPTIONS = [
|
|||||||
const LOSSY_FORMATS = ["jpg", "webp", "avif", "heic", "heif", "jxl"];
|
const LOSSY_FORMATS = ["jpg", "webp", "avif", "heic", "heif", "jxl"];
|
||||||
|
|
||||||
export function PdfToImageSettings() {
|
export function PdfToImageSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const store = usePdfToImageStore();
|
const store = usePdfToImageStore();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
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"
|
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" />
|
<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
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -115,7 +118,9 @@ export function PdfToImageSettings() {
|
|||||||
|
|
||||||
{/* Output Format - grid buttons */}
|
{/* Output Format - grid buttons */}
|
||||||
<div>
|
<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">
|
<div className="grid grid-cols-4 gap-1">
|
||||||
{FORMAT_OPTIONS.map((opt) => (
|
{FORMAT_OPTIONS.map((opt) => (
|
||||||
<button
|
<button
|
||||||
@@ -138,7 +143,9 @@ export function PdfToImageSettings() {
|
|||||||
{isLossy && (
|
{isLossy && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center">
|
<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>
|
<span className="text-xs font-mono text-foreground">{store.quality}</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -154,7 +161,9 @@ export function PdfToImageSettings() {
|
|||||||
|
|
||||||
{/* DPI presets + custom */}
|
{/* DPI presets + custom */}
|
||||||
<div>
|
<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">
|
<div className="grid grid-cols-5 gap-1">
|
||||||
{DPI_PRESETS.map((opt) => (
|
{DPI_PRESETS.map((opt) => (
|
||||||
<button
|
<button
|
||||||
@@ -204,7 +213,9 @@ export function PdfToImageSettings() {
|
|||||||
|
|
||||||
{/* Color Mode */}
|
{/* Color Mode */}
|
||||||
<div>
|
<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">
|
<div className="grid grid-cols-3 gap-1">
|
||||||
{COLOR_MODE_OPTIONS.map((opt) => (
|
{COLOR_MODE_OPTIONS.map((opt) => (
|
||||||
<button
|
<button
|
||||||
@@ -226,7 +237,7 @@ export function PdfToImageSettings() {
|
|||||||
{/* Page range input */}
|
{/* Page range input */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="pdf-pages" className="text-xs text-muted-foreground">
|
<label htmlFor="pdf-pages" className="text-xs text-muted-foreground">
|
||||||
Pages
|
{t.toolSettings["pdf-to-image"].pages}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="pdf-pages"
|
id="pdf-pages"
|
||||||
@@ -256,7 +267,7 @@ export function PdfToImageSettings() {
|
|||||||
>
|
>
|
||||||
{store.processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
{store.processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
{store.processing
|
{store.processing
|
||||||
? "Converting..."
|
? t.toolSettings["pdf-to-image"].converting
|
||||||
: `Convert ${selectedCount} page${selectedCount !== 1 ? "s" : ""}`}
|
: `Convert ${selectedCount} page${selectedCount !== 1 ? "s" : ""}`}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import {
|
|||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { TOOLS } from "@snapotter/shared";
|
import { TOOLS } from "@snapotter/shared";
|
||||||
import { FileImage, GripVertical, X } from "lucide-react";
|
import { FileImage, GripVertical, X } from "lucide-react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { ICON_MAP } from "@/lib/icon-map";
|
import { ICON_MAP } from "@/lib/icon-map";
|
||||||
|
import { getToolName } from "@/lib/tool-i18n";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { PipelineStep } from "@/stores/pipeline-store";
|
import type { PipelineStep } from "@/stores/pipeline-store";
|
||||||
import { PipelineStepSettings } from "./pipeline-step-settings";
|
import { PipelineStepSettings } from "./pipeline-step-settings";
|
||||||
@@ -52,6 +54,7 @@ function SortableStep({
|
|||||||
onRemove,
|
onRemove,
|
||||||
onUpdateSettings,
|
onUpdateSettings,
|
||||||
}: SortableStepProps) {
|
}: SortableStepProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: step.id,
|
id: step.id,
|
||||||
});
|
});
|
||||||
@@ -85,7 +88,7 @@ function SortableStep({
|
|||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={onToggle}
|
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 */}
|
{/* Drag handle */}
|
||||||
{
|
{
|
||||||
@@ -108,11 +111,13 @@ function SortableStep({
|
|||||||
|
|
||||||
{/* Tool icon + name */}
|
{/* Tool icon + name */}
|
||||||
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
<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 */}
|
{/* Settings summary when collapsed */}
|
||||||
{!isExpanded && summary && (
|
{!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" />
|
<span className="flex-1" />
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import QRCodeStyling from "qr-code-styling";
|
import QRCodeStyling from "qr-code-styling";
|
||||||
import { useCallback, useRef } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
import { CollapsibleSection } from "@/components/common/collapsible-section";
|
import { CollapsibleSection } from "@/components/common/collapsible-section";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import {
|
import {
|
||||||
type ContentType,
|
type ContentType,
|
||||||
type CornerDotType,
|
type CornerDotType,
|
||||||
@@ -345,6 +346,7 @@ function PillButton({
|
|||||||
// ── Main settings component ──────────────────────────────────────────
|
// ── Main settings component ──────────────────────────────────────────
|
||||||
|
|
||||||
export function QrGenerateSettings() {
|
export function QrGenerateSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const store = useQrStore();
|
const store = useQrStore();
|
||||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -515,7 +517,7 @@ export function QrGenerateSettings() {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
{store.dotGradientEnabled && (
|
{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 gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label htmlFor="qr-gradient-from" className="text-[10px] text-muted-foreground">
|
<label htmlFor="qr-gradient-from" className="text-[10px] text-muted-foreground">
|
||||||
@@ -764,7 +766,7 @@ export function QrGenerateSettings() {
|
|||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => store.setDownloadFormat(value)}
|
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
|
store.downloadFormat === value
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "bg-muted text-muted-foreground hover:text-foreground"
|
: "bg-muted text-muted-foreground hover:text-foreground"
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
const LOSSY_FORMATS = new Set(["jpeg", "webp", "avif", "jxl"]);
|
const LOSSY_FORMATS = new Set(["jpeg", "webp", "avif", "jxl"]);
|
||||||
@@ -140,6 +142,7 @@ export function RedEyeRemovalControls({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RedEyeRemovalSettings() {
|
export function RedEyeRemovalSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const {
|
const {
|
||||||
processFiles,
|
processFiles,
|
||||||
@@ -196,7 +199,9 @@ export function RedEyeRemovalSettings() {
|
|||||||
disabled={!hasFile || processing}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
type SubjectType = "people" | "products" | "general";
|
type SubjectType = "people" | "products" | "general";
|
||||||
@@ -84,6 +86,7 @@ export interface RemoveBgControlsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgControlsProps) {
|
export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgControlsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [subject, setSubject] = useState<SubjectType>("people");
|
const [subject, setSubject] = useState<SubjectType>("people");
|
||||||
const [quality, setQuality] = useState<Quality>("balanced");
|
const [quality, setQuality] = useState<Quality>("balanced");
|
||||||
const [isPassport, setIsPassport] = useState(true);
|
const [isPassport, setIsPassport] = useState(true);
|
||||||
@@ -167,7 +170,7 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Subject type */}
|
{/* Subject type */}
|
||||||
<SectionLabel>Subject</SectionLabel>
|
<SectionLabel>{t.toolSettings["remove-background"].subject}</SectionLabel>
|
||||||
<div className="grid grid-cols-3 gap-1.5">
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
{SUBJECT_OPTIONS.map((opt) => {
|
{SUBJECT_OPTIONS.map((opt) => {
|
||||||
const Icon = opt.icon;
|
const Icon = opt.icon;
|
||||||
@@ -202,12 +205,14 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
|
|||||||
onChange={(e) => setIsPassport(e.target.checked)}
|
onChange={(e) => setIsPassport(e.target.checked)}
|
||||||
className="rounded border-border accent-primary"
|
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>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Quality */}
|
{/* 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"}`}>
|
<div className={`grid gap-1.5 ${qualityOptions.length > 3 ? "grid-cols-4" : "grid-cols-3"}`}>
|
||||||
{qualityOptions.map((opt) => (
|
{qualityOptions.map((opt) => (
|
||||||
<button
|
<button
|
||||||
@@ -226,7 +231,7 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Background */}
|
{/* Background */}
|
||||||
<SectionLabel>Background</SectionLabel>
|
<SectionLabel>{t.toolSettings["remove-background"].background}</SectionLabel>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Type buttons */}
|
{/* Type buttons */}
|
||||||
<div className="flex gap-1.5 flex-wrap">
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
@@ -258,7 +263,7 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
|
|||||||
|
|
||||||
{/* Color options */}
|
{/* Color options */}
|
||||||
{bgType === "color" && (
|
{bgType === "color" && (
|
||||||
<div className="space-y-2 pl-1">
|
<div className="space-y-2 ps-1">
|
||||||
<div className="flex gap-1.5 flex-wrap">
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
{COLOR_PRESETS.map((preset) => (
|
{COLOR_PRESETS.map((preset) => (
|
||||||
<button
|
<button
|
||||||
@@ -293,7 +298,7 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
|
|||||||
|
|
||||||
{/* Gradient options */}
|
{/* Gradient options */}
|
||||||
{bgType === "gradient" && (
|
{bgType === "gradient" && (
|
||||||
<div className="space-y-2 pl-1">
|
<div className="space-y-2 ps-1">
|
||||||
<div className="flex gap-1.5 flex-wrap">
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
{GRADIENT_PRESETS.map((preset) => (
|
{GRADIENT_PRESETS.map((preset) => (
|
||||||
<button
|
<button
|
||||||
@@ -351,7 +356,7 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
|
|||||||
|
|
||||||
{/* Image upload */}
|
{/* Image upload */}
|
||||||
{bgType === "image" && (
|
{bgType === "image" && (
|
||||||
<div className="pl-1">
|
<div className="ps-1">
|
||||||
{bgImageFile ? (
|
{bgImageFile ? (
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span className="text-foreground truncate flex-1">{bgImageFile.name}</span>
|
<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" />}
|
{effectsOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||||
Effects
|
Effects
|
||||||
{(blurEnabled || shadowEnabled) && (
|
{(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>
|
</button>
|
||||||
|
|
||||||
{effectsOpen && (
|
{effectsOpen && (
|
||||||
<div className="space-y-3 pl-1">
|
<div className="space-y-3 ps-1">
|
||||||
{/* Blur */}
|
{/* Blur */}
|
||||||
<div>
|
<div>
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<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)}
|
onChange={(e) => setBlurEnabled(e.target.checked)}
|
||||||
className="rounded border-border accent-primary"
|
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>
|
</label>
|
||||||
{blurEnabled && (
|
{blurEnabled && (
|
||||||
<div className="mt-1.5 pl-5">
|
<div className="mt-1.5 ps-5">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-xs text-muted-foreground">Intensity</span>
|
<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}
|
{blurIntensity}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -437,13 +444,15 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
|
|||||||
onChange={(e) => setShadowEnabled(e.target.checked)}
|
onChange={(e) => setShadowEnabled(e.target.checked)}
|
||||||
className="rounded border-border accent-primary"
|
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>
|
</label>
|
||||||
{shadowEnabled && (
|
{shadowEnabled && (
|
||||||
<div className="mt-1.5 pl-5">
|
<div className="mt-1.5 ps-5">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-xs text-muted-foreground">Opacity</span>
|
<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}
|
{shadowOpacity}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -526,6 +535,7 @@ interface RemoveBgSettingsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const {
|
const {
|
||||||
processFiles,
|
processFiles,
|
||||||
@@ -796,7 +806,7 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
|||||||
<ProgressCard
|
<ProgressCard
|
||||||
active={processing}
|
active={processing}
|
||||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||||
label="Removing background"
|
label={t.toolSettings["remove-background"].progressLabel}
|
||||||
stage={progress.stage}
|
stage={progress.stage}
|
||||||
percent={progress.percent}
|
percent={progress.percent}
|
||||||
elapsed={progress.elapsed}
|
elapsed={progress.elapsed}
|
||||||
@@ -809,7 +819,9 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
|||||||
disabled={!hasFile || processing}
|
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"
|
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>
|
</button>
|
||||||
) : null}
|
) : 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"
|
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" />
|
<Download className="h-4 w-4" />
|
||||||
{applyingEffects ? "Rendering..." : "Download"}
|
{applyingEffects ? t.toolSettings["remove-background"].rendering : "Download"}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -2,17 +2,15 @@ import { SOCIAL_MEDIA_PRESETS } from "@snapotter/shared";
|
|||||||
import { Download, Info, Link, Unlink } from "lucide-react";
|
import { Download, Info, Link, Unlink } from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
type ResizeTab = "presets" | "custom" | "scale" | "content-aware";
|
type ResizeTab = "presets" | "custom" | "scale" | "content-aware";
|
||||||
type FitMode = "cover" | "contain" | "fill";
|
type FitMode = "cover" | "contain" | "fill";
|
||||||
|
|
||||||
const FIT_LABELS: Record<FitMode, string> = {
|
const FIT_MODES: FitMode[] = ["cover", "contain", "fill"];
|
||||||
cover: "Crop to fit",
|
|
||||||
contain: "Fit inside",
|
|
||||||
fill: "Stretch",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Group presets by platform
|
// Group presets by platform
|
||||||
const platforms = [...new Set(SOCIAL_MEDIA_PRESETS.map((p) => p.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) {
|
export function ResizeControls({ settings: initialSettings, onChange }: ResizeControlsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [tab, setTab] = useState<ResizeTab>("custom");
|
const [tab, setTab] = useState<ResizeTab>("custom");
|
||||||
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
||||||
const [width, setWidth] = useState<string>("");
|
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 items-end gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label htmlFor="resize-width" className="text-xs text-muted-foreground">
|
<label htmlFor="resize-width" className="text-xs text-muted-foreground">
|
||||||
Width (px)
|
{t.toolSettings.resize.widthPx}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="resize-width"
|
id="resize-width"
|
||||||
@@ -149,7 +148,7 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
|
|||||||
</button>
|
</button>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label htmlFor="resize-height" className="text-xs text-muted-foreground">
|
<label htmlFor="resize-height" className="text-xs text-muted-foreground">
|
||||||
Height (px)
|
{t.toolSettings.resize.heightPx}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="resize-height"
|
id="resize-height"
|
||||||
@@ -172,8 +171,8 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
|
|||||||
onChange={(e) => setWithoutEnlargement(e.target.checked)}
|
onChange={(e) => setWithoutEnlargement(e.target.checked)}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
<span>Limit to original size</span>
|
<span>{t.toolSettings.resize.limitToOriginalSize}</span>
|
||||||
<HintIcon text="If your image is already smaller than the target, keep it as-is instead of scaling it up" />
|
<HintIcon text={t.toolSettings.resize.limitToOriginalSizeHint} />
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -183,27 +182,27 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button type="button" onClick={() => setTab("custom")} className={tabClass("custom")}>
|
<button type="button" onClick={() => setTab("custom")} className={tabClass("custom")}>
|
||||||
Custom Size
|
{t.toolSettings.resize.customSize}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
|
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
|
||||||
Scale
|
{t.toolSettings.resize.scale}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => setTab("presets")} className={tabClass("presets")}>
|
<button type="button" onClick={() => setTab("presets")} className={tabClass("presets")}>
|
||||||
Presets
|
{t.toolSettings.resize.presets}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTab("content-aware")}
|
onClick={() => setTab("content-aware")}
|
||||||
className={tabClass("content-aware")}
|
className={tabClass("content-aware")}
|
||||||
>
|
>
|
||||||
Content-Aware
|
{t.toolSettings.resize.contentAware}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Presets tab */}
|
{/* Presets tab */}
|
||||||
{tab === "presets" && (
|
{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) => (
|
{platforms.map((platform) => (
|
||||||
<div key={platform}>
|
<div key={platform}>
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-1.5">{platform}</p>
|
<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 */}
|
{/* Fit mode */}
|
||||||
<div>
|
<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">
|
<div className="flex gap-1 mt-1">
|
||||||
{(Object.keys(FIT_LABELS) as FitMode[]).map((f) => (
|
{FIT_MODES.map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f}
|
key={f}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFit(f)}
|
onClick={() => setFit(f)}
|
||||||
className={`flex-1 text-xs py-1.5 rounded ${fit === f ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -311,7 +314,7 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
|
|||||||
onChange={(e) => setSquareMode(e.target.checked)}
|
onChange={(e) => setSquareMode(e.target.checked)}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
Resize to square
|
{t.toolSettings.resize.resizeToSquare}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* Face protection */}
|
{/* Face protection */}
|
||||||
@@ -322,7 +325,7 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
|
|||||||
onChange={(e) => setProtectFaces(e.target.checked)}
|
onChange={(e) => setProtectFaces(e.target.checked)}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
Protect faces
|
{t.toolSettings.resize.protectFaces}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* Blur radius */}
|
{/* Blur radius */}
|
||||||
@@ -369,6 +372,7 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ResizeSettings() {
|
export function ResizeSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const standardResize = useToolProcessor("resize");
|
const standardResize = useToolProcessor("resize");
|
||||||
const contentAwareResize = useToolProcessor("content-aware-resize");
|
const contentAwareResize = useToolProcessor("content-aware-resize");
|
||||||
@@ -420,7 +424,7 @@ export function ResizeSettings() {
|
|||||||
<ProgressCard
|
<ProgressCard
|
||||||
active={processing}
|
active={processing}
|
||||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||||
label="Resizing"
|
label={t.toolSettings.resize.progressLabel}
|
||||||
stage={progress.stage}
|
stage={progress.stage}
|
||||||
percent={progress.percent}
|
percent={progress.percent}
|
||||||
elapsed={progress.elapsed}
|
elapsed={progress.elapsed}
|
||||||
@@ -432,7 +436,9 @@ export function ResizeSettings() {
|
|||||||
disabled={!canProcess}
|
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"
|
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>
|
</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"
|
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 className="h-4 w-4" />
|
||||||
Download
|
{t.common.download}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
export interface RestorePhotoControlsProps {
|
export interface RestorePhotoControlsProps {
|
||||||
@@ -13,6 +15,7 @@ export function RestorePhotoControls({
|
|||||||
settings: initialSettings,
|
settings: initialSettings,
|
||||||
onChange,
|
onChange,
|
||||||
}: RestorePhotoControlsProps) {
|
}: RestorePhotoControlsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [scratchRemoval, setScratchRemoval] = useState(true);
|
const [scratchRemoval, setScratchRemoval] = useState(true);
|
||||||
const [faceEnhancement, setFaceEnhancement] = useState(true);
|
const [faceEnhancement, setFaceEnhancement] = useState(true);
|
||||||
const [fidelity, setFidelity] = useState(70);
|
const [fidelity, setFidelity] = useState(70);
|
||||||
@@ -103,7 +106,7 @@ export function RestorePhotoControls({
|
|||||||
|
|
||||||
{/* Fidelity slider (only when face enhancement is on) */}
|
{/* Fidelity slider (only when face enhancement is on) */}
|
||||||
{faceEnhancement && (
|
{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">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-xs text-muted-foreground">Face Fidelity</p>
|
<p className="text-xs text-muted-foreground">Face Fidelity</p>
|
||||||
<span className="text-xs font-mono tabular-nums">{fidelity}%</span>
|
<span className="text-xs font-mono tabular-nums">{fidelity}%</span>
|
||||||
@@ -142,7 +145,7 @@ export function RestorePhotoControls({
|
|||||||
|
|
||||||
{/* Denoise strength slider */}
|
{/* Denoise strength slider */}
|
||||||
{denoise && (
|
{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">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-xs text-muted-foreground">Denoise Strength</p>
|
<p className="text-xs text-muted-foreground">Denoise Strength</p>
|
||||||
<span className="text-xs font-mono tabular-nums">{denoiseStrength}</span>
|
<span className="text-xs font-mono tabular-nums">{denoiseStrength}</span>
|
||||||
@@ -183,7 +186,7 @@ export function RestorePhotoControls({
|
|||||||
|
|
||||||
{/* Colorize strength slider */}
|
{/* Colorize strength slider */}
|
||||||
{colorize && (
|
{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">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-xs text-muted-foreground">Colorize Strength</p>
|
<p className="text-xs text-muted-foreground">Colorize Strength</p>
|
||||||
<span className="text-xs font-mono tabular-nums">{colorizeStrength}%</span>
|
<span className="text-xs font-mono tabular-nums">{colorizeStrength}%</span>
|
||||||
@@ -209,6 +212,7 @@ export function RestorePhotoControls({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RestorePhotoSettings() {
|
export function RestorePhotoSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const {
|
const {
|
||||||
processFiles,
|
processFiles,
|
||||||
@@ -265,7 +269,9 @@ export function RestorePhotoSettings() {
|
|||||||
disabled={!hasFile || processing}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export function RotateControls({
|
|||||||
commitAngleInput();
|
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">
|
<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 type React from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
type Method = "adaptive" | "unsharp-mask" | "high-pass";
|
type Method = "adaptive" | "unsharp-mask" | "high-pass";
|
||||||
@@ -29,6 +31,7 @@ const PRESETS: Preset[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function SharpeningSettings() {
|
export function SharpeningSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const {
|
const {
|
||||||
processFiles,
|
processFiles,
|
||||||
@@ -103,7 +106,7 @@ export function SharpeningSettings() {
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-3">
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
{/* Method selector */}
|
{/* Method selector */}
|
||||||
<SectionLabel>Method</SectionLabel>
|
<SectionLabel>{t.toolSettings.sharpening.method}</SectionLabel>
|
||||||
<div className="grid grid-cols-3 gap-1">
|
<div className="grid grid-cols-3 gap-1">
|
||||||
{(["adaptive", "unsharp-mask", "high-pass"] as const).map((m) => (
|
{(["adaptive", "unsharp-mask", "high-pass"] as const).map((m) => (
|
||||||
<button
|
<button
|
||||||
@@ -127,7 +130,7 @@ export function SharpeningSettings() {
|
|||||||
{/* Presets (adaptive only) */}
|
{/* Presets (adaptive only) */}
|
||||||
{method === "adaptive" && (
|
{method === "adaptive" && (
|
||||||
<>
|
<>
|
||||||
<SectionLabel>Presets</SectionLabel>
|
<SectionLabel>{t.toolSettings.sharpening.presets}</SectionLabel>
|
||||||
<div className="grid grid-cols-4 gap-1">
|
<div className="grid grid-cols-4 gap-1">
|
||||||
{PRESETS.map((p) => (
|
{PRESETS.map((p) => (
|
||||||
<button
|
<button
|
||||||
@@ -199,7 +202,7 @@ export function SharpeningSettings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Noise reduction */}
|
{/* Noise reduction */}
|
||||||
<SectionLabel>Noise Reduction</SectionLabel>
|
<SectionLabel>{t.toolSettings.sharpening.noiseReduction}</SectionLabel>
|
||||||
<div className="grid grid-cols-4 gap-1">
|
<div className="grid grid-cols-4 gap-1">
|
||||||
{(["off", "light", "medium", "strong"] as const).map((d) => (
|
{(["off", "light", "medium", "strong"] as const).map((d) => (
|
||||||
<button
|
<button
|
||||||
@@ -228,7 +231,7 @@ export function SharpeningSettings() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{advancedOpen && (
|
{advancedOpen && (
|
||||||
<div className="space-y-2 pl-1">
|
<div className="space-y-2 ps-1">
|
||||||
{method === "adaptive" && (
|
{method === "adaptive" && (
|
||||||
<>
|
<>
|
||||||
<SliderControl
|
<SliderControl
|
||||||
@@ -359,7 +362,7 @@ export function SharpeningSettings() {
|
|||||||
<ProgressCard
|
<ProgressCard
|
||||||
active={processing}
|
active={processing}
|
||||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||||
label="Sharpening"
|
label={t.toolSettings.sharpening.progressLabel}
|
||||||
stage={progress.stage}
|
stage={progress.stage}
|
||||||
percent={progress.percent}
|
percent={progress.percent}
|
||||||
elapsed={progress.elapsed}
|
elapsed={progress.elapsed}
|
||||||
@@ -371,7 +374,9 @@ export function SharpeningSettings() {
|
|||||||
disabled={!hasFile || processing}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -424,9 +429,9 @@ function SliderControl({
|
|||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<label htmlFor={id} className={`text-xs ${color || "text-muted-foreground"}`}>
|
<label htmlFor={id} className={`text-xs ${color || "text-muted-foreground"}`}>
|
||||||
{label}
|
{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>
|
</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}
|
{displayValue}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { SMART_CROP_FACE_PRESETS, SOCIAL_MEDIA_PRESETS } from "@snapotter/shared
|
|||||||
import { ArrowLeftRight, Info } from "lucide-react";
|
import { ArrowLeftRight, Info } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
type CropMode = "subject" | "face" | "trim";
|
type CropMode = "subject" | "face" | "trim";
|
||||||
@@ -36,6 +38,7 @@ export interface SmartCropControlsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SmartCropControls({ settings: initialSettings, onChange }: SmartCropControlsProps) {
|
export function SmartCropControls({ settings: initialSettings, onChange }: SmartCropControlsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [mode, setMode] = useState<CropMode>("subject");
|
const [mode, setMode] = useState<CropMode>("subject");
|
||||||
const [subjectTab, setSubjectTab] = useState<SubjectTab>("custom");
|
const [subjectTab, setSubjectTab] = useState<SubjectTab>("custom");
|
||||||
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
||||||
@@ -261,13 +264,13 @@ export function SmartCropControls({ settings: initialSettings, onChange }: Smart
|
|||||||
onClick={() => setMode("subject")}
|
onClick={() => setMode("subject")}
|
||||||
className={modeTabClass("subject")}
|
className={modeTabClass("subject")}
|
||||||
>
|
>
|
||||||
Subject Focus
|
{t.toolSettings["smart-crop"].subjectFocus}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => setMode("face")} className={modeTabClass("face")}>
|
<button type="button" onClick={() => setMode("face")} className={modeTabClass("face")}>
|
||||||
Face Focus
|
{t.toolSettings["smart-crop"].faceFocus}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => setMode("trim")} className={modeTabClass("trim")}>
|
<button type="button" onClick={() => setMode("trim")} className={modeTabClass("trim")}>
|
||||||
Auto Trim
|
{t.toolSettings["smart-crop"].autoTrim}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -293,7 +296,7 @@ export function SmartCropControls({ settings: initialSettings, onChange }: Smart
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{subjectTab === "presets" ? (
|
{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) => (
|
{platforms.map((platform) => (
|
||||||
<div key={platform}>
|
<div key={platform}>
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-1.5">{platform}</p>
|
<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 */}
|
{/* Strategy toggle */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1.5 mb-1">
|
<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." />
|
<HintIcon text="Attention finds the most visually salient region. Entropy finds the area with most detail and information." />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
@@ -546,6 +551,7 @@ export function SmartCropControls({ settings: initialSettings, onChange }: Smart
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SmartCropSettings() {
|
export function SmartCropSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { processFiles, processAllFiles, processing, error, progress } =
|
const { processFiles, processAllFiles, processing, error, progress } =
|
||||||
useToolProcessor("smart-crop");
|
useToolProcessor("smart-crop");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Download, Loader2, PackageOpen } from "lucide-react";
|
import { Download, Loader2, PackageOpen } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { CollapsibleSection } from "@/components/common/collapsible-section";
|
import { CollapsibleSection } from "@/components/common/collapsible-section";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
import type { SplitMode } from "@/stores/split-store";
|
import type { SplitMode } from "@/stores/split-store";
|
||||||
@@ -35,6 +36,7 @@ const OUTPUT_FORMATS = [
|
|||||||
const LOSSY_FORMATS = new Set(["jpg", "webp", "avif", "jxl"]);
|
const LOSSY_FORMATS = new Set(["jpg", "webp", "avif", "jxl"]);
|
||||||
|
|
||||||
export function SplitSettings() {
|
export function SplitSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files, processing: fileStoreProcessing } = useFileStore();
|
const { files, processing: fileStoreProcessing } = useFileStore();
|
||||||
const {
|
const {
|
||||||
mode,
|
mode,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Download, Loader2 } from "lucide-react";
|
import { Download, Loader2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ type Alignment = "start" | "center" | "end";
|
|||||||
type OutputFormat = "png" | "jpeg" | "webp" | "avif" | "jxl";
|
type OutputFormat = "png" | "jpeg" | "webp" | "avif" | "jxl";
|
||||||
|
|
||||||
export function StitchSettings() {
|
export function StitchSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
||||||
useFileStore();
|
useFileStore();
|
||||||
|
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export function StripMetadataControls({
|
|||||||
/>
|
/>
|
||||||
Strip EXIF (camera info, date, exposure)
|
Strip EXIF (camera info, date, exposure)
|
||||||
{hasExif && !stripAll && (
|
{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
|
{Object.keys(metadata?.exif ?? {}).filter((k) => !SKIP_KEYS.has(k)).length} fields
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -161,7 +161,7 @@ export function StripMetadataControls({
|
|||||||
/>
|
/>
|
||||||
Strip GPS (location data)
|
Strip GPS (location data)
|
||||||
{hasGps && !stripAll && (
|
{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>
|
</label>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
export interface TextOverlayControlsProps {
|
export interface TextOverlayControlsProps {
|
||||||
@@ -155,6 +157,7 @@ export function TextOverlayControls({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TextOverlaySettings() {
|
export function TextOverlaySettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const {
|
const {
|
||||||
processFiles,
|
processFiles,
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { CATEGORIES, TOOLS } from "@snapotter/shared";
|
|||||||
import { FileImage, Plus } from "lucide-react";
|
import { FileImage, Plus } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { SearchBar } from "@/components/common/search-bar";
|
import { SearchBar } from "@/components/common/search-bar";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { apiGet } from "@/lib/api";
|
import { apiGet } from "@/lib/api";
|
||||||
import { ICON_MAP } from "@/lib/icon-map";
|
import { ICON_MAP } from "@/lib/icon-map";
|
||||||
|
import { getCategoryName, getToolDescription, getToolName } from "@/lib/tool-i18n";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const EXCLUDED_TOOLS = new Set(["pipeline", "compare", "find-duplicates", "collage", "compose"]);
|
const EXCLUDED_TOOLS = new Set(["pipeline", "compare", "find-duplicates", "collage", "compose"]);
|
||||||
@@ -14,6 +16,7 @@ interface ToolPaletteProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ToolPalette({ onAddStep, className }: ToolPaletteProps) {
|
export function ToolPalette({ onAddStep, className }: ToolPaletteProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [disabledTools, setDisabledTools] = useState<string[]>([]);
|
const [disabledTools, setDisabledTools] = useState<string[]>([]);
|
||||||
const [experimentalEnabled, setExperimentalEnabled] = useState(false);
|
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">
|
<div className="flex items-center gap-1.5 mb-1.5 px-1">
|
||||||
<CatIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
<CatIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<span className="text-xs font-semibold uppercase text-muted-foreground tracking-wider">
|
<span className="text-xs font-semibold uppercase text-muted-foreground tracking-wider">
|
||||||
{cat.name}
|
{getCategoryName(t, cat.id, cat.name)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
@@ -111,21 +114,24 @@ interface ToolItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ToolItem({ tool, onAdd }: ToolItemProps) {
|
function ToolItem({ tool, onAdd }: ToolItemProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const Icon = (ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage;
|
const Icon = (ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onAdd(tool.id)}
|
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">
|
<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" />
|
<Icon className="h-3.5 w-3.5 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<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">
|
<div className="text-[11px] text-muted-foreground truncate leading-tight">
|
||||||
{tool.description}
|
{getToolDescription(t, tool.id, tool.description)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Plus className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
<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 { ChevronDown, ChevronRight, Droplets } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
type OutputFormat = "png" | "webp";
|
type OutputFormat = "png" | "webp";
|
||||||
@@ -17,6 +19,7 @@ export function TransparencyFixerControls({
|
|||||||
settings: _settings,
|
settings: _settings,
|
||||||
onChange,
|
onChange,
|
||||||
}: TransparencyFixerControlsProps) {
|
}: TransparencyFixerControlsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [defringe, setDefringe] = useState(30);
|
const [defringe, setDefringe] = useState(30);
|
||||||
const [outputFormat, setOutputFormat] = useState<OutputFormat>("png");
|
const [outputFormat, setOutputFormat] = useState<OutputFormat>("png");
|
||||||
const [removeWatermark, setRemoveWatermark] = useState(false);
|
const [removeWatermark, setRemoveWatermark] = useState(false);
|
||||||
@@ -76,12 +79,12 @@ export function TransparencyFixerControls({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{advancedOpen && (
|
{advancedOpen && (
|
||||||
<div className="space-y-3 pl-1">
|
<div className="space-y-3 ps-1">
|
||||||
{/* Defringe slider */}
|
{/* Defringe slider */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-xs text-muted-foreground">Defringe</span>
|
<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}
|
{defringe}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,6 +121,7 @@ export function TransparencyFixerControls({
|
|||||||
// ── Standalone tool page wrapper ──
|
// ── Standalone tool page wrapper ──
|
||||||
|
|
||||||
export function TransparencyFixerSettings() {
|
export function TransparencyFixerSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { processFiles, processAllFiles, processing, error, progress } =
|
const { processFiles, processAllFiles, processing, error, progress } =
|
||||||
useToolProcessor("transparency-fixer");
|
useToolProcessor("transparency-fixer");
|
||||||
@@ -160,7 +164,9 @@ export function TransparencyFixerSettings() {
|
|||||||
disabled={!hasFile || processing}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
const QUICK_SCALES = [2, 3, 4, 6, 8];
|
const QUICK_SCALES = [2, 3, 4, 6, 8];
|
||||||
@@ -29,6 +31,7 @@ export interface UpscaleControlsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UpscaleControls({ settings: initialSettings, onChange }: UpscaleControlsProps) {
|
export function UpscaleControls({ settings: initialSettings, onChange }: UpscaleControlsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [scale, setScale] = useState(2);
|
const [scale, setScale] = useState(2);
|
||||||
const [model, setModel] = useState<"auto" | "realesrgan" | "lanczos">("auto");
|
const [model, setModel] = useState<"auto" | "realesrgan" | "lanczos">("auto");
|
||||||
const [faceEnhance, setFaceEnhance] = useState(false);
|
const [faceEnhance, setFaceEnhance] = useState(false);
|
||||||
@@ -70,7 +73,9 @@ export function UpscaleControls({ settings: initialSettings, onChange }: Upscale
|
|||||||
{/* Scale factor */}
|
{/* Scale factor */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center">
|
<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>
|
<span className="text-sm font-mono font-medium">{scale}x</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 mt-1.5">
|
<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)}
|
onChange={(e) => setFaceEnhance(e.target.checked)}
|
||||||
className="rounded border-border"
|
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>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Noise Reduction */}
|
{/* Noise Reduction */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center">
|
<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">
|
<span className="text-sm font-mono font-medium">
|
||||||
{denoise === 0 ? "Off" : denoise.toFixed(1)}
|
{denoise === 0 ? "Off" : denoise.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
@@ -198,6 +205,7 @@ export function UpscaleControls({ settings: initialSettings, onChange }: Upscale
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UpscaleSettings() {
|
export function UpscaleSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const {
|
const {
|
||||||
processFiles,
|
processFiles,
|
||||||
@@ -242,7 +250,7 @@ export function UpscaleSettings() {
|
|||||||
<ProgressCard
|
<ProgressCard
|
||||||
active={processing}
|
active={processing}
|
||||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||||
label={hasMultiple ? `Upscaling ${files.length} images` : "Upscaling image"}
|
label={t.toolSettings.upscale.progressLabel}
|
||||||
percent={progress.percent}
|
percent={progress.percent}
|
||||||
elapsed={progress.elapsed}
|
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"
|
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
|
{hasMultiple
|
||||||
? `Upscale ${(settings.scale as number) ?? 2}x (${files.length} files)`
|
? format(t.toolSettings.upscale.submitBatch, {
|
||||||
: `Upscale ${(settings.scale as number) ?? 2}x`}
|
scale: (settings.scale as number) ?? 2,
|
||||||
|
count: files.length,
|
||||||
|
})
|
||||||
|
: format(t.toolSettings.upscale.submit, { scale: (settings.scale as number) ?? 2 })}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
type ColorMode = "bw" | "color";
|
type ColorMode = "bw" | "color";
|
||||||
@@ -72,6 +74,7 @@ function speckleToDetail(speckle: number): Detail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function VectorizeSettings() {
|
export function VectorizeSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const {
|
const {
|
||||||
processFiles,
|
processFiles,
|
||||||
@@ -353,7 +356,7 @@ export function VectorizeSettings() {
|
|||||||
<ProgressCard
|
<ProgressCard
|
||||||
active={processing}
|
active={processing}
|
||||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||||
label="Vectorizing"
|
label={t.toolSettings.vectorize.progressLabel}
|
||||||
stage={progress.stage}
|
stage={progress.stage}
|
||||||
percent={progress.percent}
|
percent={progress.percent}
|
||||||
elapsed={progress.elapsed}
|
elapsed={progress.elapsed}
|
||||||
@@ -366,7 +369,9 @@ export function VectorizeSettings() {
|
|||||||
disabled={!hasFile || processing}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Download, Loader2, Upload } from "lucide-react";
|
import { Download, Loader2, Upload } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||||
export function WatermarkImageSettings() {
|
export function WatermarkImageSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
|
||||||
useFileStore();
|
useFileStore();
|
||||||
const [position, setPosition] = useState<Position>("bottom-right");
|
const [position, setPosition] = useState<Position>("bottom-right");
|
||||||
@@ -198,8 +201,8 @@ export function WatermarkImageSettings() {
|
|||||||
{processing
|
{processing
|
||||||
? "Processing..."
|
? "Processing..."
|
||||||
: files.length > 1
|
: files.length > 1
|
||||||
? `Apply Watermark (${files.length} files)`
|
? format(t.toolSettings["watermark-image"].submitBatch, { count: files.length })
|
||||||
: "Apply Watermark"}
|
: t.toolSettings["watermark-image"].submit}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{downloadUrl && (
|
{downloadUrl && (
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
|
import { format } from "@/lib/format";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right" | "tiled";
|
type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right" | "tiled";
|
||||||
@@ -149,6 +151,7 @@ export function WatermarkTextControls({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WatermarkTextSettings() {
|
export function WatermarkTextSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const {
|
const {
|
||||||
processFiles,
|
processFiles,
|
||||||
@@ -190,7 +193,7 @@ export function WatermarkTextSettings() {
|
|||||||
<ProgressCard
|
<ProgressCard
|
||||||
active={processing}
|
active={processing}
|
||||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||||
label="Adding watermark"
|
label={t.toolSettings["watermark-text"].progressLabel}
|
||||||
stage={progress.stage}
|
stage={progress.stage}
|
||||||
percent={progress.percent}
|
percent={progress.percent}
|
||||||
elapsed={progress.elapsed}
|
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