mirror of
https://github.com/temetro/temetro.git
synced 2026-08-17 22:07:48 +00:00
d237504af9
Add three UI locales (so/ar/de) with full ~1,660-key translations alongside en/fr, selectable in Settings → Profile. Arabic gets full right-to-left support: - config.ts registers the locales and exports a `dirFor` helper; an inline <head> script in layout.tsx sets <html dir/lang> before first paint (no RTL flash), and i18n-provider keeps them in sync on language change. - ~160 physical direction utilities converted to logical (ms/me/ps/pe/ start/end/text-start/text-end); directional chevrons/arrows get rtl:rotate-180; chat-bubble align variants fixed to logical. - IBM Plex Sans Arabic appended to the sans/heading font stacks for per-character Arabic fallback. - Language persists to the backend user_settings and re-applies on sign-in so it roams across devices (localStorage stays the offline source of truth). - New scripts/check-locales.mjs (npm run check-locales) enforces key/placeholder parity and Arabic CLDR plural completeness. Bump to 0.3.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
156 lines
5.4 KiB
TypeScript
156 lines
5.4 KiB
TypeScript
"use client";
|
|
|
|
import { History, Plus, Search, SquarePen, Trash2 } from "lucide-react";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { type MouseEvent, useEffect, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Sheet,
|
|
SheetDescription,
|
|
SheetHeader,
|
|
SheetPanel,
|
|
SheetPopup,
|
|
SheetTitle,
|
|
} from "@/components/ui/sheet";
|
|
import {
|
|
deleteThread,
|
|
listThreads,
|
|
THREADS_CHANGED_EVENT,
|
|
type ThreadSummary,
|
|
} from "@/lib/ai-chat-history";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
// The pill (panel toggle + search) that sits top-left of the AI chat, next to
|
|
// the sidebar. Opens a sheet listing saved chats with a "Start new chat" button
|
|
// — so chat history is reachable from inside the chat, not just the sidebar.
|
|
export function ChatHistoryPanel() {
|
|
const { t } = useTranslation();
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const activeThread = searchParams.get("thread");
|
|
|
|
const [open, setOpen] = useState(false);
|
|
const [threads, setThreads] = useState<ThreadSummary[]>([]);
|
|
const [query, setQuery] = useState("");
|
|
|
|
useEffect(() => {
|
|
const refresh = () => {
|
|
listThreads()
|
|
.then(setThreads)
|
|
.catch(() => {
|
|
/* not signed in / no clinic — show nothing */
|
|
});
|
|
};
|
|
refresh();
|
|
window.addEventListener(THREADS_CHANGED_EVENT, refresh);
|
|
return () => window.removeEventListener(THREADS_CHANGED_EVENT, refresh);
|
|
}, []);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return threads;
|
|
return threads.filter((x) => x.title.toLowerCase().includes(q));
|
|
}, [threads, query]);
|
|
|
|
const go = (href: string) => {
|
|
setOpen(false);
|
|
router.push(href);
|
|
};
|
|
|
|
const remove = async (event: MouseEvent, id: string) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setThreads((prev) => prev.filter((x) => x.id !== id));
|
|
await deleteThread(id).catch(() => {
|
|
/* ignore */
|
|
});
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="flex items-center gap-0.5 rounded-full border bg-card/40 p-0.5">
|
|
<button
|
|
aria-label={t("chat.history.open")}
|
|
className="flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
onClick={() => setOpen(true)}
|
|
type="button"
|
|
>
|
|
<History className="size-4" />
|
|
</button>
|
|
<button
|
|
aria-label={t("chat.history.startNew")}
|
|
className="flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
onClick={() => router.push("/")}
|
|
type="button"
|
|
>
|
|
<SquarePen className="size-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<Sheet onOpenChange={setOpen} open={open}>
|
|
<SheetPopup side="left">
|
|
<SheetHeader>
|
|
<SheetTitle>{t("chat.history.title")}</SheetTitle>
|
|
<SheetDescription className="sr-only">
|
|
{t("chat.history.open")}
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
<SheetPanel className="flex min-h-0 flex-1 flex-col gap-3">
|
|
<Button className="w-full justify-start" onClick={() => go("/")}>
|
|
<Plus className="size-4" />
|
|
{t("chat.history.startNew")}
|
|
</Button>
|
|
<div className="relative">
|
|
<Search className="-translate-y-1/2 absolute top-1/2 start-2.5 size-4 text-muted-foreground" />
|
|
<Input
|
|
className="ps-8"
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder={t("chat.history.search")}
|
|
value={query}
|
|
/>
|
|
</div>
|
|
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto">
|
|
{filtered.length === 0 ? (
|
|
<p className="px-2 py-6 text-center text-muted-foreground text-sm">
|
|
{t("chat.history.empty")}
|
|
</p>
|
|
) : (
|
|
filtered.map((thread) => {
|
|
const active = activeThread === thread.id;
|
|
return (
|
|
<button
|
|
className={cn(
|
|
"group flex items-center gap-2 rounded-md px-2 py-2 text-start text-sm transition-colors hover:bg-accent",
|
|
active
|
|
? "bg-accent text-foreground"
|
|
: "text-muted-foreground",
|
|
)}
|
|
key={thread.id}
|
|
onClick={() => go(`/?thread=${thread.id}`)}
|
|
type="button"
|
|
>
|
|
<span className="min-w-0 flex-1 truncate">
|
|
{thread.title}
|
|
</span>
|
|
<span
|
|
aria-label={t("chat.history.delete")}
|
|
className="shrink-0 opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
|
|
onClick={(event) => remove(event, thread.id)}
|
|
>
|
|
<Trash2 className="size-3.5" />
|
|
</span>
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</SheetPanel>
|
|
</SheetPopup>
|
|
</Sheet>
|
|
</>
|
|
);
|
|
}
|