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 type { FeatureBundleState } from "@snapotter/shared";
|
||||
import { Clock, Download, Loader2, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { apiGet } from "@/lib/api";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFeaturesStore } from "@/stores/features-store";
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
@@ -52,6 +54,7 @@ const PROGRESS_MESSAGES = [
|
||||
];
|
||||
|
||||
export function AiFeaturesSection() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
bundles,
|
||||
fetch,
|
||||
@@ -95,10 +98,8 @@ export function AiFeaturesSection() {
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">AI Features</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage AI model bundles for advanced image processing.
|
||||
</p>
|
||||
<h3 className="text-lg font-semibold text-foreground">{t.settings.aiFeatures.title}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t.settings.aiFeatures.description}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -107,7 +108,7 @@ export function AiFeaturesSection() {
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Install All
|
||||
{t.settings.aiFeatures.installAll}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -130,7 +131,7 @@ export function AiFeaturesSection() {
|
||||
|
||||
{diskUsage !== null && (
|
||||
<p className="text-xs text-muted-foreground pt-2 border-t border-border">
|
||||
Disk usage: {formatBytes(diskUsage)}
|
||||
{format(t.settings.aiFeatures.diskUsage, { size: formatBytes(diskUsage) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -163,6 +164,7 @@ function BundleCard({
|
||||
isQueued: boolean;
|
||||
startTime: number | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [messageIndex, setMessageIndex] = useState(() =>
|
||||
Math.floor(Math.random() * PROGRESS_MESSAGES.length),
|
||||
@@ -197,24 +199,30 @@ function BundleCard({
|
||||
{bundle.description} (~{bundle.estimatedSize})
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0 ml-4">
|
||||
<div className="flex items-center gap-3 shrink-0 ms-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{status === "installed" && (
|
||||
<>
|
||||
<span className="bg-green-500 rounded-full h-2 w-2" />
|
||||
<span className="text-xs text-muted-foreground">Installed</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t.settings.aiFeatures.installed}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{status === "not_installed" && !error && (
|
||||
<>
|
||||
<span className="bg-muted-foreground rounded-full h-2 w-2" />
|
||||
<span className="text-xs text-muted-foreground">Not installed</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t.settings.aiFeatures.notInstalled}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{status === "queued" && (
|
||||
<>
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Queued</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t.settings.aiFeatures.queued}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{status === "installing" && progress && (
|
||||
@@ -240,7 +248,7 @@ function BundleCard({
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Install
|
||||
{t.settings.aiFeatures.install}
|
||||
</button>
|
||||
)}
|
||||
{status === "installed" && !confirming && (
|
||||
@@ -251,7 +259,7 @@ function BundleCard({
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
Repair
|
||||
{t.settings.aiFeatures.repair}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -259,7 +267,7 @@ function BundleCard({
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-sm text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Uninstall
|
||||
{t.settings.aiFeatures.uninstall}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -274,14 +282,14 @@ function BundleCard({
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Confirm
|
||||
{t.common.confirm}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(false)}
|
||||
className="px-3 py-1.5 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{t.common.cancel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -292,7 +300,7 @@ function BundleCard({
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium opacity-50"
|
||||
>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Installing...
|
||||
{t.settings.aiFeatures.installing}
|
||||
</button>
|
||||
)}
|
||||
{(status === "error" || error) && !isInstalling && !isQueued && (
|
||||
@@ -302,7 +310,7 @@ function BundleCard({
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Retry
|
||||
{t.common.retry}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -319,7 +327,7 @@ function BundleCard({
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{PROGRESS_MESSAGES[messageIndex]}
|
||||
</p>
|
||||
{eta && <p className="text-xs text-muted-foreground shrink-0 ml-2">{eta}</p>}
|
||||
{eta && <p className="text-xs text-muted-foreground shrink-0 ms-2">{eta}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user