Spaces:
Sleeping
Sleeping
| "use client"; | |
| import { useMemo, useRef, useState } from "react"; | |
| import { | |
| AlertCircle, | |
| CheckCircle2, | |
| FileText, | |
| Loader2, | |
| Plus, | |
| Search, | |
| Trash2, | |
| UploadCloud, | |
| } from "lucide-react"; | |
| import { api } from "@/lib/api"; | |
| import { toast } from "@/lib/toast"; | |
| import type { DocumentMeta } from "@/lib/types"; | |
| function StatusDot({ status }: { status: DocumentMeta["status"] }) { | |
| if (status === "ready") | |
| return <CheckCircle2 size={12} className="text-emerald-400" />; | |
| if (status === "failed") | |
| return <AlertCircle size={12} className="text-rose-400" />; | |
| return <Loader2 size={12} className="animate-spin text-brand-300" />; | |
| } | |
| function timeAgo(iso: string): string { | |
| const d = new Date(iso).getTime(); | |
| if (isNaN(d)) return ""; | |
| const s = Math.floor((Date.now() - d) / 1000); | |
| if (s < 60) return "just now"; | |
| if (s < 3600) return `${Math.floor(s / 60)}m ago`; | |
| if (s < 86400) return `${Math.floor(s / 3600)}h ago`; | |
| return `${Math.floor(s / 86400)}d ago`; | |
| } | |
| export default function Sidebar({ | |
| docs, | |
| activeId, | |
| onSelect, | |
| onUploaded, | |
| onDeleted, | |
| }: { | |
| docs: DocumentMeta[]; | |
| activeId: string | null; | |
| onSelect: (id: string) => void; | |
| onUploaded: (meta: DocumentMeta) => void; | |
| onDeleted: (id: string) => void; | |
| }) { | |
| const inputRef = useRef<HTMLInputElement>(null); | |
| const [uploading, setUploading] = useState(false); | |
| const [drag, setDrag] = useState(false); | |
| const [q, setQ] = useState(""); | |
| const filtered = useMemo( | |
| () => | |
| docs.filter((d) => d.filename.toLowerCase().includes(q.toLowerCase())), | |
| [docs, q], | |
| ); | |
| const doUpload = async (file: File) => { | |
| setUploading(true); | |
| const t = toast.info("Uploading…", file.name); | |
| try { | |
| const meta = await api.upload(file); | |
| onUploaded(meta); | |
| toast.success("Uploaded", `${file.name} is being processed.`); | |
| } catch (e: any) { | |
| toast.error("Upload failed", e?.message ?? String(e)); | |
| } finally { | |
| setUploading(false); | |
| } | |
| }; | |
| return ( | |
| <aside className="flex h-full w-64 flex-col border-r border-white/[0.06] bg-surface-900/60"> | |
| {/* upload */} | |
| <div className="p-3"> | |
| <button | |
| onClick={() => inputRef.current?.click()} | |
| disabled={uploading} | |
| className="group relative flex w-full items-center justify-center gap-2 overflow-hidden rounded-xl bg-gradient-to-r from-brand-500 to-brand-600 px-3 py-2.5 text-sm font-semibold text-white shadow-glow transition hover:from-brand-400 hover:to-brand-500 disabled:opacity-60" | |
| > | |
| <span className="absolute inset-0 -translate-x-full bg-sheen transition-transform duration-700 group-hover:translate-x-full" /> | |
| {uploading ? ( | |
| <Loader2 size={15} className="animate-spin" /> | |
| ) : ( | |
| <Plus size={15} /> | |
| )} | |
| {uploading ? "Uploading…" : "New document"} | |
| </button> | |
| <input | |
| ref={inputRef} | |
| type="file" | |
| hidden | |
| accept=".pdf,.docx,.doc,.pptx,.xlsx,.html,.md,.txt,.png,.jpg,.jpeg,.tiff,.bmp" | |
| onChange={(e) => { | |
| const f = e.target.files?.[0]; | |
| if (f) doUpload(f); | |
| e.target.value = ""; | |
| }} | |
| /> | |
| </div> | |
| {/* dropzone */} | |
| <div | |
| onDragOver={(e) => { | |
| e.preventDefault(); | |
| setDrag(true); | |
| }} | |
| onDragLeave={() => setDrag(false)} | |
| onDrop={(e) => { | |
| e.preventDefault(); | |
| setDrag(false); | |
| const f = e.dataTransfer.files?.[0]; | |
| if (f) doUpload(f); | |
| }} | |
| onClick={() => inputRef.current?.click()} | |
| className={`mx-3 mb-3 flex cursor-pointer flex-col items-center gap-1.5 rounded-xl border border-dashed px-3 py-5 text-center transition ${ | |
| drag | |
| ? "border-brand-400 bg-brand-500/10" | |
| : "border-white/10 hover:border-white/20" | |
| }`} | |
| > | |
| <UploadCloud | |
| size={20} | |
| className={drag ? "text-brand-300" : "text-white/30"} | |
| /> | |
| <p className="text-[11px] leading-tight text-white/40"> | |
| Drag & drop or <span className="text-brand-300">browse</span> | |
| </p> | |
| <p className="text-[10px] text-white/25">PDF · DOCX · XLSX · images</p> | |
| </div> | |
| {/* search */} | |
| {docs.length > 4 && ( | |
| <div className="mx-3 mb-2"> | |
| <div className="flex items-center gap-2 rounded-lg border border-white/[0.06] bg-surface-850 px-2.5 py-1.5"> | |
| <Search size={13} className="text-white/30" /> | |
| <input | |
| value={q} | |
| onChange={(e) => setQ(e.target.value)} | |
| placeholder="Search documents" | |
| className="w-full bg-transparent text-xs text-white placeholder:text-white/25 focus:outline-none" | |
| /> | |
| </div> | |
| </div> | |
| )} | |
| <div className="flex items-center justify-between px-4 pb-1.5 pt-1"> | |
| <span className="text-[10px] font-semibold uppercase tracking-wider text-white/30"> | |
| Library | |
| </span> | |
| <span className="rounded-full bg-white/[0.05] px-1.5 text-[10px] font-medium text-white/40"> | |
| {docs.length} | |
| </span> | |
| </div> | |
| <div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3"> | |
| {filtered.length === 0 && ( | |
| <p className="px-2 py-8 text-center text-xs text-white/25"> | |
| {docs.length === 0 ? "No documents yet." : "No matches."} | |
| </p> | |
| )} | |
| {filtered.map((d) => ( | |
| <div | |
| key={d.id} | |
| onClick={() => onSelect(d.id)} | |
| className={`group mb-1 flex cursor-pointer items-center gap-2.5 rounded-xl px-2.5 py-2 transition ${ | |
| activeId === d.id | |
| ? "bg-white/[0.06] shadow-[inset_0_0_0_1px_rgba(99,102,241,0.35)]" | |
| : "hover:bg-white/[0.03]" | |
| }`} | |
| > | |
| <div | |
| className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg ${ | |
| activeId === d.id | |
| ? "bg-brand-500/20 text-brand-300" | |
| : "bg-white/[0.04] text-white/35" | |
| }`} | |
| > | |
| <FileText size={14} /> | |
| </div> | |
| <div className="min-w-0 flex-1"> | |
| <p className="truncate text-xs font-medium text-white/90"> | |
| {d.filename} | |
| </p> | |
| <p className="flex items-center gap-1.5 text-[10px] text-white/35"> | |
| <StatusDot status={d.status} /> | |
| <span className="capitalize">{d.status}</span> | |
| <span className="text-white/15">·</span> | |
| <span>{timeAgo(d.created_at)}</span> | |
| </p> | |
| </div> | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| if (confirm(`Delete "${d.filename}"?`)) onDeleted(d.id); | |
| }} | |
| className="shrink-0 text-white/0 transition group-hover:text-white/30 hover:!text-rose-400" | |
| > | |
| <Trash2 size={13} /> | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| <div className="flex items-center gap-1.5 border-t border-white/[0.06] px-4 py-2.5 text-[10px] text-white/25"> | |
| <span className="h-1.5 w-1.5 rounded-full bg-emerald-400/70" /> | |
| Docling · Gemini · local-first | |
| </div> | |
| </aside> | |
| ); | |
| } | |