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