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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user