| "use client"; |
|
|
| import Link from "next/link"; |
| import { usePathname } from "next/navigation"; |
| import { useChatStore } from "@/lib/chatStore"; |
| import { bucketByDate, relativeTime, useTick, useIsMobile } from "@/lib/hooks"; |
| import clsx from "clsx"; |
| import { useEffect, useMemo, useRef, useState } from "react"; |
|
|
| export function Sidebar() { |
| const pathname = usePathname(); |
| const sidebarOpen = useChatStore((s) => s.sidebarOpen); |
| const list = useChatStore((s) => s.list()); |
| const activeId = useChatStore((s) => s.activeId); |
| const newConversation = useChatStore((s) => s.newConversation); |
| const openConversation = useChatStore((s) => s.openConversation); |
| const deleteConversation = useChatStore((s) => s.deleteConversation); |
| const renameConversation = useChatStore((s) => s.renameConversation); |
| const setSidebarOpen = useChatStore((s) => s.setSidebarOpen); |
|
|
| const isMobile = useIsMobile(); |
| useTick(30_000); |
| const [filter, setFilter] = useState(""); |
| const [editingId, setEditingId] = useState<string | null>(null); |
|
|
| |
| const groups = useMemo(() => { |
| const f = filter.trim().toLowerCase(); |
| const filtered = f |
| ? list.filter((c) => c.title.toLowerCase().includes(f)) |
| : list; |
| return bucketByDate(filtered); |
| }, [list, filter]); |
|
|
| |
| const didInitMobile = useRef(false); |
| useEffect(() => { |
| if (didInitMobile.current) return; |
| if (isMobile) setSidebarOpen(false); |
| didInitMobile.current = true; |
| }, [isMobile, setSidebarOpen]); |
|
|
| return ( |
| <> |
| {/* Mobile scrim */} |
| {sidebarOpen && ( |
| <button |
| aria-label="Close sidebar" |
| onClick={() => setSidebarOpen(false)} |
| className="md:hidden fixed inset-0 z-30 bg-canvas-deep/70 backdrop-blur-sm animate-fade-in" |
| /> |
| )} |
| |
| <aside |
| className={clsx( |
| "z-40 flex flex-col shrink-0", |
| // Mobile: slide-in drawer (fixed) |
| "fixed top-14 bottom-0 left-0 w-[280px]", |
| "transition-transform duration-300 ease-atelier", |
| sidebarOpen ? "translate-x-0" : "-translate-x-full", |
| // Desktop: in flow, fills row (which is locked to viewport - topbar) |
| "md:relative md:top-auto md:bottom-auto md:h-full md:self-stretch", |
| "md:translate-x-0 md:transition-[width,opacity] md:duration-300", |
| sidebarOpen |
| ? "md:w-[280px] md:opacity-100" |
| : "md:w-0 md:opacity-0 md:pointer-events-none" |
| )} |
| > |
| <div className="h-full overflow-hidden border-r border-glass-border bg-canvas-deep/70 backdrop-blur-glass flex flex-col"> |
| {/* New chat */} |
| <div className="p-4"> |
| <button |
| onClick={() => { |
| newConversation(); |
| if (pathname !== "/") { |
| window.history.pushState(null, "", "/"); |
| window.dispatchEvent(new PopStateEvent("popstate")); |
| } |
| if (isMobile) setSidebarOpen(false); |
| }} |
| className="group w-full flex items-center justify-between gap-2 px-4 py-3 rounded-md |
| bg-amber/10 text-amber |
| hover:bg-amber/15 transition-colors |
| text-body-strong" |
| style={{ boxShadow: "0 0 0 1px rgba(255,181,69,0.28)" }} |
| > |
| <span className="flex items-center gap-2.5"> |
| <svg width="14" height="14" viewBox="0 0 14 14" fill="none"> |
| <path |
| d="M7 2v10M2 7h10" |
| stroke="currentColor" |
| strokeWidth="1.6" |
| strokeLinecap="round" |
| /> |
| </svg> |
| <span>New conversation</span> |
| </span> |
| <kbd className="hidden md:inline text-micro text-amber/70 font-mono"> |
| ⌘N |
| </kbd> |
| </button> |
| </div> |
| |
| {/* Sections */} |
| <nav className="px-3 pb-3 flex flex-col gap-0.5"> |
| <NavLink |
| href="/" |
| active={pathname === "/"} |
| label="Chat" |
| icon={<IconSpark />} |
| /> |
| <NavLink |
| href="/documents" |
| active={pathname.startsWith("/documents")} |
| label="Documents" |
| icon={<IconDoc />} |
| /> |
| <NavLink |
| href="/system" |
| active={pathname.startsWith("/system")} |
| label="System" |
| icon={<IconPulse />} |
| /> |
| </nav> |
| |
| <div className="hairline mx-4 my-2" /> |
| |
| {/* Search + history */} |
| <div className="px-3 flex flex-col flex-1 min-h-0"> |
| <div className="relative px-1 py-1"> |
| <svg |
| width="13" |
| height="13" |
| viewBox="0 0 13 13" |
| fill="none" |
| className="absolute left-3 top-1/2 -translate-y-1/2 text-ink-50 pointer-events-none" |
| > |
| <circle cx="5.5" cy="5.5" r="3.5" stroke="currentColor" strokeWidth="1.4" /> |
| <path d="m8.5 8.5 3 3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" /> |
| </svg> |
| <input |
| type="text" |
| value={filter} |
| onChange={(e) => setFilter(e.target.value)} |
| placeholder="Search transcripts…" |
| className="w-full bg-glass text-ink text-caption rounded-md pl-8 pr-3 py-2 outline-none placeholder:text-ink-30 transition-shadow" |
| style={{ boxShadow: "0 0 0 1px rgba(255,255,255,0.06)" }} |
| /> |
| </div> |
| |
| <div className="flex-1 overflow-y-auto mt-1"> |
| {groups.length === 0 ? ( |
| <div className="px-3 py-4 text-caption text-ink-50"> |
| {filter ? "No matches." : "No conversations yet."} |
| </div> |
| ) : ( |
| groups.map((g) => ( |
| <section key={g.label} className="mb-2"> |
| <div className="px-3 py-1.5 text-micro uppercase tracking-[0.15em] text-ink-50 font-mono"> |
| {g.label} |
| </div> |
| <ul className="flex flex-col gap-0.5"> |
| {g.items.map((c) => { |
| const active = activeId === c.id && pathname === "/"; |
| const editing = editingId === c.id; |
| return ( |
| <li |
| key={c.id} |
| className="group relative" |
| > |
| {editing ? ( |
| <RenameInput |
| initial={c.title} |
| onCommit={(next) => { |
| if (next.trim()) renameConversation(c.id, next.trim()); |
| setEditingId(null); |
| }} |
| onCancel={() => setEditingId(null)} |
| /> |
| ) : ( |
| <button |
| onClick={() => { |
| openConversation(c.id); |
| if (pathname !== "/") { |
| window.history.pushState(null, "", "/"); |
| window.dispatchEvent(new PopStateEvent("popstate")); |
| } |
| if (isMobile) setSidebarOpen(false); |
| }} |
| onDoubleClick={() => setEditingId(c.id)} |
| className={clsx( |
| "w-full text-left px-3 py-2 rounded-md transition-colors", |
| "flex items-start gap-2 min-w-0", |
| active |
| ? "bg-glass-stronger text-ink" |
| : "text-ink-70 hover:text-ink hover:bg-glass" |
| )} |
| title="Double-click to rename" |
| > |
| <span className="text-caption truncate flex-1 min-w-0"> |
| {c.title} |
| </span> |
| <span className="text-micro text-ink-30 font-mono shrink-0 mt-0.5"> |
| {relativeTime(c.updatedAt)} |
| </span> |
| </button> |
| )} |
| {!editing && ( |
| <div className="absolute right-1.5 top-1/2 -translate-y-1/2 hidden group-hover:flex items-center gap-0.5"> |
| <IconBtn |
| label="Rename" |
| onClick={(e) => { |
| e.stopPropagation(); |
| setEditingId(c.id); |
| }} |
| > |
| <svg width="11" height="11" viewBox="0 0 12 12" fill="none"> |
| <path |
| d="M2 9.2 9.2 2l1 1L3 10.2H2v-1z" |
| stroke="currentColor" |
| strokeWidth="1.2" |
| strokeLinejoin="round" |
| /> |
| </svg> |
| </IconBtn> |
| <IconBtn |
| label="Delete" |
| danger |
| onClick={(e) => { |
| e.stopPropagation(); |
| if (window.confirm(`Delete "${c.title}"?`)) { |
| deleteConversation(c.id); |
| } |
| }} |
| > |
| <svg width="11" height="11" viewBox="0 0 12 12" fill="none"> |
| <path |
| d="M3 3l6 6M9 3l-6 6" |
| stroke="currentColor" |
| strokeWidth="1.4" |
| strokeLinecap="round" |
| /> |
| </svg> |
| </IconBtn> |
| </div> |
| )} |
| </li> |
| ); |
| })} |
| </ul> |
| </section> |
| )) |
| )} |
| </div> |
| </div> |
| |
| {/* Footer */} |
| <div className="px-4 py-3 border-t border-glass-border"> |
| <a |
| href="https://huggingface.co/spaces/Etiya/d2l-api" |
| target="_blank" |
| rel="noopener noreferrer" |
| className="flex items-center justify-between text-micro uppercase tracking-[0.12em] text-ink-50 hover:text-ink-70 transition-colors" |
| > |
| <span>HF Space · backend</span> |
| <svg width="10" height="10" viewBox="0 0 10 10" fill="none"> |
| <path |
| d="M3 3h4v4M7 3 3 7" |
| stroke="currentColor" |
| strokeWidth="1.3" |
| strokeLinecap="round" |
| /> |
| </svg> |
| </a> |
| </div> |
| </div> |
| </aside> |
| </> |
| ); |
| } |
|
|
| function NavLink({ |
| href, |
| active, |
| label, |
| icon, |
| }: { |
| href: string; |
| active: boolean; |
| label: string; |
| icon: React.ReactNode; |
| }) { |
| return ( |
| <Link |
| href={href} |
| className={clsx( |
| "flex items-center gap-3 px-3 py-2 rounded-md transition-all", |
| active |
| ? "bg-glass-stronger text-ink" |
| : "text-ink-70 hover:text-ink hover:bg-glass" |
| )} |
| > |
| <span className={clsx("w-4 h-4", active && "text-amber")}>{icon}</span> |
| <span className="text-body">{label}</span> |
| </Link> |
| ); |
| } |
|
|
| function RenameInput({ |
| initial, |
| onCommit, |
| onCancel, |
| }: { |
| initial: string; |
| onCommit: (next: string) => void; |
| onCancel: () => void; |
| }) { |
| const [v, setV] = useState(initial); |
| const ref = useRef<HTMLInputElement | null>(null); |
| useEffect(() => { |
| ref.current?.focus(); |
| ref.current?.select(); |
| }, []); |
| return ( |
| <input |
| ref={ref} |
| value={v} |
| onChange={(e) => setV(e.target.value)} |
| onBlur={() => onCommit(v)} |
| onKeyDown={(e) => { |
| if (e.key === "Enter") { |
| e.preventDefault(); |
| onCommit(v); |
| } else if (e.key === "Escape") { |
| e.preventDefault(); |
| onCancel(); |
| } |
| }} |
| className="w-full bg-glass-stronger text-ink text-caption rounded-md px-3 py-2 outline-none" |
| style={{ boxShadow: "0 0 0 1.5px rgba(255,181,69,0.65)" }} |
| /> |
| ); |
| } |
|
|
| function IconBtn({ |
| children, |
| onClick, |
| label, |
| danger, |
| }: { |
| children: React.ReactNode; |
| onClick: (e: React.MouseEvent) => void; |
| label: string; |
| danger?: boolean; |
| }) { |
| return ( |
| <button |
| onClick={onClick} |
| title={label} |
| aria-label={label} |
| className={clsx( |
| "p-1 rounded transition-colors", |
| danger |
| ? "text-ink-50 hover:text-status-err hover:bg-status-err-glow" |
| : "text-ink-50 hover:text-ink hover:bg-glass" |
| )} |
| > |
| {children} |
| </button> |
| ); |
| } |
|
|
| function IconSpark() { |
| return ( |
| <svg viewBox="0 0 16 16" fill="none" className="w-full h-full"> |
| <path |
| d="M8 1.5v3M8 11.5v3M1.5 8h3M11.5 8h3M3.5 3.5l2 2M10.5 10.5l2 2M3.5 12.5l2-2M10.5 5.5l2-2" |
| stroke="currentColor" |
| strokeWidth="1.4" |
| strokeLinecap="round" |
| /> |
| </svg> |
| ); |
| } |
|
|
| function IconDoc() { |
| return ( |
| <svg viewBox="0 0 16 16" fill="none" className="w-full h-full"> |
| <path |
| d="M3.5 1.5h6l3.5 3.5v9a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5v-12a.5.5 0 0 1 .5-.5z" |
| stroke="currentColor" |
| strokeWidth="1.4" |
| /> |
| <path d="M9.5 1.5V5h3.5" stroke="currentColor" strokeWidth="1.4" /> |
| </svg> |
| ); |
| } |
|
|
| function IconPulse() { |
| return ( |
| <svg viewBox="0 0 16 16" fill="none" className="w-full h-full"> |
| <path |
| d="M1.5 8h3l1.5-4 3 8L10.5 8h4" |
| stroke="currentColor" |
| strokeWidth="1.4" |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| /> |
| </svg> |
| ); |
| } |
|
|