2026-10-25 17:18:44 +03:00
"use client" ;
2026-10-25 21:19:48 +03:00
import { createContext , useCallback , useContext , useEffect , useId , useMemo , useRef , useState , type ReactNode } from "react" ;
2026-10-26 00:58:25 +03:00
import { resolveLanguage , resolveSttLanguage , translator , type GeneralSettings , type Language , type SttLanguage , type Translator } from "@/src/core" ;
2026-10-25 17:18:44 +03:00
import { notifySettingsSaved , useSettingsSaved } from "./settings-signal" ;
2026-10-25 21:19:48 +03:00
import { MenuShell } from "./menu-shell" ;
2026-10-26 02:14:27 +03:00
import { CheckIcon } from "lucide-react" ;
2026-10-25 17:18:44 +03:00
2026-10-26 00:58:25 +03:00
type LanguageField = "language" | "sttLanguage" ;
2026-10-25 17:18:44 +03:00
type I18nValue = {
language : Language ;
2026-10-26 00:58:25 +03:00
sttLanguage : SttLanguage ;
2026-10-25 17:18:44 +03:00
general : GeneralSettings ;
dictating : boolean ;
setDictating : ( active : boolean ) => void ;
t : Translator ;
saveGeneral : ( patch : Partial < GeneralSettings >) => Promise < void >;
2026-10-26 00:58:25 +03:00
saveLanguage : < Field extends LanguageField >( field : Field , value : GeneralSettings [ Field ]) => Promise < void >;
2026-10-25 17:18:44 +03:00
};
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 );
2026-10-26 00:58:25 +03:00
const sttLanguage = resolveSttLanguage ( general . sttLanguage , detected );
2026-10-25 17:18:44 +03:00
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 ) };
2026-10-26 02:14:27 +03:00
for ( const field of [ "language" , "sttLanguage" , "sttPreferredLanguages" , "autoUpdate" ] as const ) {
2026-10-25 17:18:44 +03:00
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 ;
}, []);
2026-10-26 00:58:25 +03:00
const saveLanguage = useCallback (< Field extends LanguageField ,>( field : Field , value : GeneralSettings [ Field ]) => saveGeneral ({ [ field ] : value }), [ saveGeneral ]);
2026-10-25 17:18:44 +03:00
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 );
2026-10-25 21:19:48 +03:00
const [ open , setOpen ] = useState ( false );
const trigger = useRef < HTMLButtonElement >( null );
const menuId = useId ();
const locked = disabled || saving || ( field === "sttLanguage" && dictating );
const menuOpen = open && ! locked ;
2026-10-26 00:58:25 +03:00
const compactStt = compact && field === "sttLanguage" ;
const saved = general [ field ] ?? "auto" ;
const selected = compactStt && saved === "auto" ? sttLanguage : saved ;
2026-10-25 17:18:44 +03:00
const label = t ( field === "language" ? "Interface language" : "Speech recognition language" );
2026-10-25 21:19:48 +03:00
const testid = `language-select- ${ field }${ compact ? "-compact" : "" } ` ;
const options = [
2026-10-26 00:58:25 +03:00
...( ! compactStt ? [{ value : "auto" , label : t ( "System default" ) } as const ] : []),
2026-10-25 21:19:48 +03:00
{ value : "en" , label : "English" },
{ value : "ru" , label : "Русский" },
2026-10-26 00:58:25 +03:00
...( field === "sttLanguage" ? [{ value : "multi" , label : t ( "Multilingual" ) } as const ] : []),
2026-10-25 21:19:48 +03:00
] 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 ]);
2026-10-26 00:58:25 +03:00
const pick = ( value : "auto" | SttLanguage ) => {
2026-10-25 21:19:48 +03:00
if ( locked ) return ;
setOpen ( false );
trigger . current ? . focus ();
2026-10-26 00:58:25 +03:00
if ( value === saved ) return ;
2026-10-25 21:19:48 +03:00
setSaving ( true ); setError ( null );
void saveLanguage ( field , value === "auto" ? undefined : value ). catch (( e : unknown ) => setError ( e instanceof Error ? e . message : "Could not save language settings" )). finally (() => setSaving ( false ));
};
2026-10-25 17:18:44 +03:00
return < span className = { compact ? "flex shrink-0 items-center gap-1" : "flex flex-col items-end gap-1" }>
2026-10-25 21:19:48 +03:00
< 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" : "w-36" } ` }
onClick = {() => setOpen ( ! menuOpen )}
onKeyDown = {( event ) => {
if ( event . key !== "ArrowDown" && event . key !== "ArrowUp" ) return ;
event . preventDefault (); event . stopPropagation (); setOpen ( true );
2026-10-25 17:18:44 +03:00
}}>
2026-10-26 00:58:25 +03:00
< span className = "min-w-0 flex-1 truncate text-left" >{ compact && selected === "multi" ? t ( "Multi" ) : options . find (( option ) => option . value === selected ) ? . label }</ span >
2026-10-25 21:19:48 +03:00
< span aria - hidden className = "text-muted-foreground" > ▾ </ span >
</ button >
{ menuOpen && < MenuShell anchorRef = { trigger } testid = { ` ${ testid } -menu` } preferredSide = { compact ? "up" : "down" } menuClass = "flex 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 >}
2026-10-25 17:18:44 +03:00
{ error && < span role = "alert" className = "max-w-56 text-xs text-destructive" >{ t ( error )}</ span >}
</ span >;
}
2026-10-26 02:14:27 +03:00
/** 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 preferred = general . sttPreferredLanguages ?? [];
const locked = sttLanguage !== "multi" || dictating || saving ;
const toggle = ( language : Language ) => {
if ( locked ) return ;
const next = ([ "en" , "ru" ] as const ). filter (( code ) => code === language ? ! preferred . includes ( code ) : preferred . includes ( code ));
setSaving ( true ); setError ( null );
void saveGeneral ({ sttPreferredLanguages : next })
. catch (() => setError ( "Could not save preferred languages" ))
. finally (() => setSaving ( false ));
};
return < span className = "flex max-w-full flex-col items-start gap-1" >
< span role = "group" aria-label = { t ( "Preferred languages" )} className = "flex max-w-full flex-wrap gap-2" >
{([ "en" , "ru" ] as const ). map (( language ) => < button key = { language } type = "button" role = "checkbox" aria-checked = { preferred . includes ( language )}
data-testid = { `stt-preferred- ${ language } ` } disabled = { locked } onClick = {() => toggle ( language )}
className = { `flex h-7 items-center gap-1.5 rounded-sm border border-border px-1.5 font-ui text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${ preferred . includes ( language ) ? "bg-muted text-foreground" : "bg-background text-muted-foreground hover:border-muted-foreground/50" } ` }>
< span aria - hidden className = "flex size-3.5 shrink-0 items-center justify-center rounded-xs border border-border" >{ preferred . includes ( language ) && < CheckIcon className = "size-3" />}</ span >
{ language === "en" ? "English" : "Русский" }
</ button >)}
</ span >
{ error && < span role = "alert" className = "max-w-56 text-xs text-destructive" >{ t ( error )}</ span >}
</ span >;
}