feat: add multi-language support for 20 locales

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

- I18nProvider context with three-tier locale detection
  (user preference > navigator.languages > instance default > English)
- ~1500 translation keys per locale with TypeScript-enforced completeness
- Dynamic code-splitting: only the active locale is loaded at runtime
- Language selectors in footer, login page, settings, and mobile sidebar
- Arabic RTL support with CSS logical properties across all components
- Tool names, descriptions, and categories translated via i18n helpers
- Public API endpoint GET /api/v1/config/locale for instance default
- Multi-script font stack (CJK, Arabic, Devanagari, Thai, Cyrillic)
- format() and plural() helpers for interpolation and pluralization
- API error translation mapping (translateApiError)
- 36 Playwright e2e tests verifying all 21 locales load correctly
- 25 unit tests for format, plural, locale detection, and completeness
- Updated translations.md docs and CLAUDE.md conventions
This commit is contained in:
SnapOtter
2026-05-15 17:02:49 +08:00
parent 3a82936d93
commit d38621d7b9
141 changed files with 43160 additions and 936 deletions
+11 -8
View File
@@ -1,12 +1,11 @@
import { en } from "@snapotter/shared";
import { Shield } from "lucide-react";
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "@/contexts/i18n-context";
import { useAnalyticsStore } from "@/stores/analytics-store";
const t = en.analytics;
export function AnalyticsConsentPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { config, configLoaded, fetchConfig, acceptAnalytics, declineAnalytics, remindLater } =
useAnalyticsStore();
@@ -49,11 +48,15 @@ export function AnalyticsConsentPage() {
</div>
<div className="space-y-3 text-center">
<h1 className="text-lg font-semibold text-foreground">{t.consentTitle}</h1>
<p className="text-sm leading-relaxed text-muted-foreground">{t.consentDescription}</p>
<h1 className="text-lg font-semibold text-foreground">{t.analytics.consentTitle}</h1>
<p className="text-sm leading-relaxed text-muted-foreground">
{t.analytics.consentDescription}
</p>
</div>
<p className="text-center text-xs text-muted-foreground/60">{t.consentChangeable}</p>
<p className="text-center text-xs text-muted-foreground/60">
{t.analytics.consentChangeable}
</p>
<div className="flex gap-2.5">
<button
@@ -61,14 +64,14 @@ export function AnalyticsConsentPage() {
onClick={handleAccept}
className="flex-1 rounded-[10px] bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{t.acceptButton}
{t.analytics.acceptButton}
</button>
<button
type="button"
onClick={handleDecline}
className="flex-1 rounded-[10px] border border-border px-4 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted"
>
{t.declineButton}
{t.analytics.declineButton}
</button>
</div>
</div>
+43 -34
View File
@@ -26,15 +26,18 @@ import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
import { AppLayout } from "@/components/layout/app-layout";
import { PipelineBuilder } from "@/components/tools/pipeline-builder";
import { ToolPalette } from "@/components/tools/tool-palette";
import { useTranslation } from "@/contexts/i18n-context";
import { useMobile } from "@/hooks/use-mobile";
import { usePipelineProcessor } from "@/hooks/use-pipeline-processor";
import { formatHeaders, getFileDownloadUrl } from "@/lib/api";
import { formatFileSize } from "@/lib/download";
import { format } from "@/lib/format";
import { cn } from "@/lib/utils";
import { useFileStore } from "@/stores/file-store";
import { type SavedPipeline, usePipelineStore } from "@/stores/pipeline-store";
export function AutomatePage() {
const { t } = useTranslation();
const {
files,
entries,
@@ -273,19 +276,19 @@ export function AutomatePage() {
const data = JSON.parse(text);
if (data.format !== "snapotter-pipeline") {
setImportError("Not a valid SnapOtter pipeline file");
setImportError(t.automate.invalidPipelineFile);
return;
}
if (typeof data.version !== "number" || data.version > 1) {
setImportError("This pipeline was created with a newer version of SnapOtter");
setImportError(t.automate.newerVersion);
return;
}
if (!data.name || typeof data.name !== "string") {
setImportError("Pipeline file is missing a name");
setImportError(t.automate.missingName);
return;
}
if (!Array.isArray(data.steps) || data.steps.length === 0) {
setImportError("Pipeline file has no steps");
setImportError(t.automate.noSteps);
return;
}
@@ -313,7 +316,7 @@ export function AutomatePage() {
setSavedPipelines(listData.pipelines || []);
}
} catch {
setImportError("Could not read pipeline file");
setImportError(t.automate.couldNotRead);
}
};
input.click();
@@ -366,7 +369,7 @@ export function AutomatePage() {
{/* Mobile header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border shrink-0">
<Workflow className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold text-foreground flex-1">Automate</h1>
<h1 className="text-base font-semibold text-foreground flex-1">{t.automate.title}</h1>
{hasFile && (
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{files.length} file{files.length !== 1 ? "s" : ""}
@@ -385,7 +388,7 @@ export function AutomatePage() {
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm text-primary border border-dashed border-primary/40 rounded-lg hover:bg-primary/5 transition-colors"
>
<FolderOpen className="h-4 w-4" />
Import from Library
{t.automate.importFromLibrary}
</button>
</div>
)}
@@ -455,7 +458,7 @@ export function AutomatePage() {
{hasFile && !hasProcessed && currentEntry?.status === "failed" && (
<div className="mb-3 rounded-lg border border-border p-3 text-center">
<p className="text-sm text-red-500">
{currentEntry.error ?? "Processing failed for this file"}
{currentEntry.error ?? t.toolPage.processingFailed}
</p>
</div>
)}
@@ -493,8 +496,8 @@ export function AutomatePage() {
phase={progress.phase === "idle" ? "processing" : progress.phase}
label={
files.length > 1
? `Processing ${files.length} files...`
: "Processing pipeline..."
? format(t.automate.processingMultiple, { count: files.length })
: t.automate.processingPipeline
}
stage={progress.stage}
percent={progress.percent}
@@ -513,7 +516,7 @@ export function AutomatePage() {
className="flex-1 py-2.5 rounded-lg bg-primary text-primary-foreground font-medium flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Play className="h-4 w-4" />
{files.length <= 1 ? "Process" : `Process (${files.length})`}
{files.length <= 1 ? t.common.process : `${t.common.process} (${files.length})`}
</button>
{hasProcessed && batchZipBlob && (
<button
@@ -545,7 +548,7 @@ export function AutomatePage() {
/>
<div className="fixed inset-x-0 bottom-0 z-50 bg-background border-t border-border rounded-t-2xl shadow-xl max-h-[70vh] flex flex-col animate-in slide-in-from-bottom">
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
<h2 className="text-sm font-semibold text-foreground">Add Tool</h2>
<h2 className="text-sm font-semibold text-foreground">{t.automate.addTool}</h2>
<button
type="button"
onClick={() => setMobileToolPaletteOpen(false)}
@@ -582,8 +585,8 @@ export function AutomatePage() {
<Layers className="h-5 w-5" />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">Tool Palette</h2>
<p className="text-xs text-muted-foreground">Click to add to pipeline</p>
<h2 className="text-sm font-semibold text-foreground">{t.automate.toolPalette}</h2>
<p className="text-xs text-muted-foreground">{t.automate.clickToAdd}</p>
</div>
</div>
@@ -594,7 +597,7 @@ export function AutomatePage() {
<div className="px-3 py-2 border-t border-border shrink-0">
<div className="flex items-center justify-between mb-1.5">
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider">
Saved
{t.automate.savedLabel}
</h3>
<button
type="button"
@@ -612,7 +615,7 @@ export function AutomatePage() {
</div>
)}
{savedPipelines.length === 0 ? (
<p className="text-xs text-muted-foreground italic">No saved pipelines</p>
<p className="text-xs text-muted-foreground italic">{t.automate.noSavedPipelines}</p>
) : showAllSaved ? (
<div className="space-y-1 max-h-36 overflow-y-auto">
{savedPipelines.map((p) => (
@@ -620,10 +623,10 @@ export function AutomatePage() {
<button
type="button"
onClick={() => handleLoadPipeline(p)}
className="flex-1 text-left text-xs text-foreground hover:text-primary truncate py-1 px-2 rounded hover:bg-muted"
className="flex-1 text-start text-xs text-foreground hover:text-primary truncate py-1 px-2 rounded hover:bg-muted"
>
{p.name}
<span className="text-muted-foreground ml-1">
<span className="text-muted-foreground ms-1">
({p.steps.length} step{p.steps.length !== 1 ? "s" : ""})
</span>
</button>
@@ -660,10 +663,10 @@ export function AutomatePage() {
<button
type="button"
onClick={() => handleLoadPipeline(p)}
className="flex-1 text-left text-xs text-foreground hover:text-primary truncate py-1 px-2 rounded hover:bg-muted"
className="flex-1 text-start text-xs text-foreground hover:text-primary truncate py-1 px-2 rounded hover:bg-muted"
>
{p.name}
<span className="text-muted-foreground ml-1">
<span className="text-muted-foreground ms-1">
({p.steps.length} step{p.steps.length !== 1 ? "s" : ""})
</span>
</button>
@@ -697,11 +700,13 @@ export function AutomatePage() {
<div className="flex items-center gap-3 px-5 py-3 border-b border-border shrink-0">
<Workflow className="h-5 w-5 text-primary" />
<div className="flex-1 min-w-0">
<h1 className="text-lg font-semibold text-foreground">Pipeline Builder</h1>
<h1 className="text-lg font-semibold text-foreground">
{t.automate.pipelineBuilder}
</h1>
<p className="text-xs text-muted-foreground">
{steps.length === 0
? "Add tools from the palette to get started"
: `${steps.length} step${steps.length !== 1 ? "s" : ""} configured`}
? t.automate.addToolsPrompt
: `${steps.length} ${steps.length !== 1 ? "steps" : "step"} configured`}
</p>
</div>
@@ -744,14 +749,16 @@ export function AutomatePage() {
</div>
) : (
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-muted-foreground italic">No files loaded</span>
<span className="text-xs text-muted-foreground italic">
{t.automate.noFilesLoaded}
</span>
<button
type="button"
onClick={() => setLibraryModalOpen(true)}
className="text-xs text-primary hover:text-primary/80 flex items-center gap-1"
>
<FolderOpen className="h-3 w-3" />
Import from Library
{t.automate.importFromLibrary}
</button>
</div>
)}
@@ -804,7 +811,9 @@ export function AutomatePage() {
className="px-5 py-2.5 rounded-lg bg-primary text-primary-foreground font-medium flex items-center gap-2 hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Play className="h-4 w-4" />
{files.length <= 1 ? "Process" : `Process All (${files.length})`}
{files.length <= 1
? t.common.process
: format(t.automate.processAllButton, { count: files.length })}
</button>
{hasProcessed && batchZipBlob && (
@@ -814,7 +823,7 @@ export function AutomatePage() {
className="px-4 py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center gap-2 hover:bg-primary/5"
>
<Download className="h-4 w-4" />
Download ZIP
{t.automate.downloadZip}
</button>
)}
@@ -837,14 +846,14 @@ export function AutomatePage() {
type="text"
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
placeholder="Pipeline name"
placeholder={t.automate.pipelineName}
className="text-sm px-2.5 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground w-36"
/>
<input
type="text"
value={saveDescription}
onChange={(e) => setSaveDescription(e.target.value)}
placeholder="Description"
placeholder={t.automate.descriptionPlaceholder}
className="text-sm px-2.5 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground w-36"
/>
<button
@@ -853,7 +862,7 @@ export function AutomatePage() {
disabled={!saveName.trim() || saving}
className="px-3 py-1.5 rounded bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50"
>
{saving ? "..." : "Save"}
{saving ? "..." : t.common.save}
</button>
<button
type="button"
@@ -889,10 +898,10 @@ export function AutomatePage() {
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Preview
{t.automate.preview}
</span>
{hasFile && hasProcessed && processedSize != null && (
<span className="text-xs text-muted-foreground ml-auto">
<span className="text-xs text-muted-foreground ms-auto">
{formatFileSize(originalSize ?? 0)} &rarr; {formatFileSize(processedSize)}
</span>
)}
@@ -948,7 +957,7 @@ export function AutomatePage() {
className="flex items-center gap-2 px-4 py-2 text-sm text-primary border border-dashed border-primary/40 rounded-lg hover:bg-primary/5 transition-colors"
>
<FolderOpen className="h-4 w-4" />
Import from Library
{t.automate.importFromLibrary}
</button>
</div>
)}
@@ -956,7 +965,7 @@ export function AutomatePage() {
{hasFile && !hasProcessed && currentEntry?.status === "failed" && (
<div className="flex flex-col items-center justify-center gap-2 h-full text-center px-4">
<p className="text-sm text-red-500">
{currentEntry.error ?? "Processing failed for this file"}
{currentEntry.error ?? t.toolPage.processingFailed}
</p>
</div>
)}
+18 -21
View File
@@ -1,4 +1,5 @@
import { type FormEvent, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
/**
@@ -61,6 +62,7 @@ function generatePassword(): string {
}
export function ChangePasswordPage() {
const { t } = useTranslation();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
@@ -80,7 +82,7 @@ export function ChangePasswordPage() {
setError("");
if (newPassword !== confirmPassword) {
setError("Passwords do not match");
setError(t.changePassword.passwordsMismatch);
return;
}
@@ -103,7 +105,7 @@ export function ChangePasswordPage() {
triggerBrowserPasswordSave(username, newPassword);
return; // navigation happens inside triggerBrowserPasswordSave
} catch {
setError("Connection error");
setError(t.changePassword.failedError);
} finally {
setLoading(false);
}
@@ -117,16 +119,13 @@ export function ChangePasswordPage() {
<h1 className="text-3xl font-bold text-foreground">
<span className="text-primary">SnapOtter</span>
</h1>
<h2 className="text-2xl font-bold mt-4 text-foreground">Change your password</h2>
<p className="text-sm text-muted-foreground mt-2">
You need to set a new password before continuing. Your password must be at least 8
characters with uppercase, lowercase, and a number.
</p>
<h2 className="text-2xl font-bold mt-4 text-foreground">{t.changePassword.title}</h2>
<p className="text-sm text-muted-foreground mt-2">{t.changePassword.description}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium mb-1 text-foreground">
Username
{t.changePassword.usernameLabel}
</label>
<input
id="username"
@@ -143,7 +142,7 @@ export function ChangePasswordPage() {
htmlFor="current-password"
className="block text-sm font-medium mb-1 text-foreground"
>
Current password
{t.changePassword.currentPasswordLabel}
</label>
<input
id="current-password"
@@ -151,7 +150,7 @@ export function ChangePasswordPage() {
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Enter current password"
placeholder={t.changePassword.currentPasswordPlaceholder}
className="w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
required
/>
@@ -159,14 +158,14 @@ export function ChangePasswordPage() {
<div>
<div className="flex items-center justify-between mb-1">
<label htmlFor="new-password" className="text-sm font-medium text-foreground">
New password
{t.changePassword.newPasswordLabel}
</label>
<button
type="button"
onClick={handleGenerate}
className="text-xs text-primary hover:text-primary/80 font-medium"
>
Generate strong password
{t.changePassword.generateButton}
</button>
</div>
<input
@@ -178,7 +177,7 @@ export function ChangePasswordPage() {
setNewPassword(e.target.value);
setShowGenerated(false);
}}
placeholder="At least 8 characters"
placeholder={t.changePassword.newPasswordPlaceholder}
className={`w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 ${showGenerated ? "font-mono text-sm" : ""}`}
required
minLength={8}
@@ -189,7 +188,7 @@ export function ChangePasswordPage() {
htmlFor="confirm-password"
className="block text-sm font-medium mb-1 text-foreground"
>
Confirm new password
{t.changePassword.confirmPasswordLabel}
</label>
<input
id="confirm-password"
@@ -200,7 +199,7 @@ export function ChangePasswordPage() {
setConfirmPassword(e.target.value);
setShowGenerated(false);
}}
placeholder="Repeat new password"
placeholder={t.changePassword.confirmPasswordPlaceholder}
className={`w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 ${showGenerated ? "font-mono text-sm" : ""}`}
required
minLength={8}
@@ -212,17 +211,15 @@ export function ChangePasswordPage() {
disabled={loading || !currentPassword || !newPassword || !confirmPassword}
className="w-full py-3 rounded-lg bg-primary/80 text-primary-foreground font-medium hover:bg-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Changing..." : "Change password"}
{loading ? t.changePassword.changingButton : t.changePassword.changeButton}
</button>
</form>
</div>
</div>
<div className="hidden lg:flex flex-1 bg-primary/90 items-center justify-center p-12 text-white rounded-l-3xl">
<div className="hidden lg:flex flex-1 bg-primary/90 items-center justify-center p-12 text-white rounded-s-3xl">
<div className="max-w-lg space-y-6 text-center">
<h2 className="text-3xl font-bold">Almost there</h2>
<p className="text-lg text-white/80">
Set a strong password to secure your account, then you are good to go.
</p>
<h2 className="text-3xl font-bold">{t.changePassword.sidebarTitle}</h2>
<p className="text-lg text-white/80">{t.changePassword.sidebarDescription}</p>
</div>
</div>
</div>
+4 -5
View File
@@ -19,6 +19,7 @@ import { EditorOptionsBar } from "@/components/editor/editor-options-bar";
import { EditorRightPanel } from "@/components/editor/editor-right-panel";
import { EditorStatusBar } from "@/components/editor/editor-status-bar";
import { EditorToolbar } from "@/components/editor/editor-toolbar";
import { useTranslation } from "@/contexts/i18n-context";
import { useEditorShortcuts } from "@/hooks/use-editor-shortcuts";
import { useMobile } from "@/hooks/use-mobile";
import { useEditorStore } from "@/stores/editor-store";
@@ -26,6 +27,7 @@ import { useEditorStore } from "@/stores/editor-store";
const SERVER_DECODED_EXTS = new Set(["psd", "tga", "exr", "hdr"]);
export function EditorPage() {
const { t } = useTranslation();
const isMobile = useMobile();
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
const isDirty = useEditorStore((s) => s.isDirty);
@@ -141,11 +143,8 @@ export function EditorPage() {
return (
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
<Monitor size={48} className="text-muted-foreground mb-4" />
<h2 className="text-lg font-semibold text-foreground mb-2">Desktop Recommended</h2>
<p className="text-sm text-muted-foreground max-w-sm">
The image editor works best on desktop screens. Please switch to a device with a larger
display for the full editing experience.
</p>
<h2 className="text-lg font-semibold text-foreground mb-2">{t.editor.mobile.heading}</h2>
<p className="text-sm text-muted-foreground max-w-sm">{t.editor.mobile.description}</p>
</div>
);
}
+5 -3
View File
@@ -5,10 +5,12 @@ import { FileList } from "@/components/files/file-list";
import { FileUploadArea } from "@/components/files/file-upload-area";
import { FilesNav } from "@/components/files/files-nav";
import { AppLayout } from "@/components/layout/app-layout";
import { useTranslation } from "@/contexts/i18n-context";
import { useMobile } from "@/hooks/use-mobile";
import { useFilesPageStore } from "@/stores/files-page-store";
export function FilesPage() {
const { t } = useTranslation();
const { activeTab, setActiveTab, selectedFileId } = useFilesPageStore();
const isMobile = useMobile();
const [showDetails, setShowDetails] = useState(false);
@@ -28,7 +30,7 @@ export function FilesPage() {
: "text-muted-foreground"
}`}
>
Recent
{t.files.recentTab}
</button>
<button
type="button"
@@ -39,7 +41,7 @@ export function FilesPage() {
: "text-muted-foreground"
}`}
>
Upload
{t.files.uploadTab}
</button>
</div>
@@ -77,7 +79,7 @@ export function FilesPage() {
>
<div className="absolute bottom-0 left-0 right-0 bg-background rounded-t-xl p-4 max-h-[70vh] overflow-y-auto">
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-semibold">File Details</span>
<span className="text-sm font-semibold">{t.files.fileDetailsHeading}</span>
<button type="button" onClick={() => setShowDetails(false)}>
<X className="h-5 w-5 text-muted-foreground" />
</button>
+18 -10
View File
@@ -4,13 +4,16 @@ import { Eye, EyeOff, FileImage, LayoutGrid, List, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { OtterLogo } from "@/components/common/otter-logo";
import { useTranslation } from "@/contexts/i18n-context";
import { track } from "@/lib/analytics";
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";
import { useFeaturesStore } from "@/stores/features-store";
export function FullscreenGridPage() {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [showDetails, setShowDetails] = useState(true);
const navigate = useNavigate();
@@ -95,8 +98,8 @@ export function FullscreenGridPage() {
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search tools..."
className="w-full pl-10 pr-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder={t.common.search}
className="w-full ps-10 pe-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
@@ -113,7 +116,7 @@ export function FullscreenGridPage() {
>
{showDetails ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
<span className="hidden sm:inline">
{showDetails ? "Hide Details" : "Show Details"}
{showDetails ? t.fullscreenGrid.hideDetails : t.fullscreenGrid.showDetails}
</span>
</button>
@@ -125,7 +128,7 @@ export function FullscreenGridPage() {
title="Switch to sidebar view"
>
<List className="h-4 w-4" />
<span className="hidden sm:inline">Sidebar</span>
<span className="hidden sm:inline">{t.fullscreenGrid.sidebarButton}</span>
</button>
</div>
</header>
@@ -135,8 +138,8 @@ export function FullscreenGridPage() {
{activeCategories.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<Search className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p className="text-lg font-medium">No tools found</p>
<p className="text-sm mt-1">Try a different search term</p>
<p className="text-lg font-medium">{t.fullscreenGrid.noToolsFound}</p>
<p className="text-sm mt-1">{t.fullscreenGrid.tryDifferent}</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
@@ -164,6 +167,7 @@ function CategoryCard({
tools: Tool[];
showDetails: boolean;
}) {
const { t } = useTranslation();
const CategoryIcon =
(ICON_MAP[category.icon] as React.ComponentType<{ className?: string }>) ?? LayoutGrid;
@@ -181,7 +185,9 @@ function CategoryCard({
<CategoryIcon className="h-5 w-5" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-foreground text-sm">{category.name}</h3>
<h3 className="font-semibold text-foreground text-sm">
{getCategoryName(t, category.id, category.name)}
</h3>
</div>
<span
className="text-xs font-medium px-2 py-0.5 rounded-full"
@@ -207,16 +213,18 @@ function CategoryCard({
>
<ToolIcon className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">{tool.name}</p>
<p className="text-sm font-medium text-foreground">
{getToolName(t, tool.id, tool.name)}
</p>
{showDetails && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{tool.description}
{getToolDescription(t, tool.id, tool.description)}
</p>
)}
</div>
{tool.experimental && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 font-medium shrink-0">
Experimental
{t.common.experimental}
</span>
)}
</Link>
+21 -16
View File
@@ -5,7 +5,9 @@ import { useNavigate } from "react-router-dom";
import { ImageViewer } from "@/components/common/image-viewer";
import { MultiImageViewer } from "@/components/common/multi-image-viewer";
import { AppLayout } from "@/components/layout/app-layout";
import { useTranslation } from "@/contexts/i18n-context";
import { ICON_MAP } from "@/lib/icon-map";
import { getCategoryName, getToolName } from "@/lib/tool-i18n";
import { useFeaturesStore } from "@/stores/features-store";
import { useFileStore } from "@/stores/file-store";
import { useSettingsStore } from "@/stores/settings-store";
@@ -16,6 +18,7 @@ const QUICK_ACTION_IDS = ["resize", "compress", "convert", "remove-background"];
let hasAppliedDefaultRedirect = false;
export function HomePage() {
const { t } = useTranslation();
const {
setFiles,
files,
@@ -108,14 +111,14 @@ export function HomePage() {
onClick={reset}
className="text-xs text-muted-foreground hover:text-foreground mt-2"
>
Change file
{t.homePage.changeFile}
</button>
</div>
{/* Quick actions */}
<div className="p-4 border-b border-border">
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-3">
Quick Actions
{t.homePage.quickActions}
</h3>
<div className="grid grid-cols-2 gap-2">
{QUICK_ACTION_IDS.map((id) => {
@@ -130,20 +133,22 @@ export function HomePage() {
key={id}
type="button"
onClick={() => navigate(tool.route)}
className="flex items-center gap-2 p-3 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left"
className="flex items-center gap-2 p-3 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-colors text-start"
>
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
<Icon className="h-4 w-4" />
</div>
<span className="text-xs font-medium text-foreground">{tool.name}</span>
<span className="text-xs font-medium text-foreground">
{getToolName(t, tool.id, tool.name)}
</span>
{status === "not_installed" && (
<Download className="h-3.5 w-3.5 text-muted-foreground ml-auto" />
<Download className="h-3.5 w-3.5 text-muted-foreground ms-auto" />
)}
{status === "queued" && (
<Clock className="h-3.5 w-3.5 text-muted-foreground ml-auto" />
<Clock className="h-3.5 w-3.5 text-muted-foreground ms-auto" />
)}
{status === "installing" && (
<Loader2 className="h-3.5 w-3.5 text-muted-foreground ml-auto animate-spin" />
<Loader2 className="h-3.5 w-3.5 text-muted-foreground ms-auto animate-spin" />
)}
</button>
);
@@ -154,7 +159,7 @@ export function HomePage() {
{/* All tools by category */}
<div className="p-4">
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-3">
All Tools
{t.homePage.allTools}
</h3>
{CATEGORIES.map((category) => {
const categoryTools = TOOLS.filter((t) => t.category === category.id);
@@ -165,7 +170,7 @@ export function HomePage() {
className="text-xs font-medium text-muted-foreground mb-1.5"
style={{ color: category.color }}
>
{category.name}
{getCategoryName(t, category.id, category.name)}
</p>
<div className="space-y-0.5">
{categoryTools.map((tool) => {
@@ -178,18 +183,18 @@ export function HomePage() {
key={tool.id}
type="button"
onClick={() => navigate(tool.route)}
className="flex items-center gap-2.5 w-full py-1.5 px-2 rounded-lg text-left transition-colors hover:bg-muted text-foreground"
className="flex items-center gap-2.5 w-full py-1.5 px-2 rounded-lg text-start transition-colors hover:bg-muted text-foreground"
>
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm">{tool.name}</span>
<span className="text-sm">{getToolName(t, tool.id, tool.name)}</span>
{status === "not_installed" && (
<Download className="h-3.5 w-3.5 text-muted-foreground ml-auto" />
<Download className="h-3.5 w-3.5 text-muted-foreground ms-auto" />
)}
{status === "queued" && (
<Clock className="h-3.5 w-3.5 text-muted-foreground ml-auto" />
<Clock className="h-3.5 w-3.5 text-muted-foreground ms-auto" />
)}
{status === "installing" && (
<Loader2 className="h-3.5 w-3.5 text-muted-foreground ml-auto animate-spin" />
<Loader2 className="h-3.5 w-3.5 text-muted-foreground ms-auto animate-spin" />
)}
</button>
);
@@ -208,7 +213,7 @@ export function HomePage() {
) : currentEntry?.previewLoading ? (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
<p className="text-sm text-muted-foreground">Generating preview...</p>
<p className="text-sm text-muted-foreground">{t.homePage.generatingPreview}</p>
<p className="text-xs text-muted-foreground/60">{selectedFileName}</p>
</div>
) : originalBlobUrl ? (
@@ -219,7 +224,7 @@ export function HomePage() {
/>
) : (
<div className="text-center text-muted-foreground">
<p>Loading preview...</p>
<p>{t.homePage.loadingPreview}</p>
</div>
)}
</div>
+118 -53
View File
@@ -1,37 +1,13 @@
import { type FormEvent, useCallback, useEffect, useState } from "react";
import { type FormEvent, useCallback, useEffect, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "@/contexts/i18n-context";
import { useAuth } from "@/hooks/use-auth";
import { setToken } from "@/lib/api";
const phrases = [
"No signups. No accounts.",
"No uploads to the cloud. Ever.",
"100% local processing.",
"Free. Forever.",
"No limits. No hidden caps.",
"Works fully offline.",
"Unlimited batch processing.",
"51 image tools.",
"15 AI models. Your hardware.",
"Lightning fast. Built on Sharp.",
"Air-gapped ready.",
"One Docker container.",
"Open source. Always.",
"Every pixel stays on your hardware.",
"Your server. Your rules.",
"Deploys in a single docker pull.",
"The self-hosted alternative.",
"Resize, compress, convert. Locally.",
"Free image editor. No watermarks.",
"Bulk image processing. Unlimited.",
"Remove backgrounds offline.",
"Self-hosted Canva alternative.",
"No API keys needed.",
"GDPR compliant by design.",
"Works without internet.",
];
import { format } from "@/lib/format";
function RotatingPhrase() {
const { t } = useTranslation();
const phrases = t.auth.rotatingPhrases;
const [index, setIndex] = useState(0);
const [visible, setVisible] = useState(true);
@@ -41,7 +17,7 @@ function RotatingPhrase() {
setIndex((i) => (i + 1) % phrases.length);
setVisible(true);
}, 300);
}, []);
}, [phrases.length]);
useEffect(() => {
const timer = setInterval(advance, 3000);
@@ -61,7 +37,96 @@ function RotatingPhrase() {
);
}
function LanguageSelector() {
const { locale, setLocale, supportedLocales } = useTranslation();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
if (open) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
const current = supportedLocales.find((l) => l.code === locale);
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 text-sm text-foreground/70 hover:text-foreground transition-colors px-3 py-2 rounded-lg border border-border hover:bg-muted/50"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
role="img"
aria-label="Language"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>
{current?.nativeName ?? "English"}
</button>
{open && (
<div className="absolute bottom-full mb-1 left-0 w-56 max-h-64 overflow-y-auto rounded-lg border border-border bg-background shadow-lg z-50">
{supportedLocales.map((l) => (
<button
key={l.code}
type="button"
onClick={() => {
setLocale(l.code);
setOpen(false);
}}
className="w-full text-start px-3 py-2 text-sm hover:bg-muted/50 flex items-center justify-between transition-colors"
>
<span
className={l.code === locale ? "font-medium text-foreground" : "text-foreground"}
>
{l.nativeName}
</span>
{l.code === locale && (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-primary shrink-0"
role="img"
aria-label="Selected"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</button>
))}
</div>
)}
</div>
);
}
export function LoginPage() {
const { t } = useTranslation();
const { oidcEnabled, oidcProviderName } = useAuth();
const [searchParams] = useSearchParams();
const [username, setUsername] = useState("");
@@ -73,16 +138,15 @@ export function LoginPage() {
const oidcError = searchParams.get("error");
if (oidcError) {
const errorMessages: Record<string, string> = {
oidc_auth_failed: "Authentication failed. Please try again.",
oidc_provider_unreachable: "Could not reach the identity provider. Please try again later.",
oidc_session_expired: "Login session expired. Please try again.",
oidc_user_not_authorized:
"Your account is not authorized to access this application. Contact your administrator.",
oidc_user_limit_reached: "User limit reached. Contact your administrator.",
oidc_auth_failed: t.auth.oidcAuthFailed,
oidc_provider_unreachable: t.auth.oidcProviderUnreachable,
oidc_session_expired: t.auth.oidcSessionExpired,
oidc_user_not_authorized: t.auth.oidcUserNotAuthorized,
oidc_user_limit_reached: t.auth.oidcUserLimitReached,
};
setError(errorMessages[oidcError] || "Authentication error. Please try again.");
setError(errorMessages[oidcError] || t.auth.oidcGenericError);
}
}, [searchParams]);
}, [searchParams, t]);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
@@ -96,21 +160,19 @@ export function LoginPage() {
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.error || "Invalid username or password");
setError(t.auth.invalidCredentials);
return;
}
const data = await res.json();
setToken(data.token);
// Store username for settings display
localStorage.setItem("snapotter-username", data.user?.username || username);
// Redirect to password change if required, otherwise go home
if (data.user?.mustChangePassword) {
window.location.href = "/change-password";
} else {
window.location.href = "/";
}
} catch {
setError("Connection error");
setError(t.auth.connectionError);
} finally {
setLoading(false);
}
@@ -124,12 +186,12 @@ export function LoginPage() {
<h1 className="text-3xl font-bold text-foreground">
<span className="text-primary">SnapOtter</span>
</h1>
<h2 className="text-2xl font-bold mt-4 text-foreground">Login</h2>
<h2 className="text-2xl font-bold mt-4 text-foreground">{t.auth.login}</h2>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium mb-1 text-foreground">
Username
{t.auth.username}
</label>
<input
id="username"
@@ -138,14 +200,14 @@ export function LoginPage() {
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter username"
placeholder={t.auth.enterUsername}
className="w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
required
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1 text-foreground">
Password
{t.auth.password}
</label>
<input
id="password"
@@ -154,7 +216,7 @@ export function LoginPage() {
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
placeholder={t.auth.enterPassword}
className="w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
required
/>
@@ -165,30 +227,33 @@ export function LoginPage() {
disabled={loading || !username || !password}
className="w-full py-3 rounded-lg bg-primary/80 text-primary-foreground font-medium hover:bg-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Logging in..." : "Login"}
{loading ? t.auth.loggingIn : t.auth.loginButton}
</button>
</form>
{oidcEnabled && (
<>
<div className="flex items-center gap-3 my-4">
<div className="flex-1 border-t border-border" />
<span className="text-sm text-muted-foreground">or</span>
<span className="text-sm text-muted-foreground">{t.auth.or}</span>
<div className="flex-1 border-t border-border" />
</div>
<a
href="/api/auth/oidc/login"
className="w-full py-3 rounded-lg bg-secondary text-secondary-foreground font-medium hover:bg-secondary/80 transition-colors flex items-center justify-center gap-2"
>
Sign in with {oidcProviderName || "SSO"}
{format(t.auth.signInWith, { provider: oidcProviderName || "SSO" })}
</a>
</>
)}
<div className="pt-2">
<LanguageSelector />
</div>
</div>
</div>
<div className="hidden lg:flex flex-1 bg-primary/90 items-center justify-center p-12 text-white rounded-l-3xl">
<div className="hidden lg:flex flex-1 bg-primary/90 items-center justify-center p-12 text-white rounded-s-3xl">
<div className="max-w-lg space-y-4 text-center">
<h2 className="text-4xl font-extrabold tracking-tight">Your images. Stay yours.</h2>
<p className="text-lg text-white/70">Every image tool you need.</p>
<h2 className="text-4xl font-extrabold tracking-tight">{t.auth.heroTitle}</h2>
<p className="text-lg text-white/70">{t.auth.heroSubtitle}</p>
<p className="text-xl font-medium h-8">
<RotatingPhrase />
</p>
+6 -4
View File
@@ -1,7 +1,9 @@
import { ArrowLeft } from "lucide-react";
import { Link } from "react-router-dom";
import { useTranslation } from "@/contexts/i18n-context";
export function PrivacyPolicyPage() {
const { t } = useTranslation();
return (
<div className="min-h-screen bg-background text-foreground">
<div className="max-w-2xl mx-auto px-6 py-12">
@@ -10,10 +12,10 @@ export function PrivacyPolicyPage() {
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors mb-8"
>
<ArrowLeft className="h-4 w-4" />
Back to app
{t.common.back}
</Link>
<h1 className="text-3xl font-bold mb-2">Privacy Policy</h1>
<h1 className="text-3xl font-bold mb-2">{t.common.privacyPolicy}</h1>
<p className="text-sm text-muted-foreground mb-8">Last updated: April 22, 2026</p>
<div className="space-y-6 text-sm leading-relaxed text-muted-foreground">
@@ -42,13 +44,13 @@ export function PrivacyPolicyPage() {
SnapOtter includes optional, anonymous product analytics. When you choose to
participate, the following is collected:
</p>
<ul className="list-disc pl-5 mt-2 space-y-1">
<ul className="list-disc ps-5 mt-2 space-y-1">
<li>Which tools you use (e.g., "crop tool used")</li>
<li>Error reports without file data</li>
<li>App version and performance metrics</li>
</ul>
<p className="mt-2 font-medium">What is never collected:</p>
<ul className="list-disc pl-5 mt-2 space-y-1">
<ul className="list-disc ps-5 mt-2 space-y-1">
<li>Your images, PDFs, and files</li>
<li>File names and contents</li>
<li>Any personal information or IP addresses</li>
+21 -15
View File
@@ -22,10 +22,12 @@ import { CropCanvas } from "@/components/tools/crop-canvas";
import type { EraserCanvasRef } from "@/components/tools/eraser-canvas";
import { EraserCanvas } from "@/components/tools/eraser-canvas";
import type { PreviewTransform } from "@/components/tools/rotate-settings";
import { useTranslation } from "@/contexts/i18n-context";
import { useAuth } from "@/hooks/use-auth";
import { useMobile } from "@/hooks/use-mobile";
import { formatFileSize } from "@/lib/download";
import { ICON_MAP } from "@/lib/icon-map";
import { getToolName } from "@/lib/tool-i18n";
import { getToolRegistryEntry } from "@/lib/tool-registry";
import { useBase64Store } from "@/stores/base64-store";
import { useCollageStore } from "@/stores/collage-store";
@@ -78,12 +80,11 @@ function FileSelectionInfo({
onClear: () => void;
onAddMore: () => void;
}) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
if (files.length === 0) {
return (
<p className="text-xs text-muted-foreground italic">Drop or upload an image to get started</p>
);
return <p className="text-xs text-muted-foreground italic">{t.toolPage.emptyPrompt}</p>;
}
const showToggle = files.length > COLLAPSED_LIMIT;
@@ -110,7 +111,7 @@ function FileSelectionInfo({
key={`${file.name}-${i}`}
type="button"
onClick={() => onSelect(i)}
className={`w-full flex items-center gap-1.5 text-xs rounded px-2 py-1.5 text-left transition-colors ${isSelected ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-muted"}`}
className={`w-full flex items-center gap-1.5 text-xs rounded px-2 py-1.5 text-start transition-colors ${isSelected ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-muted"}`}
>
{isSelected && <CheckCircle2 className="h-3 w-3 text-primary shrink-0" />}
<span className="truncate flex-1 min-w-0">{file.name}</span>
@@ -148,6 +149,7 @@ function FileSelectionInfo({
}
export function ToolPage() {
const { t } = useTranslation();
const { toolId } = useParams<{ toolId: string }>();
const tool = useMemo(() => TOOLS.find((t) => t.id === toolId), [toolId]);
const registryEntry = useMemo(
@@ -344,7 +346,7 @@ export function ToolPage() {
return (
<AppLayout>
<div className="flex items-center justify-center h-full text-muted-foreground">
Tool not found
{t.toolPage.notFound}
</div>
</AppLayout>
);
@@ -354,7 +356,7 @@ export function ToolPage() {
return (
<AppLayout>
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading...
{t.common.loading}
</div>
</AppLayout>
);
@@ -444,7 +446,7 @@ export function ToolPage() {
}
return (
<div className="text-center text-muted-foreground">
<p className="text-sm">Configure settings and generate.</p>
<p className="text-sm">{t.toolPage.configureAndGenerate}</p>
</div>
);
}
@@ -455,7 +457,7 @@ export function ToolPage() {
return (
<div className="flex flex-col items-center justify-center gap-3 h-full text-center px-4">
<p className="text-sm text-red-500">
{currentEntry.error ?? "Processing failed for this file"}
{currentEntry.error ?? t.toolPage.processingFailed}
</p>
</div>
);
@@ -549,7 +551,7 @@ export function ToolPage() {
<CheckCircle2 className="h-8 w-8 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-sm font-medium">Conversion complete</p>
<p className="text-sm font-medium">{t.toolPage.conversionComplete}</p>
<p className="text-xs text-muted-foreground mt-1">{processedFileName}</p>
{processedSize != null && (
<p className="text-xs text-muted-foreground">
@@ -633,7 +635,7 @@ export function ToolPage() {
return (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
<p className="text-sm text-muted-foreground">Generating preview...</p>
<p className="text-sm text-muted-foreground">{t.toolPage.generatingPreview}</p>
<p className="text-xs text-muted-foreground/60">{selectedFileName}</p>
</div>
);
@@ -748,7 +750,7 @@ export function ToolPage() {
<div className="border-t border-border" />
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">Settings</h3>
<h3 className="text-sm font-medium text-muted-foreground">{t.common.settings}</h3>
<Suspense fallback={<div className="text-xs text-muted-foreground">Loading...</div>}>
<ToolSettings {...settingsProps} />
</Suspense>
@@ -762,7 +764,7 @@ export function ToolPage() {
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 All (ZIP)
{t.toolPage.downloadAllZip}
</button>
)}
@@ -795,13 +797,15 @@ export function ToolPage() {
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
<IconComponent className="h-5 w-5" />
</div>
<h2 className="font-semibold text-lg text-foreground flex-1">{tool.name}</h2>
<h2 className="font-semibold text-lg text-foreground flex-1">
{getToolName(t, tool.id, tool.name)}
</h2>
<button
type="button"
onClick={() => setMobileSettingsOpen(!mobileSettingsOpen)}
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted-foreground hover:bg-muted"
>
{mobileSettingsOpen ? "Hide Settings" : "Settings"}
{mobileSettingsOpen ? t.toolPage.hideSettings : t.common.settings}
</button>
</div>
@@ -846,7 +850,9 @@ export function ToolPage() {
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
<IconComponent className="h-5 w-5" />
</div>
<h2 className="font-semibold text-lg text-foreground">{tool.name}</h2>
<h2 className="font-semibold text-lg text-foreground">
{getToolName(t, tool.id, tool.name)}
</h2>
</div>
{renderSettingsContent()}