Files

213 lines
13 KiB
TypeScript

"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<GeneralSettings>) => Promise<void>;
saveLanguage: <Field extends LanguageField>(field: Field, value: GeneralSettings[Field]) => 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 = 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<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", "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 extends LanguageField,>(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 <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function LanguageSelect(props: { field: LanguageField; disabled?: boolean; compact?: boolean }) {
return <GeneralSelect {...props} />;
}
export function SttModelSelect() { return <GeneralSelect field="sttModel" />; }
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<string | null>(null);
const [open, setOpen] = useState(false);
const trigger = useRef<HTMLButtonElement>(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 <span className={compact ? "flex shrink-0 items-center gap-1" : "flex max-w-full shrink-0 flex-col items-end gap-1"}>
<button ref={trigger} type="button" aria-label={label} title={label} aria-haspopup="menu" aria-expanded={menuOpen} aria-controls={menuOpen ? menuId : undefined} disabled={locked} data-testid={testid}
className={`flex h-7 max-w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-50 ${compact ? "w-24" : field === "sttModel" ? "w-64" : "w-36"}`}
onClick={() => setOpen(!menuOpen)}
onKeyDown={(event) => {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault(); event.stopPropagation(); setOpen(true);
}}>
<span className="min-w-0 flex-1 truncate text-left">{compact && selected === "multi" ? t("Multi") : options.find((option) => option.value === selected)?.label}</span>
<span aria-hidden className="text-muted-foreground">▾</span>
</button>
{menuOpen && <MenuShell anchorRef={trigger} testid={`${testid}-menu`} preferredSide={compact ? "up" : "down"} menuClass={`flex ${field === "sttModel" ? "w-72" : "w-44"} flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl`}>
<div id={menuId} role="menu" aria-label={label} className="flex flex-col gap-0.5">
{options.map((option) => <button key={option.value} type="button" role="menuitemradio" aria-checked={selected === option.value} onClick={() => pick(option.value)}
className={`flex items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${selected === option.value ? "bg-muted/60 text-card-foreground" : "text-card-foreground"}`}>
<span>{option.label}</span>{selected === option.value && <span aria-hidden>✓</span>}
</button>)}
</div>
</MenuShell>}
{error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>}
</span>;
}
/** 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<string | null>(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 <span className="flex max-w-full shrink-0 flex-col items-end gap-1">
<DropdownMenu.Root open={menuOpen} onOpenChange={setOpen} modal={false}>
<DropdownMenu.Trigger asChild>
<button type="button" data-testid="stt-preferred-select" aria-label={t("Preferred languages")} title={summary} disabled={unavailable || saving}
className={`flex h-7 w-44 max-w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 ${unavailable ? "disabled:cursor-not-allowed disabled:opacity-50" : "disabled:cursor-default"}`}
onKeyDown={(event) => { if (event.key === "ArrowUp" && !unavailable && !saving) { event.preventDefault(); event.stopPropagation(); setOpen(true); } }}>
<span className="min-w-0 flex-1 truncate text-left">{summary}</span><span aria-hidden className="text-muted-foreground">▾</span>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content data-testid="stt-preferred-menu" data-lh-menu="" aria-label={t("Preferred languages")} side="bottom" align="end" sideOffset={4} collisionPadding={8} loop
className="z-60 flex w-44 max-h-[min(60vh,var(--radix-dropdown-menu-content-available-height))] flex-col gap-0.5 overflow-y-auto rounded-xl border border-border bg-card p-2 text-xs shadow-xl outline-none"
onKeyDown={(event) => event.stopPropagation()}
onEscapeKeyDown={(event) => event.stopPropagation()}>
{(["en", "ru"] as const).map((language) => <DropdownMenu.CheckboxItem key={language} data-testid={`stt-preferred-${language}`} checked={preferred.includes(language)} disabled={saving}
onCheckedChange={() => 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" : ""}`}>
<span aria-hidden className="flex size-3.5 shrink-0 items-center justify-center rounded-xs border border-border"><DropdownMenu.ItemIndicator><CheckIcon className="size-3" /></DropdownMenu.ItemIndicator></span>
{language === "en" ? "English" : "Русский"}
</DropdownMenu.CheckboxItem>)}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
{error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>}
</span>;
}