"use client"; import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; import { DEFAULT_STT_MODEL, OPENROUTER_STT_MODEL, STT_MODELS, resolveLanguage, resolveSttLanguage, translator, type GeneralSettings, type Language, type SttLanguage, type SttModel, type Translator } from "@/src/core"; import { notifySettingsSaved, useSettingsSaved } from "./settings-signal"; import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT, MenuShell } from "./menu-shell"; import { CheckIcon } from "lucide-react"; import { DropdownMenu } from "radix-ui"; type LanguageField = "language" | "sttLanguage"; type I18nValue = { language: Language; sttLanguage: SttLanguage; general: GeneralSettings; dictating: boolean; setDictating: (active: boolean) => void; t: Translator; saveGeneral: (patch: Partial) => Promise; saveLanguage: (field: Field, value: GeneralSettings[Field]) => 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 = resolveSttLanguage(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", "sttModel", "sttPreferredLanguages", "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: Field, value: GeneralSettings[Field]) => 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(props: { field: LanguageField; disabled?: boolean; compact?: boolean }) { return ; } export function SttModelSelect() { return ; } function GeneralSelect({ field, disabled = false, compact = false }: { field: LanguageField | "sttModel"; disabled?: boolean; compact?: boolean }) { const { general, t, saveGeneral, sttLanguage, dictating } = useI18n(); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [open, setOpen] = useState(false); const trigger = useRef(null); const menuId = useId(); const locked = disabled || saving || (field !== "language" && dictating); const menuOpen = open && !locked; const compactStt = compact && field === "sttLanguage"; const saved = general[field] ?? (field === "sttModel" ? DEFAULT_STT_MODEL : "auto"); const selected = compactStt && saved === "auto" ? sttLanguage : saved; const label = t(field === "language" ? "Interface language" : field === "sttModel" ? "Speech recognition model" : "Speech recognition language"); const testid = field === "sttModel" ? "stt-model-select" : `language-select-${field}${compact ? "-compact" : ""}`; const options: readonly { value: string; label: string }[] = field === "sttModel" ? STT_MODELS.map((model) => ({ value: model.id, label: model.name })) : [ ...(!compactStt ? [{ value: "auto", label: t("System default") } as const] : []), { value: "en", label: "English" }, { value: "ru", label: "Русский" }, ...(field === "sttLanguage" ? [{ value: "multi", label: t("Multilingual") } as const] : []), ] as const; useEffect(() => { if (locked) setOpen(false); }, [locked]); useEffect(() => { if (!menuOpen) return; const outside = (event: PointerEvent) => { const target = event.target as Node; if (!trigger.current?.contains(target) && !document.getElementById(menuId)?.contains(target)) setOpen(false); }; const escape = (event: KeyboardEvent) => { if (event.key !== "Escape") return; event.preventDefault(); event.stopPropagation(); setOpen(false); trigger.current?.focus(); }; document.addEventListener("pointerdown", outside); // Claim Escape before the settings dialog or dictation's outer layer. document.addEventListener("keydown", escape, true); return () => { document.removeEventListener("pointerdown", outside); document.removeEventListener("keydown", escape, true); }; }, [menuOpen, menuId]); const pick = (value: string) => { if (locked) return; setOpen(false); trigger.current?.focus(); if (value === saved) return; setSaving(true); setError(null); const patch = field === "sttModel" ? { sttModel: value as SttModel } : { [field]: value === "auto" ? undefined : value }; void saveGeneral(patch).catch((e: unknown) => setError(e instanceof Error ? e.message : "Could not save language settings")).finally(() => setSaving(false)); }; return {menuOpen && } {error && {t(error)}} ; } /** Preferred languages guide multilingual recognition; they are saved independently of its mode. */ export function SttPreferredLanguages() { const { general, sttLanguage, dictating, saveGeneral, t } = useI18n(); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [open, setOpen] = useState(false); const busy = useRef(false); const preferred = general.sttPreferredLanguages ?? []; const unavailable = sttLanguage !== "multi" || general.sttModel === OPENROUTER_STT_MODEL || dictating; const menuOpen = open && !unavailable; const summary = (["en", "ru"] as const).filter((language) => preferred.includes(language)).map((language) => language === "en" ? "English" : "Русский").join(", ") || t("No preference"); useEffect(() => { if (unavailable) setOpen(false); }, [unavailable]); useEffect(() => { if (!menuOpen) return; window.dispatchEvent(new CustomEvent(MENU_OPEN_EVENT)); return () => { window.dispatchEvent(new CustomEvent(MENU_CLOSE_EVENT)); }; }, [menuOpen]); const toggle = (language: Language) => { if (unavailable || busy.current) return; const next = (["en", "ru"] as const).filter((code) => code === language ? !preferred.includes(code) : preferred.includes(code)); busy.current = true; setSaving(true); setError(null); void saveGeneral({ sttPreferredLanguages: next }) .catch(() => setError("Could not save preferred languages")) .finally(() => { busy.current = false; setSaving(false); }); }; return event.stopPropagation()} onEscapeKeyDown={(event) => event.stopPropagation()}> {(["en", "ru"] as const).map((language) => toggle(language)} onSelect={(event) => event.preventDefault()} className={`flex cursor-default select-none items-center gap-2 rounded-lg px-2 py-1.5 text-left text-card-foreground outline-none transition-colors data-highlighted:bg-muted ${preferred.includes(language) ? "bg-muted/60" : ""}`}> {language === "en" ? "English" : "Русский"} )} {error && {t(error)}} ; }