feat: refine language controls and add per-tier ChatGPT Fast mode
This commit is contained in:
+61
-11
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useId, 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";
|
||||
import { MenuShell } from "./menu-shell";
|
||||
|
||||
type I18nValue = {
|
||||
language: Language;
|
||||
@@ -78,19 +79,68 @@ export function LanguageSelect({ field, disabled = false, compact = false }: { f
|
||||
const { general, t, saveLanguage, 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 === "sttLanguage" && dictating);
|
||||
const menuOpen = open && !locked;
|
||||
const selected = general[field] ?? "auto";
|
||||
const label = t(field === "language" ? "Interface language" : "Speech recognition language");
|
||||
const testid = `language-select-${field}${compact ? "-compact" : ""}`;
|
||||
const options = [
|
||||
{ value: "auto", label: compact ? `${t("Auto")} · ${sttLanguage === "ru" ? "RU" : "EN"}` : t("System default") },
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "ru", label: "Русский" },
|
||||
] 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: "auto" | Language) => {
|
||||
if (locked) return;
|
||||
setOpen(false);
|
||||
trigger.current?.focus();
|
||||
if (value === selected) return;
|
||||
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));
|
||||
};
|
||||
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));
|
||||
<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);
|
||||
}}>
|
||||
<option value="auto">{compact ? `${t("Auto")} · ${sttLanguage === "ru" ? "RU" : "EN"}` : t("System default")}</option>
|
||||
<option value="en">English</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
<span className="min-w-0 flex-1 truncate text-left">{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 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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user