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