All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m49s
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).
189 lines
4.4 KiB
TypeScript
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 };
|
|
}
|