feat: add Russian localization and independent STT languages
This commit is contained in:
+46
-41
@@ -1,4 +1,7 @@
|
||||
"use client";
|
||||
import { translator, type Language } from "@/src/core";
|
||||
import { useI18n } from "@/components/i18n";
|
||||
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
@@ -66,6 +69,7 @@ declare global {
|
||||
/** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */
|
||||
onZoom?: (cb: (factor: number) => void) => () => void;
|
||||
getZoom?: () => Promise<number>;
|
||||
setLanguage?: (language: "en" | "ru") => void;
|
||||
/** Auto-update channel (Wave C, 2026-10-10): "lh:update-status" pushes once a staged update exists. */
|
||||
onUpdateStatus?: (cb: (status: { status: string; version: string | null }) => void) => () => void;
|
||||
installUpdate?: () => void;
|
||||
@@ -188,6 +192,7 @@ export function SidebarProvider({ children }: { children: React.ReactNode }) {
|
||||
* The icon fades with the slot and is clipped by it as it collapses —
|
||||
* no popping in either direction. */
|
||||
export function SidebarToggle() {
|
||||
const { t } = useI18n();
|
||||
const { open, toggle } = useSidebar();
|
||||
return (
|
||||
<span
|
||||
@@ -196,11 +201,11 @@ export function SidebarToggle() {
|
||||
open ? "w-0 -mr-3" : "w-9 mr-0 lh-closed-toggle",
|
||||
)}
|
||||
>
|
||||
<Hint label="Open sidebar" action="toggle-sidebar">
|
||||
<Hint label={t("Open sidebar")} action="toggle-sidebar">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Open sidebar"
|
||||
aria-label={t("Open sidebar")}
|
||||
aria-hidden={open}
|
||||
tabIndex={open ? -1 : 0}
|
||||
onClick={toggle}
|
||||
@@ -239,16 +244,18 @@ function SessionStatus({ busy }: { busy: boolean }) {
|
||||
* in any locale and it sorts (owner, typography round 5; the old
|
||||
* M/D/YYYY was the US convention. A user-locale setting can come later
|
||||
* — trivial, a format function swap). Pure — exported for tests. */
|
||||
export function timeAgo(iso: string, now = new Date()): string {
|
||||
export function timeAgo(iso: string, now = new Date(), language: Language = "en"): string {
|
||||
const t = translator(language);
|
||||
const ms = now.getTime() - new Date(iso).getTime();
|
||||
if (ms < 60_000) return "just now";
|
||||
if (ms < 60_000) return t("just now");
|
||||
const min = Math.floor(ms / 60_000);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
if (min < 60) return t("{v0}m ago", { v0: min });
|
||||
const h = Math.floor(min / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
if (h < 24) return t("{v0}h ago", { v0: h });
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 7) return `${d}d ago`;
|
||||
if (d < 7) return t("{v0}d ago", { v0: d });
|
||||
const date = new Date(iso);
|
||||
if (language === "ru") return new Intl.DateTimeFormat("ru-RU").format(date);
|
||||
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getDate()).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${mm}-${dd}`;
|
||||
@@ -259,14 +266,15 @@ export function timeAgo(iso: string, now = new Date()): string {
|
||||
* typography round 5): it informed no decision — clutter. messageCount
|
||||
* still rides the API payload, so this is a one-line flip back if it's
|
||||
* ever wanted. */
|
||||
function sessionMeta(chat: ChatListItem): string {
|
||||
if (chat.compacting) return `Compacting context · ${timeAgo(chat.updatedAt)}`;
|
||||
if (chat.pendingQuestion) return `Waiting for input · ${timeAgo(chat.updatedAt)}`;
|
||||
function sessionMeta(chat: ChatListItem, language: Language = "en"): string {
|
||||
const t = translator(language);
|
||||
if (chat.compacting) return t("Compacting context · {v0}", { v0: timeAgo(chat.updatedAt, undefined, language) });
|
||||
if (chat.pendingQuestion) return t("Waiting for input · {v0}", { v0: timeAgo(chat.updatedAt, undefined, language) });
|
||||
if ((chat.kind ?? "teach") === "practice" && chat.pendingProblem) {
|
||||
return `Problem waiting · ${timeAgo(chat.updatedAt)}`;
|
||||
return t("Problem waiting · {v0}", { v0: timeAgo(chat.updatedAt, undefined, language) });
|
||||
}
|
||||
if (chat.busy) return "Working…";
|
||||
return timeAgo(chat.updatedAt);
|
||||
if (chat.busy) return t("Working…");
|
||||
return timeAgo(chat.updatedAt, undefined, language);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -290,6 +298,7 @@ export function SessionRow({
|
||||
/** Right-click opens the context menu (wave 6: Rename / Delete). */
|
||||
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}) {
|
||||
const { t, language } = useI18n();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -310,13 +319,13 @@ export function SessionRow({
|
||||
rule, finally applied to the picker too (owner, typography
|
||||
round): mono = chrome, Source Sans = human words. */}
|
||||
<span className="block truncate font-prose text-(length:--lh-prose-size)">{chat.title}</span>
|
||||
<span className="block text-xs text-muted-foreground">{sessionMeta(chat)}</span>
|
||||
<span className="block text-xs text-muted-foreground">{sessionMeta(chat, language)}</span>
|
||||
</span>
|
||||
{/* The dumbbell rides the FAR RIGHT (owner round 6, 2026-10-05) —
|
||||
was left of the name; right-aligned keeps the titles flush with
|
||||
the teaching rows and reads cleaner. */}
|
||||
{(chat.kind ?? "teach") === "practice" && (
|
||||
<DumbbellIcon className="size-3.5 shrink-0 text-muted-foreground/70" aria-label="Practice session" />
|
||||
<DumbbellIcon className="size-3.5 shrink-0 text-muted-foreground/70" aria-label={t("Practice session")} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
@@ -327,6 +336,7 @@ export function SessionRow({
|
||||
* "replace", the header rename's rule), Enter/blur commits, Esc discards.
|
||||
* The meta line stays so the row never changes height mid-edit. */
|
||||
export function RenameRow({ chat, onCommit, onCancel }: { chat: ChatListItem; onCommit: (next: string) => void; onCancel: () => void }) {
|
||||
const { t, language } = useI18n();
|
||||
const [draft, setDraft] = useState(chat.title);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -362,11 +372,11 @@ export function RenameRow({ chat, onCommit, onCancel }: { chat: ChatListItem; on
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
aria-label="Rename session"
|
||||
aria-label={t("Rename session")}
|
||||
data-testid="session-rename-input"
|
||||
className="lh-input w-full min-w-0 truncate border-0 border-b border-border bg-transparent px-0 text-foreground outline-none"
|
||||
/>
|
||||
<span className="block truncate text-xs text-muted-foreground">{sessionMeta(chat)}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">{sessionMeta(chat, language)}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -379,6 +389,7 @@ export type SessionKindFilter = "teach" | "all" | "practice";
|
||||
const KIND_FILTER_KEY = "lh:session-kind-filter";
|
||||
|
||||
function SessionsSidebar() {
|
||||
const { t } = useI18n();
|
||||
const { open, toggle } = useSidebar();
|
||||
const [chats, setChats] = useState<ChatListItem[] | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -656,23 +667,23 @@ function SessionsSidebar() {
|
||||
// of the logo, carrying its real shortcut: ⌘G jumps to
|
||||
// the graph view (keyboard-shortcuts routes it to "/",
|
||||
// the same destination as this logo).
|
||||
label="Home"
|
||||
label={t("Home")}
|
||||
action="jump-graph"
|
||||
side="right"
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Home"
|
||||
aria-label={t("Home")}
|
||||
className="lh-no-drag flex size-9 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50"
|
||||
>
|
||||
<LhMark className="size-6" />
|
||||
</Link>
|
||||
</Hint>
|
||||
<Hint label="Close sidebar" action="toggle-sidebar">
|
||||
<Hint label={t("Close sidebar")} action="toggle-sidebar">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Close sidebar"
|
||||
aria-label={t("Close sidebar")}
|
||||
onClick={toggle}
|
||||
className="lh-no-drag"
|
||||
>
|
||||
@@ -693,8 +704,7 @@ function SessionsSidebar() {
|
||||
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<PlusIcon className="size-3" />
|
||||
Teaching
|
||||
<span className="sr-only">create a new teaching session</span>
|
||||
{t("Teaching")}<span className="sr-only">{t("create a new teaching session")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -703,14 +713,13 @@ function SessionsSidebar() {
|
||||
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<DumbbellIcon className="size-3" />
|
||||
Practice
|
||||
<span className="sr-only">create a new practice session</span>
|
||||
{t("Practice")}<span className="sr-only">{t("create a new practice session")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mx-3 mb-2 flex items-center gap-2">
|
||||
<span className="text-xs uppercase tracking-widest text-muted-foreground/70">Sessions</span>
|
||||
<Hint label="Search sessions" action="sessions-search" side="bottom">
|
||||
<span className="text-xs uppercase tracking-widest text-muted-foreground/70">{t("Sessions")}</span>
|
||||
<Hint label={t("Search sessions")} action="sessions-search" side="bottom">
|
||||
<span className="relative flex-1">
|
||||
<Input
|
||||
ref={searchRef}
|
||||
@@ -760,8 +769,8 @@ function SessionsSidebar() {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
placeholder="Search…"
|
||||
aria-label="Search sessions"
|
||||
placeholder={t("Search…")}
|
||||
aria-label={t("Search sessions")}
|
||||
className="h-6 rounded-sm border-border bg-background pl-6 text-xs placeholder:text-muted-foreground/60"
|
||||
/>
|
||||
<SearchIcon className="pointer-events-none absolute left-1.5 top-1/2 size-3 -translate-y-1/2 text-muted-foreground/70" />
|
||||
@@ -790,17 +799,17 @@ function SessionsSidebar() {
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{f === "teach" ? "Teaching" : f === "all" ? "All" : "Practice"}
|
||||
{f === "teach" ? t("Teaching") : f === "all" ? t("All") : t("Practice")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="flex-1 overflow-y-auto p-2">
|
||||
{shown === null ? (
|
||||
<div className="px-2 py-4 text-xs text-muted-foreground">Loading…</div>
|
||||
<div className="px-2 py-4 text-xs text-muted-foreground">{t("Loading…")}</div>
|
||||
) : shown.length === 0 ? (
|
||||
<div className="px-2 py-4 text-xs text-muted-foreground">
|
||||
{query.trim() ? "No sessions match." : "No sessions yet."}
|
||||
{query.trim() ? t("No sessions match.") : t("No sessions yet.")}
|
||||
</div>
|
||||
) : (
|
||||
shown.map((c, i) =>
|
||||
@@ -839,7 +848,7 @@ function SessionsSidebar() {
|
||||
New session — quiet, bottom-pinned, opens the centered
|
||||
dialog (also mod+, in the keyboard registry). */}
|
||||
<div className="px-3 pb-3 pt-1">
|
||||
<Hint label="Settings" action="open-settings" side="right">
|
||||
<Hint label={t("Settings")} action="open-settings" side="right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openSettings()}
|
||||
@@ -847,8 +856,7 @@ function SessionsSidebar() {
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<SettingsIcon className="size-3" />
|
||||
Settings
|
||||
</button>
|
||||
{t("Settings")}</button>
|
||||
</Hint>
|
||||
</div>
|
||||
</div>
|
||||
@@ -879,8 +887,7 @@ function SessionsSidebar() {
|
||||
}}
|
||||
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
{t("Rename")}</button>
|
||||
{/* Export as Markdown (owner, release polish 2026-10-06): the
|
||||
session-markdown engine's user-facing debut — same-origin
|
||||
anchor download; the route's Content-Disposition names the
|
||||
@@ -901,8 +908,7 @@ function SessionsSidebar() {
|
||||
}}
|
||||
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
Export as Markdown…
|
||||
</button>
|
||||
{t("Export as Markdown…")}</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="ctx-delete"
|
||||
@@ -913,8 +919,7 @@ function SessionsSidebar() {
|
||||
}}
|
||||
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-muted-foreground/80 transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Delete…
|
||||
</button>
|
||||
{t("Delete…")}</button>
|
||||
<div className="my-0.5 border-t border-border/60" aria-hidden="true" />
|
||||
<CoverageMenuBlock chatId={ctxChatId} />
|
||||
</MenuShell>
|
||||
|
||||
Reference in New Issue
Block a user