Files
office_translator/frontend/src/lib/i18n.tsx
sepehr fa637abff0
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m49s
perf+security: fix build, secure downloads, dedupe translations, refactor i18n
Frontend:
- Fix Framer Motion / motion-dom build error by pinning framer-motion to
  11.18.2 (compatible with React 19 and Next.js 16).
- Add cross-env and build:local script to bypass standalone symlink errors
  on Windows without Developer Mode.
- Allow NEXT_OUTPUT=default to disable standalone output for local builds.
- Refactor i18n: split 14,177-line src/lib/i18n.tsx into per-locale,
  per-namespace JSON files under src/lib/i18n/messages/.
- Load English synchronously; other locales loaded on demand via dynamic
  imports (reduces initial bundle, improves maintainability).
- Remove unused next-intl message files src/messages/en.json and fr.json.

Backend:
- Remove insecure legacy /api/v1/download/{filename} and /api/v1/cleanup/{filename}
  endpoints. The job-based /api/v1/download/{job_id} already enforces ownership.
- Deduplicate texts in TranslationService.translate_batch before sending them
  to the provider, reducing API calls for repeated strings.
- Pin httpx to <0.28 to fix TestClient incompatibility with starlette 0.35.1.
- Add pytest-cov and ruff dev dependencies/config.

DevOps:
- Remove hardcoded Grafana password from docker-compose.yml and
  docker-compose.monitoring.yml; use GRAFANA_PASSWORD env var.
- Change default TRANSLATION_SERVICE from ollama to google in
  docker-compose.yml (Ollama is an optional profile).
- Add GRAFANA_PASSWORD to .env.example.
- Add .coverage and frontend/pnpm-workspace.yaml to .gitignore.

Tests:
- Update API versioning tests for removed legacy endpoints.
- Add tests/test_translation_service.py for deduplication behavior.

Verified:
- pnpm run build:local passes.
- uv run pytest tests/test_providers/* tests/test_translation_service.py
  tests/test_story_3_5_api_versioning.py tests/test_download_endpoint.py
  tests/test_translators/test_excel_translator.py: provider/translator tests
  pass; one pre-existing French error-message test still fails (message is
  returned in English, unrelated to this change).
2026-06-14 16:44:18 +02:00

189 lines
4.4 KiB
TypeScript

"use client";
import {
createContext,
useContext,
useState,
useCallback,
useEffect,
useMemo,
type ReactNode,
} from "react";
import enMessages from "./i18n/messages/en";
export type Locale =
| "en"
| "fr"
| "es"
| "de"
| "pt"
| "it"
| "nl"
| "ru"
| "ja"
| "ko"
| "zh"
| "ar"
| "fa";
const VALID_LOCALES: ReadonlyArray<Locale> = [
"en",
"fr",
"es",
"de",
"pt",
"it",
"nl",
"ru",
"ja",
"ko",
"zh",
"ar",
"fa",
];
const DEFAULT_LOCALE: Locale = "en";
const RTL_LOCALES: ReadonlyArray<Locale> = ["ar", "fa"];
type TranslationParams = Record<string, string | number>;
interface I18nContextValue {
locale: Locale;
dir: "ltr" | "rtl";
isRTL: boolean;
setLocale: (locale: Locale) => void;
t: (key: string, params?: TranslationParams) => string;
isLoading: boolean;
}
const I18nContext = createContext<I18nContextValue | null>(null);
const messageCache: Partial<Record<Locale, Record<string, string>>> = {
en: enMessages,
};
function interpolate(template: string, params?: TranslationParams): string {
if (!params) return template;
return Object.entries(params).reduce(
(acc, [k, v]) => acc.replace(new RegExp(`\\{${k}\\}`, "g"), String(v)),
template,
);
}
function detectInitialLocale(): Locale {
if (typeof window === "undefined") return DEFAULT_LOCALE;
const saved = localStorage.getItem("locale");
if (saved && (VALID_LOCALES as readonly string[]).includes(saved)) {
return saved as Locale;
}
const primary = navigator.language.split("-")[0];
if ((VALID_LOCALES as readonly string[]).includes(primary)) {
return primary as Locale;
}
const allLangs = navigator.languages || [];
for (const lang of allLangs) {
const code = lang.split("-")[0];
if ((VALID_LOCALES as readonly string[]).includes(code)) {
return code as Locale;
}
}
return DEFAULT_LOCALE;
}
async function loadLocaleMessages(locale: Locale): Promise<Record<string, string>> {
if (messageCache[locale]) return messageCache[locale];
try {
const mod = await import(`./i18n/messages/${locale}`);
messageCache[locale] = mod.default as Record<string, string>;
return messageCache[locale];
} catch (err) {
console.warn(`Failed to load locale messages for ${locale}`, err);
messageCache[locale] = enMessages;
return enMessages;
}
}
export function formatDate(
date: Date,
locale: Locale,
options?: Intl.DateTimeFormatOptions,
): string {
const calendar =
locale === "fa" ? "fa-IR-u-ca-persian" : locale === "ar" ? "ar-SA" : locale;
const defaults: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "long",
year: "numeric",
};
return date.toLocaleDateString(calendar, { ...defaults, ...options });
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(detectInitialLocale);
const [messages, setMessages] = useState<Record<string, string>>(enMessages);
const [isLoading, setIsLoading] = useState(false);
const isRTL = (RTL_LOCALES as readonly string[]).includes(locale);
const dir = isRTL ? ("rtl" as const) : ("ltr" as const);
useEffect(() => {
document.documentElement.dir = dir;
document.documentElement.lang = locale;
}, [locale, dir]);
useEffect(() => {
let cancelled = false;
if (locale === "en") {
setMessages(enMessages);
return;
}
setIsLoading(true);
loadLocaleMessages(locale).then((loaded) => {
if (!cancelled) {
setMessages(loaded);
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [locale]);
const setLocale = useCallback((newLocale: Locale) => {
setLocaleState(newLocale);
localStorage.setItem("locale", newLocale);
}, []);
const t = useCallback(
(key: string, params?: TranslationParams): string => {
const msg = messages[key] ?? enMessages[key] ?? key;
return interpolate(msg, params);
},
[messages],
);
const value = useMemo(
() => ({ locale, dir, isRTL, setLocale, t, isLoading }),
[locale, dir, isRTL, setLocale, t, isLoading],
);
return (
<I18nContext.Provider value={value}>{children}</I18nContext.Provider>
);
}
export function useI18n() {
const ctx = useContext(I18nContext);
if (!ctx) {
throw new Error("useI18n must be used within an I18nProvider");
}
return ctx;
}
export function useTranslation() {
const { t } = useI18n();
return { t };
}