"use client"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { resolveLanguage, translator, type GeneralSettings, type Language, type Translator } from "@/src/core"; import { notifySettingsSaved, useSettingsSaved } from "./settings-signal"; type I18nValue = { language: Language; sttLanguage: Language; general: GeneralSettings; dictating: boolean; setDictating: (active: boolean) => void; t: Translator; saveGeneral: (patch: Partial) => Promise; saveLanguage: (field: "language" | "sttLanguage", value: Language | undefined) => Promise; }; const I18nContext = createContext({ language: "en", sttLanguage: "en", general: {}, dictating: false, setDictating: () => {}, t: translator("en"), saveGeneral: async () => {}, saveLanguage: async () => {} }); export function useI18n(): I18nValue { return useContext(I18nContext); } /** Initial settings and locale come from the server, so hydration preserves the first frame. */ export function I18nProvider({ children, initialGeneral = {}, system = "en" }: { children: ReactNode; initialGeneral?: GeneralSettings; system?: Language }) { const [general, setGeneral] = useState(initialGeneral); const [dictating, setDictating] = useState(false); const [detected, setDetected] = useState(system); const pending = useRef>(Promise.resolve()); const sequence = useRef(0); const latest = useRef(general); latest.current = general; const language = resolveLanguage(general.language, detected); const sttLanguage = resolveLanguage(general.sttLanguage, detected); const t = useMemo(() => translator(language), [language]); useEffect(() => { const detect = () => setDetected(resolveLanguage(undefined, navigator.languages)); detect(); window.addEventListener("languagechange", detect); return () => window.removeEventListener("languagechange", detect); }, []); useEffect(() => { document.documentElement.lang = language; document.title = "Learning Harness"; const description = document.querySelector('meta[name="description"]'); if (description) description.setAttribute("content", t("A teacher agent with a memory — sessions, quizzes, and a knowledge graph of what you know.")); window.lh?.setLanguage?.(language); }, [language, t]); useSettingsSaved((section) => { if (section !== "general") return; const request = ++sequence.current; void fetch("/api/settings").then((r) => { if (!r.ok) throw new Error("settings"); return r.json(); }) .then((body: { settings?: { general?: GeneralSettings } }) => { if (request === sequence.current) setGeneral(body.settings?.general ?? {}); }).catch(() => {}); }); // Serialize writes and re-read before replacing the section: simultaneous selectors cannot lose each other's changes. const saveGeneral = useCallback((patch: Partial) => { const write = pending.current.catch(() => {}).then(async () => { const current = await fetch("/api/settings"); if (!current.ok) throw new Error("Could not load language settings"); const body = await current.json() as { settings?: { general?: GeneralSettings } }; const next = { ...(body.settings?.general ?? latest.current) }; for (const field of ["language", "sttLanguage", "autoUpdate"] as const) { if (!Object.hasOwn(patch, field)) continue; if (patch[field] === undefined) delete next[field]; else Object.assign(next, { [field]: patch[field] }); } const res = await fetch("/api/settings/general", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(next) }); if (!res.ok) throw new Error("Could not save language settings"); sequence.current++; setGeneral(next); notifySettingsSaved("general"); }); pending.current = write; return write; }, []); const saveLanguage = useCallback((field: "language" | "sttLanguage", value: Language | undefined) => saveGeneral({ [field]: value }), [saveGeneral]); const value = useMemo(() => ({ language, sttLanguage, general, dictating, setDictating, t, saveGeneral, saveLanguage }), [language, sttLanguage, general, dictating, t, saveGeneral, saveLanguage]); return {children}; } export function LanguageSelect({ field, disabled = false, compact = false }: { field: "language" | "sttLanguage"; disabled?: boolean; compact?: boolean }) { const { general, t, saveLanguage, sttLanguage, dictating } = useI18n(); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const label = t(field === "language" ? "Interface language" : "Speech recognition language"); return {error && {t(error)}} ; }