feat: add multi-language support for 20 locales

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

- I18nProvider context with three-tier locale detection
  (user preference > navigator.languages > instance default > English)
- ~1500 translation keys per locale with TypeScript-enforced completeness
- Dynamic code-splitting: only the active locale is loaded at runtime
- Language selectors in footer, login page, settings, and mobile sidebar
- Arabic RTL support with CSS logical properties across all components
- Tool names, descriptions, and categories translated via i18n helpers
- Public API endpoint GET /api/v1/config/locale for instance default
- Multi-script font stack (CJK, Arabic, Devanagari, Thai, Cyrillic)
- format() and plural() helpers for interpolation and pluralization
- API error translation mapping (translateApiError)
- 36 Playwright e2e tests verifying all 21 locales load correctly
- 25 unit tests for format, plural, locale detection, and completeness
- Updated translations.md docs and CLAUDE.md conventions
This commit is contained in:
SnapOtter
2026-05-15 17:02:49 +08:00
parent 3a82936d93
commit d38621d7b9
141 changed files with 43160 additions and 936 deletions
@@ -1,7 +1,9 @@
import { Download } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type Tier = "fast" | "balanced" | "high";
@@ -22,6 +24,7 @@ const EXTEND_PRESETS = [
];
export function AiCanvasExpandSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("ai-canvas-expand");
@@ -211,7 +214,7 @@ export function AiCanvasExpandSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Extending canvas"
label={t.toolSettings["ai-canvas-expand"].progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
@@ -223,7 +226,9 @@ export function AiCanvasExpandSettings() {
disabled={!hasFile || !hasExtension || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Extend (${files.length} files)` : "Extend Canvas"}
{files.length > 1
? format(t.toolSettings["ai-canvas-expand"].submitBatch, { count: files.length })
: t.toolSettings["ai-canvas-expand"].submit}
</button>
)}
@@ -1,7 +1,9 @@
import { Check, Copy, Download, Search } from "lucide-react";
import { useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { format } from "@/lib/format";
import { copyToClipboard } from "@/lib/utils";
import { useFileStore } from "@/stores/file-store";
@@ -106,6 +108,7 @@ function scanOneFile(
}
export function BarcodeReadSettings() {
const { t } = useTranslation();
const { files, processing, error, setProcessing, setError } = useFileStore();
const [tryHarder, setTryHarder] = useState(false);
@@ -858,7 +858,7 @@ export function BeautifyControls({
<button
type="button"
onClick={() => handleRemoveStop(i)}
className="ml-auto p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
className="ms-auto p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
@@ -1,6 +1,7 @@
import { Download } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
@@ -10,6 +11,7 @@ export interface BlurFacesControlsProps {
}
export function BlurFacesControls({ settings: initialSettings, onChange }: BlurFacesControlsProps) {
const { t } = useTranslation();
const [blurRadius, setBlurRadius] = useState(30);
const [sensitivity, setSensitivity] = useState(50);
@@ -37,7 +39,7 @@ export function BlurFacesControls({ settings: initialSettings, onChange }: BlurF
<div>
<div className="flex justify-between items-center">
<label htmlFor="blur-faces-blur-radius" className="text-xs text-muted-foreground">
Blur Radius
{t.toolSettings["blur-faces"].blurRadius}
</label>
<span className="text-xs font-mono text-foreground">{blurRadius}</span>
</div>
@@ -51,8 +53,8 @@ export function BlurFacesControls({ settings: initialSettings, onChange }: BlurF
className="w-full mt-1"
/>
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span>Light</span>
<span>Heavy</span>
<span>{t.toolSettings["blur-faces"].blurLight}</span>
<span>{t.toolSettings["blur-faces"].blurHeavy}</span>
</div>
</div>
@@ -60,7 +62,7 @@ export function BlurFacesControls({ settings: initialSettings, onChange }: BlurF
<div>
<div className="flex justify-between items-center">
<label htmlFor="blur-faces-sensitivity" className="text-xs text-muted-foreground">
Detection Sensitivity
{t.toolSettings["blur-faces"].detectionSensitivity}
</label>
<span className="text-xs font-mono text-foreground">{sensitivity}%</span>
</div>
@@ -74,8 +76,8 @@ export function BlurFacesControls({ settings: initialSettings, onChange }: BlurF
className="w-full mt-1"
/>
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span>More faces</span>
<span>Fewer faces</span>
<span>{t.toolSettings["blur-faces"].moreFaces}</span>
<span>{t.toolSettings["blur-faces"].fewerFaces}</span>
</div>
</div>
</div>
@@ -83,6 +85,7 @@ export function BlurFacesControls({ settings: initialSettings, onChange }: BlurF
}
export function BlurFacesSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -128,7 +131,7 @@ export function BlurFacesSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Blurring faces"
label={t.toolSettings["blur-faces"].progressLabel}
percent={progress.percent}
elapsed={progress.elapsed}
/>
@@ -153,7 +156,7 @@ export function BlurFacesSettings() {
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
{t.common.download}
</a>
)}
</div>
@@ -2,7 +2,9 @@ import { Download } from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
// ── Presets ──────────────────────────────────────────────────────────
@@ -225,6 +227,7 @@ export function BorderControls({
onChange,
onImageStyle,
}: BorderControlsProps) {
const { t } = useTranslation();
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
const [borderWidth, setBorderWidth] = useState(10);
const [borderColor, setBorderColor] = useState("#000000");
@@ -1,8 +1,11 @@
import { Download, Loader2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export function BulkRenameSettings() {
const { t } = useTranslation();
const { files, processing, error, setProcessing, setError } = useFileStore();
const [pattern, setPattern] = useState("image-{{index}}");
const [startIndex, setStartIndex] = useState(1);
@@ -69,7 +72,7 @@ export function BulkRenameSettings() {
<div className="space-y-4">
<div>
<label htmlFor="bulk-rename-pattern" className="text-xs text-muted-foreground">
Pattern
{t.toolSettings["bulk-rename"].pattern}
</label>
<input
id="bulk-rename-pattern"
@@ -85,7 +88,7 @@ export function BulkRenameSettings() {
<div>
<label htmlFor="bulk-rename-start-index" className="text-xs text-muted-foreground">
Start Index
{t.toolSettings["bulk-rename"].startIndex}
</label>
<input
id="bulk-rename-start-index"
@@ -99,7 +102,7 @@ export function BulkRenameSettings() {
{previewNames.length > 0 && (
<div>
<p className="text-xs text-muted-foreground">Preview</p>
<p className="text-xs text-muted-foreground">{t.toolSettings["bulk-rename"].preview}</p>
<div className="mt-1 space-y-0.5">
{previewNames.map((name) => (
<div
@@ -126,12 +129,14 @@ export function BulkRenameSettings() {
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Renaming..." : `Rename ${files.length} Files`}
{processing
? t.toolSettings["bulk-rename"].renaming
: format(t.toolSettings["bulk-rename"].submit, { count: files.length })}
</button>
{downloadReady && (
<p className="text-xs text-green-600 flex items-center gap-1">
<Download className="h-3 w-3" /> ZIP downloaded successfully
<Download className="h-3 w-3" /> {t.toolSettings["bulk-rename"].zipDownloaded}
</p>
)}
</div>
@@ -24,6 +24,7 @@ import {
} from "lucide-react";
import { type DragEvent, useCallback, useEffect, useRef, useState } from "react";
import { isImageFile } from "@/components/common/dropzone";
import { useTranslation } from "@/contexts/i18n-context";
import { type CollageTemplate, getTemplateById } from "@/lib/collage-templates";
import { cn } from "@/lib/utils";
import type { CellTransform, CollageImage } from "@/stores/collage-store";
@@ -50,6 +51,7 @@ function displayUrl(img: CollageImage): string {
}
export function CollagePreview() {
const { t } = useTranslation();
const images = useCollageStore((s) => s.images);
const templateId = useCollageStore((s) => s.templateId);
const phase = useCollageStore((s) => s.phase);
@@ -570,7 +572,7 @@ function CollageCell({
onChange={handleZoomSlider}
className="flex-1 h-1.5 accent-white cursor-pointer"
/>
<span className="text-white text-xs font-mono w-8 text-right shrink-0">
<span className="text-white text-xs font-mono w-8 text-end shrink-0">
{transform.zoom.toFixed(1)}x
</span>
<button
@@ -1,6 +1,7 @@
import { Download, Loader2 } from "lucide-react";
import { useCallback } from "react";
import { CollapsibleSection } from "@/components/common/collapsible-section";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import {
COLLAGE_TEMPLATES,
@@ -37,6 +38,7 @@ const BG_PRESETS = [
];
export function CollageSettings() {
const { t } = useTranslation();
const store = useCollageStore();
const {
images,
@@ -1,7 +1,9 @@
import { Download } from "lucide-react";
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
const SIMULATION_TYPES = [
@@ -65,6 +67,7 @@ const SIMULATION_TYPES = [
const TYPE_MAP = new Map(SIMULATION_TYPES.flatMap((g) => g.types.map((t) => [t.value, t])));
export function ColorBlindnessSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -95,7 +98,7 @@ export function ColorBlindnessSettings() {
<div className="space-y-4">
<div>
<label htmlFor="cb-simulation-type" className="text-xs text-muted-foreground">
Simulation Type
{t.toolSettings["color-blindness"].simulationType}
</label>
<select
id="cb-simulation-type"
@@ -131,7 +134,7 @@ export function ColorBlindnessSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Simulating color blindness"
label={t.toolSettings["color-blindness"].progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
@@ -144,7 +147,9 @@ export function ColorBlindnessSettings() {
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Simulate (${files.length} files)` : "Simulate"}
{files.length > 1
? format(t.toolSettings["color-blindness"].submitBatch, { count: files.length })
: t.toolSettings["color-blindness"].submit}
</button>
)}
@@ -156,7 +161,7 @@ export function ColorBlindnessSettings() {
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
{t.common.download}
</a>
)}
</div>
@@ -1,9 +1,11 @@
import { Check, Copy, Loader2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { copyToClipboard } from "@/lib/utils";
import { useFileStore } from "@/stores/file-store";
export function ColorPaletteSettings() {
const { t } = useTranslation();
const { files, processing, error, setProcessing, setError } = useFileStore();
const [colors, setColors] = useState<string[]>([]);
const [copiedIdx, setCopiedIdx] = useState<number | null>(null);
@@ -81,7 +83,7 @@ export function ColorPaletteSettings() {
className="w-6 h-6 rounded border border-border shrink-0"
style={{ backgroundColor: color }}
/>
<span className="text-xs font-mono text-foreground flex-1 text-left">{color}</span>
<span className="text-xs font-mono text-foreground flex-1 text-start">{color}</span>
{copiedIdx === i ? (
<Check className="h-3 w-3 text-green-500 shrink-0" />
) : (
@@ -275,10 +275,10 @@ export function ColorControls({
>
{channelsOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
Color Channels
{hasChannelChanges && <span className="ml-auto text-primary text-[10px]">modified</span>}
{hasChannelChanges && <span className="ms-auto text-primary text-[10px]">modified</span>}
</button>
{channelsOpen && (
<div className="space-y-2 pl-1">
<div className="space-y-2 ps-1">
<SliderControl
label="Red"
value={red}
@@ -457,11 +457,9 @@ function SliderControl({
<div className="flex justify-between items-center">
<label htmlFor={id} className={`text-xs ${color || "text-muted-foreground"}`}>
{label}
{hint && <span className="text-[10px] text-muted-foreground/60 ml-1">({hint})</span>}
{hint && <span className="text-[10px] text-muted-foreground/60 ms-1">({hint})</span>}
</label>
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-right">
{value}
</span>
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-end">{value}</span>
</div>
<input
id={id}
@@ -1,7 +1,9 @@
import { Download } from "lucide-react";
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type Model = "auto" | "ddcolor" | "opencv";
@@ -13,6 +15,7 @@ const MODEL_OPTIONS: { value: Model; label: string; desc: string }[] = [
];
export function ColorizeSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -83,7 +86,7 @@ export function ColorizeSettings() {
? "Natural"
: "Vivid"}
</span>
<span className="text-xs font-mono text-foreground tabular-nums w-10 text-right">
<span className="text-xs font-mono text-foreground tabular-nums w-10 text-end">
{intensity}%
</span>
</div>
@@ -126,7 +129,9 @@ export function ColorizeSettings() {
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{hasMultiple ? `Colorize (${files.length} files)` : "Colorize"}
{hasMultiple
? format(t.toolSettings.colorize.submitBatch, { count: files.length })
: t.toolSettings.colorize.submit}
</button>
)}
@@ -1,8 +1,10 @@
import { Download, Loader2, Upload } from "lucide-react";
import { useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { useFileStore } from "@/stores/file-store";
export function CompareSettings() {
const { t } = useTranslation();
const { files, processing, error, setProcessing, setError, setProcessedUrl } = useFileStore();
const [secondFile, setSecondFile] = useState<File | null>(null);
const [similarity, setSimilarity] = useState<number | null>(null);
@@ -1,8 +1,10 @@
import { Download, Loader2, Upload } from "lucide-react";
import { useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { useFileStore } from "@/stores/file-store";
export function ComposeSettings() {
const { t } = useTranslation();
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
useFileStore();
const [overlayFile, setOverlayFile] = useState<File | null>(null);
@@ -166,7 +168,7 @@ export function ComposeSettings() {
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Processing..." : "Compose"}
{processing ? "Processing..." : t.toolSettings.compose.submit}
</button>
{downloadUrl && (
@@ -1,7 +1,9 @@
import { Download, Minus, Plus } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type CompressMode = "quality" | "targetSize";
@@ -13,6 +15,7 @@ export interface CompressControlsProps {
}
export function CompressControls({ settings: initialSettings, onChange }: CompressControlsProps) {
const { t } = useTranslation();
const [mode, setMode] = useState<CompressMode>("targetSize");
const [quality, setQuality] = useState(75);
const [targetSizeValue, setTargetSizeValue] = useState("");
@@ -47,21 +50,23 @@ export function CompressControls({ settings: initialSettings, onChange }: Compre
<div className="space-y-4">
{/* Mode toggle */}
<div>
<p className="text-sm font-medium text-muted-foreground">Compression Mode</p>
<p className="text-sm font-medium text-muted-foreground">
{t.toolSettings.compress.compressionMode}
</p>
<div className="flex gap-1 mt-1">
<button
type="button"
onClick={() => setMode("targetSize")}
className={`flex-1 text-xs py-1.5 rounded ${mode === "targetSize" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Target Size
{t.toolSettings.compress.targetSize}
</button>
<button
type="button"
onClick={() => setMode("quality")}
className={`flex-1 text-xs py-1.5 rounded ${mode === "quality" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Quality
{t.toolSettings.compress.quality}
</button>
</div>
</div>
@@ -69,7 +74,7 @@ export function CompressControls({ settings: initialSettings, onChange }: Compre
{mode === "targetSize" ? (
<div>
<label htmlFor="compress-target-size" className="text-xs text-muted-foreground">
Target Size
{t.toolSettings.compress.targetSize}
</label>
<div className="flex gap-1.5 mt-0.5">
<input
@@ -127,8 +132,8 @@ export function CompressControls({ settings: initialSettings, onChange }: Compre
</button>
</div>
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span>Smallest file</span>
<span>Best quality</span>
<span>{t.toolSettings.compress.smallestFile}</span>
<span>{t.toolSettings.compress.bestQuality}</span>
</div>
</div>
)}
@@ -137,6 +142,7 @@ export function CompressControls({ settings: initialSettings, onChange }: Compre
}
export function CompressSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -178,10 +184,17 @@ export function CompressSettings() {
{/* Size info */}
{originalSize != null && processedSize != null && (
<div className="text-xs text-muted-foreground space-y-0.5">
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
<p>
{format(t.toolSettings.compress.original, { size: (originalSize / 1024).toFixed(1) })}
</p>
<p>
{format(t.toolSettings.compress.processed, { size: (processedSize / 1024).toFixed(1) })}
</p>
<p className="font-medium text-foreground">
Saved: {originalSize > 0 ? ((1 - processedSize / originalSize) * 100).toFixed(1) : "0"}%
{format(t.toolSettings.compress.saved, {
percent:
originalSize > 0 ? ((1 - processedSize / originalSize) * 100).toFixed(1) : "0",
})}
</p>
</div>
)}
@@ -191,7 +204,7 @@ export function CompressSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Compressing"
label={t.toolSettings.compress.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
@@ -203,7 +216,9 @@ export function CompressSettings() {
disabled={!hasFile || !canProcess || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Compress (${files.length} files)` : "Compress"}
{files.length > 1
? format(t.toolSettings.compress.submitBatch, { count: files.length })
: t.toolSettings.compress.submit}
</button>
)}
@@ -216,7 +231,7 @@ export function CompressSettings() {
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
{t.common.download}
</a>
)}
</form>
@@ -1,7 +1,9 @@
import { Download, Info } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
function HintIcon({ text }: { text: string }) {
@@ -162,6 +164,7 @@ export function ContentAwareResizeControls({
}
export function ContentAwareResizeSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("content-aware-resize");
@@ -200,7 +203,7 @@ export function ContentAwareResizeSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Content-aware resizing"
label={t.toolSettings["content-aware-resize"].progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
@@ -212,7 +215,9 @@ export function ContentAwareResizeSettings() {
disabled={!canProcess}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Resize (${files.length} files)` : "Resize"}
{files.length > 1
? format(t.toolSettings["content-aware-resize"].submitBatch, { count: files.length })
: t.toolSettings["content-aware-resize"].submit}
</button>
)}
@@ -1,7 +1,9 @@
import { Download } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
const OUTPUT_FORMATS = [
@@ -43,6 +45,7 @@ export interface ConvertControlsProps {
}
export function ConvertControls({ settings: initialSettings, onChange }: ConvertControlsProps) {
const { t } = useTranslation();
const [format, setFormat] = useState<string>("png");
const [quality, setQuality] = useState(85);
@@ -74,7 +77,7 @@ export function ConvertControls({ settings: initialSettings, onChange }: Convert
{/* Target format */}
<div>
<label htmlFor="convert-target-format" className="text-xs text-muted-foreground">
Target Format
{t.toolSettings.convert.targetFormat}
</label>
<select
id="convert-target-format"
@@ -115,6 +118,7 @@ export function ConvertControls({ settings: initialSettings, onChange }: Convert
}
export function ConvertSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -154,7 +158,7 @@ export function ConvertSettings() {
{/* Source format */}
{hasFile && (
<div>
<p className="text-xs text-muted-foreground">Source Format</p>
<p className="text-xs text-muted-foreground">{t.toolSettings.convert.sourceFormat}</p>
<div className="mt-0.5 px-2 py-1.5 rounded bg-muted text-sm text-foreground uppercase font-mono">
{sourceExt}
</div>
@@ -179,7 +183,7 @@ export function ConvertSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Converting"
label={t.toolSettings.convert.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
@@ -191,7 +195,9 @@ export function ConvertSettings() {
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Convert (${files.length} files)` : "Convert"}
{files.length > 1
? format(t.toolSettings.convert.submitBatch, { count: files.length })
: t.toolSettings.convert.submit}
</button>
)}
@@ -204,7 +210,7 @@ export function ConvertSettings() {
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
{t.common.download}
</a>
)}
</form>
@@ -2,7 +2,9 @@ import { ArrowLeftRight, Download, Grid3x3 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { Crop } from "react-image-crop";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
const ASPECT_PRESETS = [
@@ -34,6 +36,7 @@ export function CropSettings({
onAspectChange,
onGridToggle,
}: CropSettingsProps) {
const { t } = useTranslation();
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("crop");
@@ -216,7 +219,7 @@ export function CropSettings({
{/* Aspect Ratio */}
<div>
<div className="flex items-center justify-between mb-1">
<p className="text-xs text-muted-foreground">Aspect Ratio</p>
<p className="text-xs text-muted-foreground">{t.toolSettings.crop.aspectRatio}</p>
{aspect !== undefined && (
<button
type="button"
@@ -288,7 +291,7 @@ export function CropSettings({
{/* Position & Size */}
<div>
<p className="text-xs text-muted-foreground">Position & Size</p>
<p className="text-xs text-muted-foreground">{t.toolSettings.crop.positionAndSize}</p>
<div className="grid grid-cols-2 gap-2 mt-1">
<div>
<label htmlFor="crop-x" className="text-[10px] text-muted-foreground">
@@ -358,7 +361,7 @@ export function CropSettings({
className="accent-primary h-3.5 w-3.5"
/>
<Grid3x3 className="h-3.5 w-3.5" />
Rule of Thirds
{t.toolSettings.crop.ruleOfThirds}
</label>
{/* Error */}
@@ -369,7 +372,7 @@ export function CropSettings({
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Cropping"
label={t.toolSettings.crop.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
@@ -381,7 +384,9 @@ export function CropSettings({
disabled={!canSubmit}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Crop (${files.length} files)` : "Crop"}
{files.length > 1
? format(t.toolSettings.crop.submitBatch, { count: files.length })
: t.toolSettings.crop.submit}
</button>
)}
@@ -394,7 +399,7 @@ export function CropSettings({
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
{t.common.download}
</a>
)}
</form>
@@ -409,6 +414,7 @@ export interface CropControlsProps {
}
export function CropControls({ settings: initialSettings, onChange }: CropControlsProps) {
const { t } = useTranslation();
const [left, setLeft] = useState(0);
const [top, setTop] = useState(0);
const [width, setWidth] = useState("");
@@ -443,7 +449,7 @@ export function CropControls({ settings: initialSettings, onChange }: CropContro
<div className="grid grid-cols-2 gap-2">
<div>
<label htmlFor="pipeline-crop-left" className="text-xs text-muted-foreground">
Left offset (px)
{t.toolSettings.crop.leftOffsetPx}
</label>
<input
id="pipeline-crop-left"
@@ -456,7 +462,7 @@ export function CropControls({ settings: initialSettings, onChange }: CropContro
</div>
<div>
<label htmlFor="pipeline-crop-top" className="text-xs text-muted-foreground">
Top offset (px)
{t.toolSettings.crop.topOffsetPx}
</label>
<input
id="pipeline-crop-top"
@@ -776,7 +776,7 @@ export function EditMetadataSettings() {
<button
type="button"
onClick={() => loadTemplate(t.name)}
className="flex-1 text-left text-xs px-2 py-1 rounded-md border border-input hover:bg-muted/50 truncate"
className="flex-1 text-start text-xs px-2 py-1 rounded-md border border-input hover:bg-muted/50 truncate"
>
{t.name}
</button>
@@ -110,7 +110,7 @@ export function EnhanceFacesControls({
/>
<span className="text-sm text-foreground">Only enhance main face</span>
</label>
<p className="text-[11px] text-muted-foreground/70 ml-6 mt-0.5">
<p className="text-[11px] text-muted-foreground/70 ms-6 mt-0.5">
For portraits - ignores background faces
</p>
</div>
@@ -1,7 +1,9 @@
import { Download, Redo, Trash2 } from "lucide-react";
import { useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { format } from "@/lib/format";
import { generateId } from "@/lib/utils";
import { useFileStore } from "@/stores/file-store";
import type { EraserCanvasRef } from "./eraser-canvas";
@@ -36,6 +38,7 @@ export function EraseObjectSettings({
onMaskCenter,
maskedFileCount,
}: EraseObjectSettingsProps) {
const { t } = useTranslation();
const {
files,
entries,
@@ -386,7 +389,7 @@ export function EraseObjectSettings({
<div>
<div className="flex justify-between items-center">
<label htmlFor="eraser-brush-size" className="text-xs text-muted-foreground">
Brush Size
{t.toolSettings["erase-object"].brushSize}
</label>
<span className="text-xs font-mono text-foreground">{brushSize}px</span>
</div>
@@ -400,8 +403,8 @@ export function EraseObjectSettings({
className="w-full mt-1"
/>
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
<span>Fine</span>
<span>Wide</span>
<span>{t.toolSettings["erase-object"].fine}</span>
<span>{t.toolSettings["erase-object"].wide}</span>
</div>
</div>
@@ -493,7 +496,7 @@ export function EraseObjectSettings({
<ProgressCard
active={processing}
phase={progressPhase === "idle" ? "uploading" : progressPhase}
label={progressStage || "Erasing object"}
label={progressStage || t.toolSettings["erase-object"].progressLabel}
percent={progressPercent}
elapsed={elapsed}
/>
@@ -505,7 +508,9 @@ export function EraseObjectSettings({
disabled={!hasFile || (!hasStrokes && maskedFileCount === 0) || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{maskedFileCount > 1 ? `Erase All (${maskedFileCount})` : "Erase Object"}
{maskedFileCount > 1
? format(t.toolSettings["erase-object"].submitBatch, { count: maskedFileCount })
: t.toolSettings["erase-object"].submit}
</button>
)}
@@ -2,7 +2,9 @@ import { Download } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
const SIZES = [
@@ -16,6 +18,7 @@ const SIZES = [
];
export function FaviconSettings() {
const { t } = useTranslation();
const { files, error, setProcessing, setError } = useFileStore();
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
@@ -128,13 +131,14 @@ export function FaviconSettings() {
return (
<div className="space-y-4">
<p className="text-xs text-muted-foreground">
Upload square images (recommended 512x512 or larger) to generate all favicon and app icon
sizes.{" "}
{files.length > 1 && `Each of the ${files.length} images gets its own folder in the ZIP.`}
{t.toolSettings.favicon.uploadHint}{" "}
{files.length > 1 && format(t.toolSettings.favicon.multipleHint, { count: files.length })}
</p>
<div>
<p className="text-xs font-medium text-muted-foreground">Generated Sizes (per image)</p>
<p className="text-xs font-medium text-muted-foreground">
{t.toolSettings.favicon.generatedSizes}
</p>
<div className="mt-1 space-y-0.5">
{SIZES.map((s) => (
<div key={s.name} className="flex justify-between text-xs text-foreground">
@@ -143,7 +147,9 @@ export function FaviconSettings() {
</div>
))}
</div>
<p className="text-[10px] text-muted-foreground mt-1">+ manifest.json + HTML snippet</p>
<p className="text-[10px] text-muted-foreground mt-1">
{t.toolSettings.favicon.plusManifest}
</p>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
@@ -152,7 +158,7 @@ export function FaviconSettings() {
<ProgressCard
active={busy}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Generating Favicons"
label={t.toolSettings.favicon.progressLabel}
stage={
progress.phase === "uploading"
? "Uploading images..."
@@ -169,7 +175,9 @@ export function FaviconSettings() {
disabled={!hasFiles || busy}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
Generate Favicons ({files.length} image{files.length !== 1 ? "s" : ""})
{files.length !== 1
? format(t.toolSettings.favicon.submitPlural, { count: files.length })
: format(t.toolSettings.favicon.submit, { count: files.length })}
</button>
)}
@@ -1,4 +1,5 @@
import { ArrowLeft, ChevronLeft, ChevronRight, Crown, Search } from "lucide-react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatFileSize } from "@/lib/download";
import type { DuplicateFileInfo } from "@/stores/duplicate-store";
import { useDuplicateStore } from "@/stores/duplicate-store";
@@ -63,7 +64,7 @@ function OverviewGrid() {
key={group.groupId}
type="button"
onClick={() => setSelectedGroup(gi)}
className="w-full text-left p-3 rounded-lg bg-muted/50 border border-border hover:border-primary/50 transition-colors"
className="w-full text-start p-3 rounded-lg bg-muted/50 border border-border hover:border-primary/50 transition-colors"
>
<div className="flex justify-between items-center mb-2.5">
<div className="flex items-center gap-2">
@@ -190,7 +191,7 @@ function DetailComparison() {
key={file.filename}
type="button"
onClick={() => overrideBest(selectedGroupIndex, fi)}
className="text-left"
className="text-start"
title={isCurrentBest ? "Selected as best" : "Click to mark as best"}
>
<div
@@ -219,18 +220,16 @@ function DetailComparison() {
<p className="font-medium text-foreground truncate">{file.filename}</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<span className="text-muted-foreground">Dimensions</span>
<span className="text-foreground text-right">
<span className="text-foreground text-end">
{file.width} x {file.height}
</span>
<span className="text-muted-foreground">File size</span>
<span className="text-foreground text-right">
{formatFileSize(file.fileSize)}
</span>
<span className="text-foreground text-end">{formatFileSize(file.fileSize)}</span>
<span className="text-muted-foreground">Format</span>
<span className="text-foreground text-right">{file.format.toUpperCase()}</span>
<span className="text-foreground text-end">{file.format.toUpperCase()}</span>
<span className="text-muted-foreground">Similarity</span>
<span
className={`text-right font-medium ${file.similarity === 100 ? "text-green-500" : "text-yellow-500"}`}
className={`text-end font-medium ${file.similarity === 100 ? "text-green-500" : "text-yellow-500"}`}
>
{file.similarity}%
</span>
@@ -252,6 +251,7 @@ function DetailComparison() {
}
export function FindDuplicatesResults() {
const { t } = useTranslation();
const { results, scanning, viewMode } = useDuplicateStore();
if (scanning) {
@@ -1,5 +1,6 @@
import { Download, FolderArchive, Loader2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { formatFileSize } from "@/lib/download";
import type { DuplicateResult } from "@/stores/duplicate-store";
@@ -15,6 +16,7 @@ const PRESET_DESCRIPTIONS: Record<Preset, string> = {
};
export function FindDuplicatesSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
results,
@@ -1,8 +1,10 @@
import { Download, FlipHorizontal2, FlipVertical2, Link, RotateCw, Unlink } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useGifInfo } from "@/hooks/use-gif-info";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type GifMode = "resize" | "optimize" | "speed" | "reverse" | "extract" | "rotate";
@@ -25,6 +27,7 @@ export interface GifToolsControlsProps {
}
export function GifToolsControls({ settings: initialSettings, onChange }: GifToolsControlsProps) {
const { t } = useTranslation();
const { info, loading: infoLoading } = useGifInfo();
const isAnimated = (info?.pages ?? 0) > 1;
@@ -643,6 +646,7 @@ export function GifToolsControls({ settings: initialSettings, onChange }: GifToo
}
export function GifToolsSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -678,7 +682,7 @@ export function GifToolsSettings() {
<p>
Processed: {(processedSize / 1024).toFixed(1)} KB
{originalSize > 0 && (
<span className="ml-1">
<span className="ms-1">
({Math.round(((processedSize - originalSize) / originalSize) * 100)}%)
</span>
)}
@@ -690,7 +694,7 @@ export function GifToolsSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Processing GIF"
label={t.toolSettings["gif-tools"].progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
@@ -703,7 +707,9 @@ export function GifToolsSettings() {
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Process (${files.length} files)` : "Process"}
{files.length > 1
? format(t.toolSettings["gif-tools"].submitBatch, { count: files.length })
: "Process"}
</button>
)}
@@ -11,7 +11,9 @@ import {
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type EnhancementMode = "auto" | "portrait" | "landscape" | "low-light" | "food" | "document";
@@ -144,6 +146,7 @@ export function ImageEnhancementControls({
onChange,
onPreviewFilter,
}: ImageEnhancementControlsProps) {
const { t } = useTranslation();
const { files } = useFileStore();
const [mode, setMode] = useState<EnhancementMode>("auto");
const [intensity, setIntensity] = useState(50);
@@ -283,7 +286,7 @@ export function ImageEnhancementControls({
{/* Mode selector */}
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
Enhancement Mode
{t.toolSettings.imageEnhancement.enhancementMode}
</p>
<div className="grid grid-cols-3 gap-1">
{MODES.map(({ value, label, icon: Icon }) => (
@@ -307,7 +310,7 @@ export function ImageEnhancementControls({
<div className="pt-1">
<div className="flex justify-between items-center">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
Intensity
{t.toolSettings.imageEnhancement.intensity}
</p>
<span className="text-xs font-mono text-foreground tabular-nums">{intensity}%</span>
</div>
@@ -326,7 +329,7 @@ export function ImageEnhancementControls({
<div className="flex items-center gap-2">
<Wand2 className="h-3.5 w-3.5 text-muted-foreground" />
<div>
<p className="text-xs font-medium">Deep Enhance (AI)</p>
<p className="text-xs font-medium">{t.toolSettings.imageEnhancement.deepEnhance}</p>
<p className="text-[10px] text-muted-foreground">
Removes noise and artifacts using AI
</p>
@@ -358,7 +361,7 @@ export function ImageEnhancementControls({
{analysis && !analyzing && (
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
Detected Issues
{t.toolSettings.imageEnhancement.detectedIssues}
</p>
{analysis.issues.length === 0 ? (
<p className="text-xs text-muted-foreground">
@@ -1,5 +1,6 @@
import { Check, ClipboardCopy, Download, FileJson, FileText, Loader2 } from "lucide-react";
import { useCallback, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import type { Base64Result } from "@/stores/base64-store";
import { useBase64Store } from "@/stores/base64-store";
import { useFileStore } from "@/stores/file-store";
@@ -168,6 +169,7 @@ function FileResult({ result }: { result: Base64Result }) {
// -- Main ResultsPanel ------------------------------------------------------
export function ImageToBase64Results() {
const { t } = useTranslation();
const { results, errors, processing, progress } = useBase64Store();
const { entries, selectedIndex, originalBlobUrl, selectedFileName } = useFileStore();
@@ -1,6 +1,8 @@
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { format } from "@/lib/format";
import { useBase64Store } from "@/stores/base64-store";
import { useFileStore } from "@/stores/file-store";
@@ -14,6 +16,7 @@ const OUTPUT_FORMATS = [
] as const;
export function ImageToBase64Settings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { processing, setProcessing, setProgress, addResult, addError, reset } = useBase64Store();
@@ -1,5 +1,6 @@
import { Loader2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { useFileStore } from "@/stores/file-store";
@@ -31,6 +32,7 @@ interface ImageInfoData {
}
export function InfoSettings() {
const { t } = useTranslation();
const { files, processing, error, setProcessing, setError } = useFileStore();
const selectedIndex = useFileStore((s) => s.selectedIndex);
const [info, setInfo] = useState<ImageInfoData | null>(null);
@@ -163,7 +163,7 @@ function TemplateGallery() {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search templates..."
className={cn(INPUT_CLASS, "pl-8")}
className={cn(INPUT_CLASS, "ps-8")}
/>
</div>
@@ -183,7 +183,7 @@ function TemplateGallery() {
)}
>
{cat.label}
<span className="ml-1 opacity-70">({categoryCounts[cat.id] ?? 0})</span>
<span className="ms-1 opacity-70">({categoryCounts[cat.id] ?? 0})</span>
</button>
))}
</div>
@@ -281,7 +281,7 @@ function LayoutPicker() {
data-testid={`layout-${key}`}
onClick={() => setCustomLayout(key)}
className={cn(
"relative rounded-lg border-2 p-3 transition-all text-left",
"relative rounded-lg border-2 p-3 transition-all text-start",
selected === key
? "border-primary bg-primary/5"
: "border-border hover:border-primary/40",
@@ -8,6 +8,7 @@ import {
Sparkles,
} from "lucide-react";
import { useCallback } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils";
import {
FONT_OPTIONS,
@@ -327,6 +328,7 @@ function ResultSettings() {
// ── Main Settings Component ─────────────────────────────────────────
export function MemeGeneratorSettings() {
const { t } = useTranslation();
const phase = useMemeStore((s) => s.phase);
if (phase === "gallery") return <GallerySettings />;
@@ -1,7 +1,9 @@
import { Download } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type Tier = "quick" | "balanced" | "quality" | "maximum";
@@ -203,6 +205,7 @@ export function NoiseRemovalControls({
}
export function NoiseRemovalSettings() {
const { t } = useTranslation();
const { files, entries } = useFileStore();
const {
processFiles,
@@ -271,7 +274,9 @@ export function NoiseRemovalSettings() {
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{hasMultiple ? `Remove Noise (${files.length} files)` : "Remove Noise"}
{hasMultiple
? format(t.toolSettings["noise-removal"].submitBatch, { count: files.length })
: t.toolSettings["noise-removal"].submit}
</button>
)}
+18 -7
View File
@@ -1,7 +1,9 @@
import { Check, ChevronDown, ChevronRight, Copy, Download, Info } from "lucide-react";
import { useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { format } from "@/lib/format";
import { copyToClipboard, generateId } from "@/lib/utils";
import { useFileStore } from "@/stores/file-store";
@@ -100,6 +102,7 @@ function ocrOneFile(
}
export function OcrSettings() {
const { t } = useTranslation();
const { files, processing, error, setProcessing, setError } = useFileStore();
const [quality, setQuality] = useState<OcrQuality>("balanced");
@@ -219,7 +222,7 @@ export function OcrSettings() {
return (
<div className="space-y-3">
{/* Quality selector */}
<SectionLabel>Quality</SectionLabel>
<SectionLabel>{t.toolSettings.ocr.quality}</SectionLabel>
<div className="grid grid-cols-3 gap-1.5">
{QUALITY_OPTIONS.map((opt) => (
<button
@@ -245,7 +248,9 @@ export function OcrSettings() {
onChange={(e) => handleEnhanceToggle(e.target.checked)}
className="rounded border-border accent-primary"
/>
<span className="text-sm text-muted-foreground">Enhance before scanning</span>
<span className="text-sm text-muted-foreground">
{t.toolSettings.ocr.enhanceBeforeScanning}
</span>
<span
title="Automatically deskews, enhances contrast, removes noise, and upscales the image before scanning for better accuracy."
className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-muted-foreground/40 text-muted-foreground/60 text-[10px] cursor-help"
@@ -263,7 +268,7 @@ export function OcrSettings() {
>
{langOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
Language
<span className="ml-auto text-primary text-[10px] normal-case font-normal">
<span className="ms-auto text-primary text-[10px] normal-case font-normal">
{langLabel}
</span>
</button>
@@ -290,7 +295,7 @@ export function OcrSettings() {
<ProgressCard
active={processing}
phase={progressPhase === "idle" ? "uploading" : progressPhase}
label="Extracting text"
label={t.toolSettings.ocr.progressLabel}
stage={progressStage}
percent={progressPercent}
elapsed={elapsed}
@@ -303,7 +308,9 @@ export function OcrSettings() {
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Extract Text (${files.length} files)` : "Extract Text"}
{files.length > 1
? format(t.toolSettings.ocr.submitBatch, { count: files.length })
: t.toolSettings.ocr.submit}
</button>
)}
@@ -311,7 +318,9 @@ export function OcrSettings() {
{text !== null && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Extracted Text</span>
<span className="text-xs font-medium text-muted-foreground">
{t.toolSettings.ocr.extractedText}
</span>
<div className="flex items-center gap-3">
{text.length > 0 && (
<button
@@ -342,7 +351,9 @@ export function OcrSettings() {
rows={Math.min(16, Math.max(8, text.split("\n").length + 2))}
className="w-full px-2 py-1.5 rounded border border-border bg-muted text-xs text-foreground font-mono resize-y"
/>
<p className="text-[10px] text-muted-foreground">{text.length} characters</p>
<p className="text-[10px] text-muted-foreground">
{format(t.toolSettings.ocr.characters, { count: text.length })}
</p>
</>
) : (
<p className="text-xs text-muted-foreground italic py-4 text-center">
@@ -330,7 +330,7 @@ export function OptimizeForWebSettings() {
{preview.processedSize != null && (
<div className="text-xs text-muted-foreground">
Optimized: {formatSize(preview.processedSize)}
<span className="ml-1 font-medium uppercase text-[10px]">
<span className="ms-1 font-medium uppercase text-[10px]">
{FORMAT_LABELS[format]}
</span>
</div>
@@ -20,6 +20,7 @@ import {
import { useCallback, useEffect, useRef, useState } from "react";
import { create } from "zustand";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { formatHeaders } from "@/lib/api";
import { useFileStore } from "@/stores/file-store";
@@ -324,7 +325,7 @@ function CountryOption({
}`}
>
<span>{spec.flag}</span>
<span className="flex-1 text-left">{spec.name}</span>
<span className="flex-1 text-start">{spec.name}</span>
<span className="text-muted-foreground/60 tabular-nums text-[10px]">
{formatDimensions(doc)}
</span>
@@ -336,6 +337,7 @@ function CountryOption({
// ── Settings panel (left side) ─────────────────────────────────────
export function PassportPhotoSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { error } = useToolProcessor("passport-photo");
@@ -574,9 +576,9 @@ export function PassportPhotoSettings() {
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground hover:border-primary/50 transition-colors"
>
<span>{selectedSpec.flag}</span>
<span className="flex-1 text-left truncate">
<span className="flex-1 text-start truncate">
{selectedSpec.name}
<span className="text-muted-foreground ml-1.5 text-xs">
<span className="text-muted-foreground ms-1.5 text-xs">
{formatDimensions(docSpec)}
</span>
</span>
@@ -605,7 +607,7 @@ export function PassportPhotoSettings() {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search countries..."
className="w-full pl-7 pr-2 py-1.5 rounded border border-border bg-background text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary"
className="w-full ps-7 pe-2 py-1.5 rounded border border-border bg-background text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary"
/>
</div>
</div>
@@ -626,7 +628,7 @@ export function PassportPhotoSettings() {
}`}
>
<span>{"\u2699\uFE0F"}</span>
<span className="flex-1 text-left">Custom Dimensions</span>
<span className="flex-1 text-start">Custom Dimensions</span>
{isCustom && <Check className="h-3 w-3 text-primary shrink-0" />}
</button>
)}
@@ -894,6 +896,7 @@ export function PassportPhotoSettings() {
// ── Preview panel (right side) ────────────────────────────────────
export function PassportPhotoPreview() {
const { t } = useTranslation();
const {
analyzeResult,
countryCode,
@@ -1154,7 +1157,7 @@ export function PassportPhotoPreview() {
<RotateCcw className="h-3.5 w-3.5" />
</button>
)}
<span className="text-[10px] text-muted-foreground ml-auto">
<span className="text-[10px] text-muted-foreground ms-auto">
{pxDims.w}x{pxDims.h}px
</span>
</div>
@@ -82,7 +82,7 @@ export function PdfToImagePreview() {
{store.results.length} page
{store.results.length !== 1 ? "s" : ""} converted
{totalSize > 0 && (
<span className="text-muted-foreground font-normal ml-1">
<span className="text-muted-foreground font-normal ms-1">
({formatSize(totalSize)})
</span>
)}
@@ -187,7 +187,7 @@ export function PdfToImagePreview() {
key={thumb.page}
type="button"
onClick={() => store.togglePage(thumb.page)}
className={`relative rounded-lg border overflow-hidden text-left transition-all ${
className={`relative rounded-lg border overflow-hidden text-start transition-all ${
isSelected
? "border-primary ring-1 ring-primary/30"
: "border-border opacity-50 hover:opacity-75"
@@ -1,5 +1,7 @@
import { Download, FileUp, Loader2, X } from "lucide-react";
import { useCallback, useRef } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { format } from "@/lib/format";
import { usePdfToImageStore } from "@/stores/pdf-to-image-store";
const FORMAT_OPTIONS = [
@@ -37,6 +39,7 @@ const COLOR_MODE_OPTIONS = [
const LOSSY_FORMATS = ["jpg", "webp", "avif", "heic", "heif", "jxl"];
export function PdfToImageSettings() {
const { t } = useTranslation();
const store = usePdfToImageStore();
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -79,7 +82,7 @@ export function PdfToImageSettings() {
className="border-2 border-dashed border-border rounded-lg p-6 text-center cursor-pointer hover:border-primary/50 transition-colors w-full"
>
<FileUp className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Drop a PDF here or click to select</p>
<p className="text-sm text-muted-foreground">{t.toolSettings["pdf-to-image"].dropPdf}</p>
<input
ref={fileInputRef}
type="file"
@@ -115,7 +118,9 @@ export function PdfToImageSettings() {
{/* Output Format - grid buttons */}
<div>
<p className="text-xs text-muted-foreground mb-1.5">Output Format</p>
<p className="text-xs text-muted-foreground mb-1.5">
{t.toolSettings["pdf-to-image"].outputFormat}
</p>
<div className="grid grid-cols-4 gap-1">
{FORMAT_OPTIONS.map((opt) => (
<button
@@ -138,7 +143,9 @@ export function PdfToImageSettings() {
{isLossy && (
<div>
<div className="flex justify-between items-center">
<p className="text-xs text-muted-foreground">Quality</p>
<p className="text-xs text-muted-foreground">
{t.toolSettings["pdf-to-image"].quality}
</p>
<span className="text-xs font-mono text-foreground">{store.quality}</span>
</div>
<input
@@ -154,7 +161,9 @@ export function PdfToImageSettings() {
{/* DPI presets + custom */}
<div>
<p className="text-xs text-muted-foreground mb-1.5">Resolution (DPI)</p>
<p className="text-xs text-muted-foreground mb-1.5">
{t.toolSettings["pdf-to-image"].resolutionDpi}
</p>
<div className="grid grid-cols-5 gap-1">
{DPI_PRESETS.map((opt) => (
<button
@@ -204,7 +213,9 @@ export function PdfToImageSettings() {
{/* Color Mode */}
<div>
<p className="text-xs text-muted-foreground mb-1.5">Color Mode</p>
<p className="text-xs text-muted-foreground mb-1.5">
{t.toolSettings["pdf-to-image"].colorMode}
</p>
<div className="grid grid-cols-3 gap-1">
{COLOR_MODE_OPTIONS.map((opt) => (
<button
@@ -226,7 +237,7 @@ export function PdfToImageSettings() {
{/* Page range input */}
<div>
<label htmlFor="pdf-pages" className="text-xs text-muted-foreground">
Pages
{t.toolSettings["pdf-to-image"].pages}
</label>
<input
id="pdf-pages"
@@ -256,7 +267,7 @@ export function PdfToImageSettings() {
>
{store.processing && <Loader2 className="h-4 w-4 animate-spin" />}
{store.processing
? "Converting..."
? t.toolSettings["pdf-to-image"].converting
: `Convert ${selectedCount} page${selectedCount !== 1 ? "s" : ""}`}
</button>
@@ -16,7 +16,9 @@ import {
import { CSS } from "@dnd-kit/utilities";
import { TOOLS } from "@snapotter/shared";
import { FileImage, GripVertical, X } from "lucide-react";
import { useTranslation } from "@/contexts/i18n-context";
import { ICON_MAP } from "@/lib/icon-map";
import { getToolName } from "@/lib/tool-i18n";
import { cn } from "@/lib/utils";
import type { PipelineStep } from "@/stores/pipeline-store";
import { PipelineStepSettings } from "./pipeline-step-settings";
@@ -52,6 +54,7 @@ function SortableStep({
onRemove,
onUpdateSettings,
}: SortableStepProps) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: step.id,
});
@@ -85,7 +88,7 @@ function SortableStep({
role="button"
tabIndex={0}
onClick={onToggle}
className="flex items-center gap-2 p-3 w-full text-left cursor-pointer"
className="flex items-center gap-2 p-3 w-full text-start cursor-pointer"
>
{/* Drag handle */}
{
@@ -108,11 +111,13 @@ function SortableStep({
{/* Tool icon + name */}
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium text-foreground">{tool.name}</span>
<span className="text-sm font-medium text-foreground">
{getToolName(t, tool.id, tool.name)}
</span>
{/* Settings summary when collapsed */}
{!isExpanded && summary && (
<span className="text-xs text-muted-foreground truncate ml-1">{summary}</span>
<span className="text-xs text-muted-foreground truncate ms-1">{summary}</span>
)}
<span className="flex-1" />
@@ -13,6 +13,7 @@ import {
import QRCodeStyling from "qr-code-styling";
import { useCallback, useRef } from "react";
import { CollapsibleSection } from "@/components/common/collapsible-section";
import { useTranslation } from "@/contexts/i18n-context";
import {
type ContentType,
type CornerDotType,
@@ -345,6 +346,7 @@ function PillButton({
// ── Main settings component ──────────────────────────────────────────
export function QrGenerateSettings() {
const { t } = useTranslation();
const store = useQrStore();
const logoInputRef = useRef<HTMLInputElement>(null);
@@ -515,7 +517,7 @@ export function QrGenerateSettings() {
</label>
{store.dotGradientEnabled && (
<div className="space-y-2 pl-2 border-l-2 border-primary/20 ml-1">
<div className="space-y-2 ps-2 border-s-2 border-primary/20 ms-1">
<div className="flex gap-2">
<div className="flex-1">
<label htmlFor="qr-gradient-from" className="text-[10px] text-muted-foreground">
@@ -764,7 +766,7 @@ export function QrGenerateSettings() {
key={value}
type="button"
onClick={() => store.setDownloadFormat(value)}
className={`text-left px-2 py-1.5 rounded transition-colors ${
className={`text-start px-2 py-1.5 rounded transition-colors ${
store.downloadFormat === value
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground"
@@ -1,7 +1,9 @@
import { Download } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
const LOSSY_FORMATS = new Set(["jpeg", "webp", "avif", "jxl"]);
@@ -140,6 +142,7 @@ export function RedEyeRemovalControls({
}
export function RedEyeRemovalSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -196,7 +199,9 @@ export function RedEyeRemovalSettings() {
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{hasMultiple ? `Fix Red Eye (${files.length} files)` : "Fix Red Eye"}
{hasMultiple
? format(t.toolSettings["red-eye-removal"].submitBatch, { count: files.length })
: t.toolSettings["red-eye-removal"].submit}
</button>
)}
@@ -9,8 +9,10 @@ import {
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { formatHeaders } from "@/lib/api";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type SubjectType = "people" | "products" | "general";
@@ -84,6 +86,7 @@ export interface RemoveBgControlsProps {
}
export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgControlsProps) {
const { t } = useTranslation();
const [subject, setSubject] = useState<SubjectType>("people");
const [quality, setQuality] = useState<Quality>("balanced");
const [isPassport, setIsPassport] = useState(true);
@@ -167,7 +170,7 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
return (
<div className="space-y-3">
{/* Subject type */}
<SectionLabel>Subject</SectionLabel>
<SectionLabel>{t.toolSettings["remove-background"].subject}</SectionLabel>
<div className="grid grid-cols-3 gap-1.5">
{SUBJECT_OPTIONS.map((opt) => {
const Icon = opt.icon;
@@ -202,12 +205,14 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
onChange={(e) => setIsPassport(e.target.checked)}
className="rounded border-border accent-primary"
/>
<span className="text-sm text-muted-foreground">Passport / ID photo</span>
<span className="text-sm text-muted-foreground">
{t.toolSettings["remove-background"].passportIdPhoto}
</span>
</label>
)}
{/* Quality */}
<SectionLabel>Quality</SectionLabel>
<SectionLabel>{t.toolSettings["remove-background"].quality}</SectionLabel>
<div className={`grid gap-1.5 ${qualityOptions.length > 3 ? "grid-cols-4" : "grid-cols-3"}`}>
{qualityOptions.map((opt) => (
<button
@@ -226,7 +231,7 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
</div>
{/* Background */}
<SectionLabel>Background</SectionLabel>
<SectionLabel>{t.toolSettings["remove-background"].background}</SectionLabel>
<div className="space-y-2">
{/* Type buttons */}
<div className="flex gap-1.5 flex-wrap">
@@ -258,7 +263,7 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
{/* Color options */}
{bgType === "color" && (
<div className="space-y-2 pl-1">
<div className="space-y-2 ps-1">
<div className="flex gap-1.5 flex-wrap">
{COLOR_PRESETS.map((preset) => (
<button
@@ -293,7 +298,7 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
{/* Gradient options */}
{bgType === "gradient" && (
<div className="space-y-2 pl-1">
<div className="space-y-2 ps-1">
<div className="flex gap-1.5 flex-wrap">
{GRADIENT_PRESETS.map((preset) => (
<button
@@ -351,7 +356,7 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
{/* Image upload */}
{bgType === "image" && (
<div className="pl-1">
<div className="ps-1">
{bgImageFile ? (
<div className="flex items-center gap-2 text-xs">
<span className="text-foreground truncate flex-1">{bgImageFile.name}</span>
@@ -391,12 +396,12 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
{effectsOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
Effects
{(blurEnabled || shadowEnabled) && (
<span className="ml-auto text-primary text-[10px] normal-case font-normal">active</span>
<span className="ms-auto text-primary text-[10px] normal-case font-normal">active</span>
)}
</button>
{effectsOpen && (
<div className="space-y-3 pl-1">
<div className="space-y-3 ps-1">
{/* Blur */}
<div>
<label className="flex items-center gap-2 cursor-pointer">
@@ -406,13 +411,15 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
onChange={(e) => setBlurEnabled(e.target.checked)}
className="rounded border-border accent-primary"
/>
<span className="text-xs text-muted-foreground">Blur Background</span>
<span className="text-xs text-muted-foreground">
{t.toolSettings["remove-background"].blurBackground}
</span>
</label>
{blurEnabled && (
<div className="mt-1.5 pl-5">
<div className="mt-1.5 ps-5">
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Intensity</span>
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-right">
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-end">
{blurIntensity}
</span>
</div>
@@ -437,13 +444,15 @@ export function RemoveBgControls({ settings: _settings, onChange }: RemoveBgCont
onChange={(e) => setShadowEnabled(e.target.checked)}
className="rounded border-border accent-primary"
/>
<span className="text-xs text-muted-foreground">Add Shadow</span>
<span className="text-xs text-muted-foreground">
{t.toolSettings["remove-background"].addShadow}
</span>
</label>
{shadowEnabled && (
<div className="mt-1.5 pl-5">
<div className="mt-1.5 ps-5">
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Opacity</span>
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-right">
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-end">
{shadowOpacity}
</span>
</div>
@@ -526,6 +535,7 @@ interface RemoveBgSettingsProps {
}
export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -796,7 +806,7 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Removing background"
label={t.toolSettings["remove-background"].progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
@@ -809,7 +819,9 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Remove Background (${files.length} files)` : "Remove Background"}
{files.length > 1
? format(t.toolSettings["remove-background"].submitBatch, { count: files.length })
: t.toolSettings["remove-background"].submit}
</button>
) : null}
@@ -825,7 +837,7 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<Download className="h-4 w-4" />
{applyingEffects ? "Rendering..." : "Download"}
{applyingEffects ? t.toolSettings["remove-background"].rendering : "Download"}
</button>
) : (
<a
@@ -2,17 +2,15 @@ import { SOCIAL_MEDIA_PRESETS } from "@snapotter/shared";
import { Download, Info, Link, Unlink } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type ResizeTab = "presets" | "custom" | "scale" | "content-aware";
type FitMode = "cover" | "contain" | "fill";
const FIT_LABELS: Record<FitMode, string> = {
cover: "Crop to fit",
contain: "Fit inside",
fill: "Stretch",
};
const FIT_MODES: FitMode[] = ["cover", "contain", "fill"];
// Group presets by platform
const platforms = [...new Set(SOCIAL_MEDIA_PRESETS.map((p) => p.platform))];
@@ -34,6 +32,7 @@ export interface ResizeControlsProps {
}
export function ResizeControls({ settings: initialSettings, onChange }: ResizeControlsProps) {
const { t } = useTranslation();
const [tab, setTab] = useState<ResizeTab>("custom");
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
const [width, setWidth] = useState<string>("");
@@ -127,7 +126,7 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
<div className="flex items-end gap-2">
<div className="flex-1">
<label htmlFor="resize-width" className="text-xs text-muted-foreground">
Width (px)
{t.toolSettings.resize.widthPx}
</label>
<input
id="resize-width"
@@ -149,7 +148,7 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
</button>
<div className="flex-1">
<label htmlFor="resize-height" className="text-xs text-muted-foreground">
Height (px)
{t.toolSettings.resize.heightPx}
</label>
<input
id="resize-height"
@@ -172,8 +171,8 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
onChange={(e) => setWithoutEnlargement(e.target.checked)}
className="rounded"
/>
<span>Limit to original size</span>
<HintIcon text="If your image is already smaller than the target, keep it as-is instead of scaling it up" />
<span>{t.toolSettings.resize.limitToOriginalSize}</span>
<HintIcon text={t.toolSettings.resize.limitToOriginalSizeHint} />
</label>
);
@@ -183,27 +182,27 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
<div>
<div className="flex gap-1">
<button type="button" onClick={() => setTab("custom")} className={tabClass("custom")}>
Custom Size
{t.toolSettings.resize.customSize}
</button>
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
Scale
{t.toolSettings.resize.scale}
</button>
<button type="button" onClick={() => setTab("presets")} className={tabClass("presets")}>
Presets
{t.toolSettings.resize.presets}
</button>
<button
type="button"
onClick={() => setTab("content-aware")}
className={tabClass("content-aware")}
>
Content-Aware
{t.toolSettings.resize.contentAware}
</button>
</div>
</div>
{/* Presets tab */}
{tab === "presets" && (
<div className="space-y-3 max-h-[50vh] overflow-y-auto pr-1">
<div className="space-y-3 max-h-[50vh] overflow-y-auto pe-1">
{platforms.map((platform) => (
<div key={platform}>
<p className="text-xs font-medium text-muted-foreground mb-1.5">{platform}</p>
@@ -244,16 +243,20 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
{/* Fit mode */}
<div>
<p className="text-xs text-muted-foreground">Fit Mode</p>
<p className="text-xs text-muted-foreground">{t.toolSettings.resize.fitMode}</p>
<div className="flex gap-1 mt-1">
{(Object.keys(FIT_LABELS) as FitMode[]).map((f) => (
{FIT_MODES.map((f) => (
<button
key={f}
type="button"
onClick={() => setFit(f)}
className={`flex-1 text-xs py-1.5 rounded ${fit === f ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
{FIT_LABELS[f]}
{f === "cover"
? t.toolSettings.resize.cropToFit
: f === "contain"
? t.toolSettings.resize.fitInside
: t.toolSettings.resize.stretch}
</button>
))}
</div>
@@ -311,7 +314,7 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
onChange={(e) => setSquareMode(e.target.checked)}
className="rounded"
/>
Resize to square
{t.toolSettings.resize.resizeToSquare}
</label>
{/* Face protection */}
@@ -322,7 +325,7 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
onChange={(e) => setProtectFaces(e.target.checked)}
className="rounded"
/>
Protect faces
{t.toolSettings.resize.protectFaces}
</label>
{/* Blur radius */}
@@ -369,6 +372,7 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
}
export function ResizeSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const standardResize = useToolProcessor("resize");
const contentAwareResize = useToolProcessor("content-aware-resize");
@@ -420,7 +424,7 @@ export function ResizeSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Resizing"
label={t.toolSettings.resize.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
@@ -432,7 +436,9 @@ export function ResizeSettings() {
disabled={!canProcess}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Resize (${files.length} files)` : "Resize"}
{files.length > 1
? format(t.toolSettings.resize.submitBatch, { count: files.length })
: t.toolSettings.resize.submit}
</button>
)}
@@ -445,7 +451,7 @@ export function ResizeSettings() {
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download
{t.common.download}
</a>
)}
</form>
@@ -1,7 +1,9 @@
import { Download } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export interface RestorePhotoControlsProps {
@@ -13,6 +15,7 @@ export function RestorePhotoControls({
settings: initialSettings,
onChange,
}: RestorePhotoControlsProps) {
const { t } = useTranslation();
const [scratchRemoval, setScratchRemoval] = useState(true);
const [faceEnhancement, setFaceEnhancement] = useState(true);
const [fidelity, setFidelity] = useState(70);
@@ -103,7 +106,7 @@ export function RestorePhotoControls({
{/* Fidelity slider (only when face enhancement is on) */}
{faceEnhancement && (
<div className="pl-2 border-l-2 border-primary/20">
<div className="ps-2 border-s-2 border-primary/20">
<div className="flex justify-between items-center">
<p className="text-xs text-muted-foreground">Face Fidelity</p>
<span className="text-xs font-mono tabular-nums">{fidelity}%</span>
@@ -142,7 +145,7 @@ export function RestorePhotoControls({
{/* Denoise strength slider */}
{denoise && (
<div className="pl-2 border-l-2 border-primary/20">
<div className="ps-2 border-s-2 border-primary/20">
<div className="flex justify-between items-center">
<p className="text-xs text-muted-foreground">Denoise Strength</p>
<span className="text-xs font-mono tabular-nums">{denoiseStrength}</span>
@@ -183,7 +186,7 @@ export function RestorePhotoControls({
{/* Colorize strength slider */}
{colorize && (
<div className="pl-2 border-l-2 border-primary/20">
<div className="ps-2 border-s-2 border-primary/20">
<div className="flex justify-between items-center">
<p className="text-xs text-muted-foreground">Colorize Strength</p>
<span className="text-xs font-mono tabular-nums">{colorizeStrength}%</span>
@@ -209,6 +212,7 @@ export function RestorePhotoControls({
}
export function RestorePhotoSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -265,7 +269,9 @@ export function RestorePhotoSettings() {
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{hasMultiple ? `Restore Photos (${files.length})` : "Restore Photo"}
{hasMultiple
? format(t.toolSettings["restore-photo"].submitBatch, { count: files.length })
: t.toolSettings["restore-photo"].submit}
</button>
)}
@@ -162,7 +162,7 @@ export function RotateControls({
commitAngleInput();
}
}}
className="w-16 text-center text-sm font-mono font-medium tabular-nums py-1.5 rounded-md bg-background border border-border focus:outline-none focus:ring-2 focus:ring-primary/50 pr-4"
className="w-16 text-center text-sm font-mono font-medium tabular-nums py-1.5 rounded-md bg-background border border-border focus:outline-none focus:ring-2 focus:ring-primary/50 pe-4"
/>
<span className="absolute right-2 text-sm font-mono text-muted-foreground pointer-events-none">
°
@@ -2,7 +2,9 @@ import { ChevronDown, ChevronRight, Download } from "lucide-react";
import type React from "react";
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type Method = "adaptive" | "unsharp-mask" | "high-pass";
@@ -29,6 +31,7 @@ const PRESETS: Preset[] = [
];
export function SharpeningSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -103,7 +106,7 @@ export function SharpeningSettings() {
return (
<form onSubmit={handleSubmit} className="space-y-3">
{/* Method selector */}
<SectionLabel>Method</SectionLabel>
<SectionLabel>{t.toolSettings.sharpening.method}</SectionLabel>
<div className="grid grid-cols-3 gap-1">
{(["adaptive", "unsharp-mask", "high-pass"] as const).map((m) => (
<button
@@ -127,7 +130,7 @@ export function SharpeningSettings() {
{/* Presets (adaptive only) */}
{method === "adaptive" && (
<>
<SectionLabel>Presets</SectionLabel>
<SectionLabel>{t.toolSettings.sharpening.presets}</SectionLabel>
<div className="grid grid-cols-4 gap-1">
{PRESETS.map((p) => (
<button
@@ -199,7 +202,7 @@ export function SharpeningSettings() {
</div>
{/* Noise reduction */}
<SectionLabel>Noise Reduction</SectionLabel>
<SectionLabel>{t.toolSettings.sharpening.noiseReduction}</SectionLabel>
<div className="grid grid-cols-4 gap-1">
{(["off", "light", "medium", "strong"] as const).map((d) => (
<button
@@ -228,7 +231,7 @@ export function SharpeningSettings() {
</button>
{advancedOpen && (
<div className="space-y-2 pl-1">
<div className="space-y-2 ps-1">
{method === "adaptive" && (
<>
<SliderControl
@@ -359,7 +362,7 @@ export function SharpeningSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Sharpening"
label={t.toolSettings.sharpening.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
@@ -371,7 +374,9 @@ export function SharpeningSettings() {
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Sharpen (${files.length} files)` : "Sharpen"}
{files.length > 1
? format(t.toolSettings.sharpening.submitBatch, { count: files.length })
: t.toolSettings.sharpening.submit}
</button>
)}
@@ -424,9 +429,9 @@ function SliderControl({
<div className="flex justify-between items-center">
<label htmlFor={id} className={`text-xs ${color || "text-muted-foreground"}`}>
{label}
{hint && <span className="text-[10px] text-muted-foreground/60 ml-1">({hint})</span>}
{hint && <span className="text-[10px] text-muted-foreground/60 ms-1">({hint})</span>}
</label>
<span className="text-xs font-mono text-foreground tabular-nums w-10 text-right">
<span className="text-xs font-mono text-foreground tabular-nums w-10 text-end">
{displayValue}
</span>
</div>
@@ -2,7 +2,9 @@ import { SMART_CROP_FACE_PRESETS, SOCIAL_MEDIA_PRESETS } from "@snapotter/shared
import { ArrowLeftRight, Info } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type CropMode = "subject" | "face" | "trim";
@@ -36,6 +38,7 @@ export interface SmartCropControlsProps {
}
export function SmartCropControls({ settings: initialSettings, onChange }: SmartCropControlsProps) {
const { t } = useTranslation();
const [mode, setMode] = useState<CropMode>("subject");
const [subjectTab, setSubjectTab] = useState<SubjectTab>("custom");
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
@@ -261,13 +264,13 @@ export function SmartCropControls({ settings: initialSettings, onChange }: Smart
onClick={() => setMode("subject")}
className={modeTabClass("subject")}
>
Subject Focus
{t.toolSettings["smart-crop"].subjectFocus}
</button>
<button type="button" onClick={() => setMode("face")} className={modeTabClass("face")}>
Face Focus
{t.toolSettings["smart-crop"].faceFocus}
</button>
<button type="button" onClick={() => setMode("trim")} className={modeTabClass("trim")}>
Auto Trim
{t.toolSettings["smart-crop"].autoTrim}
</button>
</div>
@@ -293,7 +296,7 @@ export function SmartCropControls({ settings: initialSettings, onChange }: Smart
</div>
{subjectTab === "presets" ? (
<div className="space-y-3 max-h-[50vh] overflow-y-auto pr-1">
<div className="space-y-3 max-h-[50vh] overflow-y-auto pe-1">
{platforms.map((platform) => (
<div key={platform}>
<p className="text-xs font-medium text-muted-foreground mb-1.5">{platform}</p>
@@ -333,7 +336,9 @@ export function SmartCropControls({ settings: initialSettings, onChange }: Smart
{/* Strategy toggle */}
<div>
<div className="flex items-center gap-1.5 mb-1">
<span className="text-xs text-muted-foreground">Detection Strategy</span>
<span className="text-xs text-muted-foreground">
{t.toolSettings["smart-crop"].detectionStrategy}
</span>
<HintIcon text="Attention finds the most visually salient region. Entropy finds the area with most detail and information." />
</div>
<div className="flex gap-1">
@@ -546,6 +551,7 @@ export function SmartCropControls({ settings: initialSettings, onChange }: Smart
}
export function SmartCropSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("smart-crop");
@@ -1,6 +1,7 @@
import { Download, Loader2, PackageOpen } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { CollapsibleSection } from "@/components/common/collapsible-section";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { useFileStore } from "@/stores/file-store";
import type { SplitMode } from "@/stores/split-store";
@@ -35,6 +36,7 @@ const OUTPUT_FORMATS = [
const LOSSY_FORMATS = new Set(["jpg", "webp", "avif", "jxl"]);
export function SplitSettings() {
const { t } = useTranslation();
const { files, processing: fileStoreProcessing } = useFileStore();
const {
mode,
@@ -1,5 +1,6 @@
import { Download, Loader2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { useFileStore } from "@/stores/file-store";
@@ -9,6 +10,7 @@ type Alignment = "start" | "center" | "end";
type OutputFormat = "png" | "jpeg" | "webp" | "avif" | "jxl";
export function StitchSettings() {
const { t } = useTranslation();
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
useFileStore();
@@ -143,7 +143,7 @@ export function StripMetadataControls({
/>
Strip EXIF (camera info, date, exposure)
{hasExif && !stripAll && (
<span className="ml-auto text-[10px] text-muted-foreground">
<span className="ms-auto text-[10px] text-muted-foreground">
{Object.keys(metadata?.exif ?? {}).filter((k) => !SKIP_KEYS.has(k)).length} fields
</span>
)}
@@ -161,7 +161,7 @@ export function StripMetadataControls({
/>
Strip GPS (location data)
{hasGps && !stripAll && (
<span className="ml-auto text-[10px] text-amber-500">location found</span>
<span className="ms-auto text-[10px] text-amber-500">location found</span>
)}
</label>
@@ -1,7 +1,9 @@
import { Download } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
export interface TextOverlayControlsProps {
@@ -155,6 +157,7 @@ export function TextOverlayControls({
}
export function TextOverlaySettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
+10 -4
View File
@@ -2,8 +2,10 @@ import { CATEGORIES, TOOLS } from "@snapotter/shared";
import { FileImage, Plus } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { SearchBar } from "@/components/common/search-bar";
import { useTranslation } from "@/contexts/i18n-context";
import { apiGet } from "@/lib/api";
import { ICON_MAP } from "@/lib/icon-map";
import { getCategoryName, getToolDescription, getToolName } from "@/lib/tool-i18n";
import { cn } from "@/lib/utils";
const EXCLUDED_TOOLS = new Set(["pipeline", "compare", "find-duplicates", "collage", "compose"]);
@@ -14,6 +16,7 @@ interface ToolPaletteProps {
}
export function ToolPalette({ onAddStep, className }: ToolPaletteProps) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [disabledTools, setDisabledTools] = useState<string[]>([]);
const [experimentalEnabled, setExperimentalEnabled] = useState(false);
@@ -87,7 +90,7 @@ export function ToolPalette({ onAddStep, className }: ToolPaletteProps) {
<div className="flex items-center gap-1.5 mb-1.5 px-1">
<CatIcon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold uppercase text-muted-foreground tracking-wider">
{cat.name}
{getCategoryName(t, cat.id, cat.name)}
</span>
</div>
<div className="space-y-0.5">
@@ -111,21 +114,24 @@ interface ToolItemProps {
}
function ToolItem({ tool, onAdd }: ToolItemProps) {
const { t } = useTranslation();
const Icon = (ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage;
return (
<button
type="button"
onClick={() => onAdd(tool.id)}
className="flex items-center gap-2.5 w-full px-2.5 py-2 rounded-lg hover:bg-muted text-left transition-colors group"
className="flex items-center gap-2.5 w-full px-2.5 py-2 rounded-lg hover:bg-muted text-start transition-colors group"
>
<div className="p-1.5 rounded-md bg-muted group-hover:bg-primary/10 transition-colors shrink-0">
<Icon className="h-3.5 w-3.5 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground leading-tight">{tool.name}</div>
<div className="text-sm font-medium text-foreground leading-tight">
{getToolName(t, tool.id, tool.name)}
</div>
<div className="text-[11px] text-muted-foreground truncate leading-tight">
{tool.description}
{getToolDescription(t, tool.id, tool.description)}
</div>
</div>
<Plus className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
@@ -1,7 +1,9 @@
import { ChevronDown, ChevronRight, Droplets } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type OutputFormat = "png" | "webp";
@@ -17,6 +19,7 @@ export function TransparencyFixerControls({
settings: _settings,
onChange,
}: TransparencyFixerControlsProps) {
const { t } = useTranslation();
const [defringe, setDefringe] = useState(30);
const [outputFormat, setOutputFormat] = useState<OutputFormat>("png");
const [removeWatermark, setRemoveWatermark] = useState(false);
@@ -76,12 +79,12 @@ export function TransparencyFixerControls({
</button>
{advancedOpen && (
<div className="space-y-3 pl-1">
<div className="space-y-3 ps-1">
{/* Defringe slider */}
<div>
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Defringe</span>
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-right">
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-end">
{defringe}
</span>
</div>
@@ -118,6 +121,7 @@ export function TransparencyFixerControls({
// ── Standalone tool page wrapper ──
export function TransparencyFixerSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("transparency-fixer");
@@ -160,7 +164,9 @@ export function TransparencyFixerSettings() {
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasMultiple ? `Fix Transparency (${files.length} files)` : "Fix Transparency"}
{hasMultiple
? format(t.toolSettings["transparency-fixer"].submitBatch, { count: files.length })
: t.toolSettings["transparency-fixer"].submit}
</button>
)}
</div>
@@ -1,7 +1,9 @@
import { Download } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
const QUICK_SCALES = [2, 3, 4, 6, 8];
@@ -29,6 +31,7 @@ export interface UpscaleControlsProps {
}
export function UpscaleControls({ settings: initialSettings, onChange }: UpscaleControlsProps) {
const { t } = useTranslation();
const [scale, setScale] = useState(2);
const [model, setModel] = useState<"auto" | "realesrgan" | "lanczos">("auto");
const [faceEnhance, setFaceEnhance] = useState(false);
@@ -70,7 +73,9 @@ export function UpscaleControls({ settings: initialSettings, onChange }: Upscale
{/* Scale factor */}
<div>
<div className="flex justify-between items-center">
<p className="text-sm font-medium text-muted-foreground">Scale Factor</p>
<p className="text-sm font-medium text-muted-foreground">
{t.toolSettings.upscale.scaleFactor}
</p>
<span className="text-sm font-mono font-medium">{scale}x</span>
</div>
<div className="flex gap-1 mt-1.5">
@@ -130,14 +135,16 @@ export function UpscaleControls({ settings: initialSettings, onChange }: Upscale
onChange={(e) => setFaceEnhance(e.target.checked)}
className="rounded border-border"
/>
<span className="text-sm text-foreground">Enhance faces</span>
<span className="text-sm text-foreground">{t.toolSettings.upscale.enhanceFaces}</span>
</label>
)}
{/* Noise Reduction */}
<div>
<div className="flex justify-between items-center">
<p className="text-sm font-medium text-muted-foreground">Noise Reduction</p>
<p className="text-sm font-medium text-muted-foreground">
{t.toolSettings.upscale.noiseReduction}
</p>
<span className="text-sm font-mono font-medium">
{denoise === 0 ? "Off" : denoise.toFixed(1)}
</span>
@@ -198,6 +205,7 @@ export function UpscaleControls({ settings: initialSettings, onChange }: Upscale
}
export function UpscaleSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -242,7 +250,7 @@ export function UpscaleSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={hasMultiple ? `Upscaling ${files.length} images` : "Upscaling image"}
label={t.toolSettings.upscale.progressLabel}
percent={progress.percent}
elapsed={progress.elapsed}
/>
@@ -255,8 +263,11 @@ export function UpscaleSettings() {
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{hasMultiple
? `Upscale ${(settings.scale as number) ?? 2}x (${files.length} files)`
: `Upscale ${(settings.scale as number) ?? 2}x`}
? format(t.toolSettings.upscale.submitBatch, {
scale: (settings.scale as number) ?? 2,
count: files.length,
})
: format(t.toolSettings.upscale.submit, { scale: (settings.scale as number) ?? 2 })}
</button>
)}
@@ -1,7 +1,9 @@
import { Download } from "lucide-react";
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type ColorMode = "bw" | "color";
@@ -72,6 +74,7 @@ function speckleToDetail(speckle: number): Detail {
}
export function VectorizeSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -353,7 +356,7 @@ export function VectorizeSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Vectorizing"
label={t.toolSettings.vectorize.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
@@ -366,7 +369,9 @@ export function VectorizeSettings() {
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{files.length > 1 ? `Vectorize (${files.length} files)` : "Vectorize"}
{files.length > 1
? format(t.toolSettings.vectorize.submitBatch, { count: files.length })
: t.toolSettings.vectorize.submit}
</button>
)}
@@ -1,10 +1,13 @@
import { Download, Loader2, Upload } from "lucide-react";
import { useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right";
export function WatermarkImageSettings() {
const { t } = useTranslation();
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
useFileStore();
const [position, setPosition] = useState<Position>("bottom-right");
@@ -198,8 +201,8 @@ export function WatermarkImageSettings() {
{processing
? "Processing..."
: files.length > 1
? `Apply Watermark (${files.length} files)`
: "Apply Watermark"}
? format(t.toolSettings["watermark-image"].submitBatch, { count: files.length })
: t.toolSettings["watermark-image"].submit}
</button>
{downloadUrl && (
@@ -1,7 +1,9 @@
import { Download } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right" | "tiled";
@@ -149,6 +151,7 @@ export function WatermarkTextControls({
}
export function WatermarkTextSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
processFiles,
@@ -190,7 +193,7 @@ export function WatermarkTextSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Adding watermark"
label={t.toolSettings["watermark-text"].progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}