Files
chat/frontend/src/components/Sidebar.tsx
T
2026-09-21 15:08:59 +02:00

295 lines
11 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import {
LayoutDashboard,
MessageSquarePlus,
MoreVertical,
PanelLeftClose,
Pencil,
Search,
Settings,
Share2,
Tag,
Trash2,
X,
} from "lucide-react";
import type { Conversation, User } from "../lib/types.ts";
import { api } from "../lib/api.ts";
import { Avatar, Button, ContextMenu, FlowerMark, IconButton } from "./ui.tsx";
import { cn } from "../lib/cn.ts";
interface MenuState {
x: number;
y: number;
conv: Conversation;
}
export function Sidebar({
user,
convs,
activeId,
collapsed,
onToggle,
onNew,
onSelect,
onRename,
onTopic,
onShare,
onDelete,
onOpenSettings,
onOpenAdmin,
onLogout,
mobileOpen,
onCloseMobile,
}: {
user: User;
convs: Conversation[];
activeId: string | null;
collapsed: boolean;
onToggle: () => void;
onNew: () => void;
onSelect: (id: string) => void;
onRename: (conv: Conversation) => void;
onTopic: (conv: Conversation) => void;
onShare: (conv: Conversation) => void;
onDelete: (conv: Conversation) => void;
onOpenSettings: () => void;
onOpenAdmin: () => void;
onLogout: () => void;
mobileOpen: boolean;
onCloseMobile: () => void;
}) {
const [menu, setMenu] = useState<MenuState | null>(null);
const [query, setQuery] = useState("");
const [results, setResults] = useState<(Conversation & { snippet: string | null; snippetRole: string | null })[] | null>(null);
const [searching, setSearching] = useState(false);
const touchX = useRef<number | null>(null);
// Long-press (mobile right-click): open the context menu, suppress the tap.
const pressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const pressPos = useRef<{ x: number; y: number } | null>(null);
const suppressClick = useRef(false);
function openMenuAt(x: number, y: number, conv: Conversation) {
setMenu({ x, y, conv });
}
function openMenu(e: React.MouseEvent, conv: Conversation) {
e.preventDefault();
e.stopPropagation();
openMenuAt(e.clientX, e.clientY, conv);
}
function cancelPress() {
if (pressTimer.current) clearTimeout(pressTimer.current);
pressTimer.current = null;
pressPos.current = null;
}
// Debounced search over titles, topics and old prompts/replies.
useEffect(() => {
const q = query.trim();
if (q.length < 2) {
setResults(null);
setSearching(false);
return;
}
setSearching(true);
const t = setTimeout(() => {
api.searchConvs(q)
.then((res) => setResults(res.conversations))
.catch(() => setResults([]))
.finally(() => setSearching(false));
}, 250);
return () => clearTimeout(t);
}, [query]);
if (collapsed && !mobileOpen) return null;
return (
<>
{mobileOpen && (
<div className="fixed inset-0 z-30 bg-black/40 md:hidden" onClick={onCloseMobile} aria-hidden="true" />
)}
<aside
onTouchStart={(e) => { touchX.current = e.touches[0]!.clientX; }}
onTouchEnd={(e) => {
if (touchX.current === null) return;
const dx = e.changedTouches[0]!.clientX - touchX.current;
touchX.current = null;
if (dx < -60) onCloseMobile(); // swipe left closes the drawer
}}
className={cn(
"fixed inset-y-0 left-0 z-40 flex h-full w-72 shrink-0 flex-col gap-2 border-r border-stone-200 bg-white p-3 shadow-2xl transition-transform duration-200",
"md:static md:z-auto md:shadow-none dark:border-zinc-800 dark:bg-zinc-900 md:dark:bg-zinc-900/70 md:bg-white/70 md:backdrop-blur",
mobileOpen ? "translate-x-0" : "-translate-x-full",
"md:translate-x-0",
collapsed && "md:hidden",
)}
>
<div className="flex items-center gap-2 px-2 pb-1 pt-0.5">
<FlowerMark size={36} dynamic={false} />
<span className="font-serif text-xl font-bold tracking-tight">KisAssistant</span>
</div>
<div className="flex items-center gap-1">
<Button variant="secondary" size="sm" className="flex-1" onClick={onNew}>
<MessageSquarePlus size={16} />
New chat
</Button>
<IconButton title="Collapse sidebar" onClick={onToggle} className="hidden md:inline-flex">
<PanelLeftClose size={18} />
</IconButton>
<IconButton title="Close chats" onClick={onCloseMobile} className="md:hidden">
<X size={18} />
</IconButton>
</div>
<div className="relative">
<Search size={15} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 opacity-50" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search chats…"
aria-label="Search conversations"
className="w-full rounded-2xl border border-stone-200/70 bg-stone-100 py-2 pl-9 pr-8 text-sm outline-none transition placeholder:text-stone-400 focus:border-accent-500/60 focus:bg-white dark:border-zinc-800 dark:bg-zinc-800/60 dark:placeholder:text-zinc-500 dark:focus:bg-zinc-900"
/>
{query && (
<button
aria-label="Clear search"
onClick={() => setQuery("")}
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-full p-0.5 opacity-60 transition hover:opacity-100"
>
<X size={14} />
</button>
)}
</div>
<div className="flex-1 space-y-1 overflow-y-auto py-1">
{results !== null ? (
<>
{searching && <p className="px-3 py-4 text-center text-sm opacity-50">Searching…</p>}
{!searching && results.length === 0 && (
<p className="px-3 py-6 text-center text-sm opacity-50">No match for “{query.trim()}”.</p>
)}
{results.map((c) => (
<div
key={c.id}
role="button"
tabIndex={0}
onClick={() => { setQuery(""); onSelect(c.id); }}
onKeyDown={(e) => { if (e.key === "Enter") { setQuery(""); onSelect(c.id); } }}
className={cn(
"group cursor-pointer select-none rounded-2xl px-3 py-2.5 text-sm transition",
c.id === activeId
? "bg-accent-600/10 font-medium text-accent-900 dark:bg-accent-500/10 dark:text-accent-100"
: "hover:bg-stone-200/50 dark:hover:bg-zinc-800/70",
)}
>
<span className="block truncate">{c.title}</span>
{c.snippet && (
<span className="mt-0.5 block truncate text-xs opacity-60">
{c.snippetRole === "user" ? "you: " : ""}{c.snippet}
</span>
)}
</div>
))}
</>
) : (
<>
{convs.length === 0 && (
<p className="px-3 py-6 text-center text-sm opacity-50">No conversations yet.<br />Start a new chat above.</p>
)}
{convs.map((c) => (
<div
key={c.id}
role="button"
tabIndex={0}
onClick={() => {
if (suppressClick.current) {
suppressClick.current = false;
return;
}
onSelect(c.id);
}}
onKeyDown={(e) => e.key === "Enter" && onSelect(c.id)}
onContextMenu={(e) => openMenu(e, c)}
onTouchStart={(e) => {
const t = e.touches[0]!;
pressPos.current = { x: t.clientX, y: t.clientY };
pressTimer.current = setTimeout(() => {
suppressClick.current = true;
openMenuAt(t.clientX, t.clientY, c);
}, 550);
}}
onTouchMove={(e) => {
const p = pressPos.current;
const t = e.touches[0]!;
if (p && Math.hypot(t.clientX - p.x, t.clientY - p.y) > 10) cancelPress();
}}
onTouchEnd={cancelPress}
className={cn(
"group flex cursor-pointer select-none items-center gap-2 rounded-2xl px-3 py-2.5 text-sm transition [-webkit-touch-callout:none]",
c.id === activeId
? "bg-accent-600/10 font-medium text-accent-900 dark:bg-accent-500/10 dark:text-accent-100"
: "hover:bg-stone-200/50 dark:hover:bg-zinc-800/70",
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate">{c.title}</span>
{c.topic && (
<span className="mt-0.5 flex items-center gap-1 text-xs opacity-60">
<Tag size={11} />
<span className="truncate">{c.topic}</span>
</span>
)}
</span>
<button
aria-label="Conversation options"
onClick={(e) => openMenu(e, c)}
className="rounded-full p-1 opacity-0 transition group-hover:opacity-60 hover:!opacity-100 hover:bg-stone-300/50 dark:hover:bg-zinc-700"
>
<MoreVertical size={15} />
</button>
</div>
))}
</>
)}
</div>
{menu && (
<ContextMenu
x={menu.x}
y={menu.y}
onClose={() => setMenu(null)}
items={[
{ icon: Pencil, label: "Rename", onClick: () => onRename(menu.conv) },
{ icon: Tag, label: "Set topic", onClick: () => onTopic(menu.conv) },
{ icon: Share2, label: "Share publicly", onClick: () => onShare(menu.conv) },
{ icon: Trash2, label: "Delete", danger: true, onClick: () => onDelete(menu.conv) },
]}
/>
)}
<div className="space-y-2 rounded-2xl border border-stone-200/70 bg-white p-2.5 dark:border-zinc-800 dark:bg-zinc-900">
<button onClick={onOpenSettings} className="m-0.5 flex w-[calc(100%-4px)] items-center gap-2.5 rounded-xl p-1.5 text-left transition hover:bg-accent-600/10" title="Profile & settings">
<Avatar name={user.displayName} url={user.avatarUrl} size={34} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{user.displayName}</span>
<span className="block truncate text-xs opacity-60">@{user.username}{user.isAdmin ? " · admin" : ""}</span>
</span>
<Settings size={16} className="opacity-50" />
</button>
<div className="flex gap-1.5">
{user.isAdmin && (
<Button variant="secondary" size="sm" className="flex-1" onClick={onOpenAdmin} title="Open the Admin Center">
<LayoutDashboard size={15} />
Admin Center
</Button>
)}
<Button variant="ghost" size="sm" className={user.isAdmin ? "" : "flex-1"} onClick={onLogout}>
Log out
</Button>
</div>
</div>
</aside>
</>
);
}