feat: refine language controls and add per-tier ChatGPT Fast mode

This commit is contained in:
2026-10-25 21:19:48 +03:00
parent f8ce0f21ef
commit 8c564946ef
17 changed files with 488 additions and 37 deletions
+61 -11
View File
@@ -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>;
}