97 lines
5.6 KiB
TypeScript
97 lines
5.6 KiB
TypeScript
"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<GeneralSettings>) => Promise<void>;
|
|
saveLanguage: (field: "language" | "sttLanguage", value: Language | undefined) => Promise<void>;
|
|
};
|
|
const I18nContext = createContext<I18nValue>({ 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<Language>(system);
|
|
const pending = useRef<Promise<void>>(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<GeneralSettings>) => {
|
|
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 <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
|
}
|
|
|
|
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<string | null>(null);
|
|
const label = t(field === "language" ? "Interface language" : "Speech recognition language");
|
|
return <span className={compact ? "flex shrink-0 items-center gap-1" : "flex flex-col items-end gap-1"}>
|
|
<select aria-label={label} title={label} value={general[field] ?? "auto"} disabled={disabled || saving || (field === "sttLanguage" && dictating)}
|
|
className="h-7 max-w-full rounded-md border border-border bg-background px-1.5 text-xs text-foreground disabled:opacity-50"
|
|
onChange={(event) => {
|
|
const value = event.currentTarget.value;
|
|
setSaving(true); setError(null);
|
|
void saveLanguage(field, value === "auto" ? undefined : value as Language).catch((e: unknown) => setError(e instanceof Error ? e.message : "Could not save language settings")).finally(() => setSaving(false));
|
|
}}>
|
|
<option value="auto">{compact ? `${t("Auto")} · ${sttLanguage === "ru" ? "RU" : "EN"}` : t("System default")}</option>
|
|
<option value="en">English</option>
|
|
<option value="ru">Русский</option>
|
|
</select>
|
|
{error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>}
|
|
</span>;
|
|
}
|