Download src/components/workspace/SourcesPanel.tsx from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 16.7 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/workspace/SourcesPanel.tsx
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/components/workspace/SourcesPanel.tsx
-
curl -L -o SourcesPanel.tsx https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/workspace/SourcesPanel.tsx
16.7 kB
| "use client"; | |
| import { useCallback, useRef, useState } from "react"; | |
| import { | |
| CheckCircle2, | |
| Clock, | |
| File, | |
| FileText, | |
| Image as ImageIcon, | |
| Link, | |
| Loader2, | |
| Plus, | |
| Search, | |
| Upload, | |
| XCircle, | |
| } from "lucide-react"; | |
| import { cn } from "@/lib/utils"; | |
| import { uploadDocument } from "@/lib/api/documents"; | |
| import type { DocumentStatus } from "@/lib/api/types"; | |
| import { getStudentFacingSourceTitle } from "@/lib/ui/displayNames"; | |
| import { | |
| MATERIAL_UPLOAD_ACCEPT, | |
| validateStudentUpload, | |
| } from "@/lib/uploads/document-upload-contract"; | |
| import { materialTypeLabel, sourceReadinessLabel } from "./SourceTrust"; | |
| import type { MaterialType } from "./workspaceTypes"; | |
| export type SourceDoc = { | |
| id: string; | |
| title: string; | |
| status: DocumentStatus; | |
| subject: string; | |
| syllabus?: string | null; | |
| file_type: string; | |
| chunk_count?: number; | |
| material_type?: MaterialType | null; | |
| source_type?: string | null; | |
| }; | |
| type SourcesPanelProps = { | |
| className?: string; | |
| subject: string; | |
| documents: SourceDoc[]; | |
| activeDocumentId?: string | null; | |
| onDocumentSelect: (doc: SourceDoc | null) => void; | |
| onRefresh?: () => void; | |
| isLoading?: boolean; | |
| onUploadClick?: () => void; | |
| }; | |
| const VISIBLE_COUNT = 4; | |
| export function SourcesPanel({ | |
| className, | |
| subject, | |
| documents, | |
| activeDocumentId, | |
| onDocumentSelect, | |
| onRefresh, | |
| isLoading = false, | |
| onUploadClick, | |
| }: SourcesPanelProps) { | |
| const [search, setSearch] = useState(""); | |
| const [showAll, setShowAll] = useState(false); | |
| const [isUploading, setIsUploading] = useState(false); | |
| const [uploadError, setUploadError] = useState<string | null>(null); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const triggerUpload = useCallback(() => { | |
| setUploadError(null); | |
| if (onUploadClick) { | |
| onUploadClick(); | |
| return; | |
| } | |
| fileInputRef.current?.click(); | |
| }, [onUploadClick]); | |
| const handleFileChange = useCallback( | |
| async (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const file = e.target.files?.[0]; | |
| if (!file) return; | |
| e.target.value = ""; | |
| const validation = validateStudentUpload(file, "material"); | |
| if (!validation.ok) { | |
| setUploadError(validation.message); | |
| return; | |
| } | |
| setIsUploading(true); | |
| setUploadError(null); | |
| try { | |
| await uploadDocument({ | |
| file, | |
| title: file.name.replace(/\.[^/.]+$/, ""), | |
| subject, | |
| }); | |
| onRefresh?.(); | |
| } catch { | |
| setUploadError("Couldn't upload this file. Try another one."); | |
| } finally { | |
| setIsUploading(false); | |
| } | |
| }, | |
| [subject, onRefresh], | |
| ); | |
| const filtered = sortDocuments(filterBySearch(documents, search), activeDocumentId); | |
| const visible = showAll ? filtered : filtered.slice(0, VISIBLE_COUNT); | |
| const visibleGroups = groupByStatus(visible); | |
| const hiddenCount = Math.max(0, filtered.length - VISIBLE_COUNT); | |
| return ( | |
| <aside | |
| className={cn( | |
| "flex h-full min-w-0 flex-col gap-2 overflow-hidden", | |
| className, | |
| )} | |
| > | |
| {/* Panel card */} | |
| <div className="flex h-full min-w-0 flex-col overflow-hidden rounded-[20px] border border-white/[0.07] bg-[#0B0B0C]"> | |
| {/* Header */} | |
| <div className="shrink-0 border-b border-white/[0.06] px-4 py-3"> | |
| <div className="flex min-w-0 items-center justify-between gap-2"> | |
| <div className="min-w-0"> | |
| <h2 className="text-[13px] font-bold text-white">Sources</h2> | |
| <p className="text-[10px] text-white/35"> | |
| {documents.length > 0 | |
| ? `${documents.filter((d) => sourceReadinessLabel(d) === "Ready").length} of ${documents.length} ready` | |
| : "Study materials"} | |
| </p> | |
| </div> | |
| <button | |
| type="button" | |
| onClick={triggerUpload} | |
| disabled={isUploading} | |
| aria-label="Upload new source" | |
| className="flex h-7 shrink-0 items-center gap-1.5 rounded-xl bg-white px-2.5 text-[11px] font-bold text-black transition hover:bg-white/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30 disabled:cursor-not-allowed disabled:opacity-50" | |
| > | |
| {isUploading ? ( | |
| <Loader2 className="size-3 animate-spin" strokeWidth={2} /> | |
| ) : ( | |
| <Upload className="size-3" strokeWidth={2} /> | |
| )} | |
| Upload | |
| </button> | |
| </div> | |
| {/* Search */} | |
| <div className="mt-2.5 flex min-w-0 items-center gap-2 rounded-xl border border-white/[0.07] bg-white/[0.03] px-2.5 py-1.5 focus-within:border-white/15"> | |
| <Search className="size-3 shrink-0 text-white/30" strokeWidth={2} /> | |
| <input | |
| value={search} | |
| onChange={(e) => { | |
| setSearch(e.target.value); | |
| setShowAll(false); | |
| }} | |
| placeholder="Search sources..." | |
| aria-label="Search sources" | |
| className="min-w-0 flex-1 bg-transparent text-[11.5px] text-white outline-none placeholder:text-white/25" | |
| /> | |
| </div> | |
| </div> | |
| {/* Upload error */} | |
| {uploadError && ( | |
| <div className="mx-3 mt-2 shrink-0 rounded-xl border border-red-400/15 bg-red-500/[0.07] px-3 py-2 text-[11px] text-red-300/80"> | |
| {uploadError} | |
| </div> | |
| )} | |
| {/* Source list */} | |
| <div className="min-w-0 flex-1 overflow-y-auto px-3 py-3 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> | |
| {isLoading ? ( | |
| <LoadingSkeleton /> | |
| ) : filtered.length === 0 ? ( | |
| <EmptyState onUpload={triggerUpload} /> | |
| ) : ( | |
| <div className="min-w-0 space-y-3"> | |
| {visibleGroups.map((group) => ( | |
| <div key={group.label} className="min-w-0"> | |
| <div className="mb-1.5 flex items-center justify-between px-1"> | |
| <span className="text-[10px] font-bold uppercase tracking-[0.12em] text-white/38"> | |
| {group.label} | |
| </span> | |
| <span className="text-[10px] font-semibold text-white/28">{group.docs.length}</span> | |
| </div> | |
| <div className="space-y-1.5"> | |
| {group.docs.map((doc) => ( | |
| <SourceCard | |
| key={doc.id} | |
| doc={doc} | |
| isActive={doc.id === activeDocumentId} | |
| onSelect={() => | |
| onDocumentSelect(doc.id === activeDocumentId ? null : doc) | |
| } | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {/* View all / collapse */} | |
| {!showAll && hiddenCount > 0 && ( | |
| <button | |
| type="button" | |
| onClick={() => setShowAll(true)} | |
| aria-label={`View all ${filtered.length} sources`} | |
| className="mt-2 w-full rounded-xl py-2 text-[11px] font-semibold text-white/38 transition hover:bg-white/[0.03] hover:text-white/62 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white/20" | |
| > | |
| View all sources ({filtered.length}) | |
| </button> | |
| )} | |
| {showAll && documents.length > VISIBLE_COUNT && ( | |
| <button | |
| type="button" | |
| onClick={() => setShowAll(false)} | |
| aria-label="Show fewer sources" | |
| className="mt-2 w-full rounded-xl py-2 text-[11px] font-semibold text-white/30 transition hover:bg-white/[0.03] hover:text-white/50" | |
| > | |
| Show less | |
| </button> | |
| )} | |
| {/* Add new source */} | |
| <button | |
| type="button" | |
| onClick={triggerUpload} | |
| disabled={isUploading} | |
| aria-label="Add new source" | |
| className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-2xl border border-dashed border-white/[0.1] py-3 text-[11.5px] font-semibold text-white/35 transition hover:border-white/20 hover:text-white/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20 disabled:cursor-not-allowed disabled:opacity-40" | |
| > | |
| <Plus className="size-3.5" strokeWidth={2} /> | |
| Add new source | |
| </button> | |
| </div> | |
| {/* Hidden file input */} | |
| {!onUploadClick ? ( | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| accept={MATERIAL_UPLOAD_ACCEPT} | |
| className="hidden" | |
| onChange={handleFileChange} | |
| /> | |
| ) : null} | |
| </div> | |
| </aside> | |
| ); | |
| } | |
| // βββ Source card ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function SourceCard({ | |
| doc, | |
| isActive, | |
| onSelect, | |
| }: { | |
| doc: SourceDoc; | |
| isActive: boolean; | |
| onSelect: () => void; | |
| }) { | |
| const iconClassName = cn( | |
| "size-4", | |
| isActive ? "text-white" : "text-white/40 group-hover:text-white/60", | |
| ); | |
| return ( | |
| <button | |
| type="button" | |
| onClick={onSelect} | |
| className={cn( | |
| "group flex w-full min-w-0 items-center gap-2.5 rounded-xl border px-2.5 py-2.5 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20", | |
| isActive | |
| ? "border-white/24 bg-white/[0.075] shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]" | |
| : "border-transparent bg-transparent active:scale-[0.98] hover:border-white/[0.06] hover:bg-white/[0.025]", | |
| )} | |
| > | |
| {/* File icon */} | |
| <div | |
| className={cn( | |
| "grid shrink-0 place-items-center rounded-lg", | |
| isActive ? "size-8 bg-white/[0.10]" : "size-7 bg-white/[0.035]", | |
| )} | |
| > | |
| <FileTypeIcon fileType={doc.file_type} className={iconClassName} /> | |
| </div> | |
| {/* Title + subject */} | |
| <div className="min-w-0 flex-1"> | |
| <p | |
| className={cn( | |
| "truncate font-semibold", | |
| isActive ? "text-[12.5px] text-white" : "text-[11.5px] text-white/54 group-hover:text-white/76", | |
| )} | |
| > | |
| {getStudentFacingSourceTitle(doc.title, doc.subject)} | |
| </p> | |
| <p className={cn("truncate text-[10px]", isActive ? "text-white/42" : "text-white/28")}> | |
| {doc.subject || "Other"} {" / "} {materialTypeLabel(doc.material_type)} | |
| </p> | |
| </div> | |
| {/* Status badge */} | |
| <StatusBadge source={doc} /> | |
| </button> | |
| ); | |
| } | |
| // βββ Status badge βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function StatusBadge({ source }: { source: SourceDoc }) { | |
| const label = sourceReadinessLabel(source); | |
| if (label === "Ready") { | |
| return ( | |
| <span className="flex shrink-0 items-center gap-1 rounded-md border border-white/[0.07] px-1.5 py-0.5"> | |
| <CheckCircle2 className="size-2.5 text-white/45" strokeWidth={2.5} /> | |
| <span className="text-[8.5px] font-bold text-white/42">Ready</span> | |
| </span> | |
| ); | |
| } | |
| if (label === "Processing") { | |
| return ( | |
| <span className="flex shrink-0 items-center gap-1 rounded-md border border-white/[0.06] px-1.5 py-0.5"> | |
| <Clock className="size-2.5 animate-pulse text-white/36" strokeWidth={2.5} /> | |
| <span className="text-[8.5px] font-bold text-white/34">Processing</span> | |
| </span> | |
| ); | |
| } | |
| if (label === "Failed") { | |
| return ( | |
| <span className="flex shrink-0 items-center gap-1 rounded-md border border-red-400/15 px-1.5 py-0.5"> | |
| <XCircle className="size-2.5 text-red-400/80" strokeWidth={2.5} /> | |
| <span className="text-[8.5px] font-bold text-red-300/70">Failed</span> | |
| </span> | |
| ); | |
| } | |
| return ( | |
| <span className="max-w-[90px] shrink-0 truncate rounded-md border border-white/[0.09] px-1.5 py-0.5 text-center text-[8px] font-semibold leading-tight text-white/48"> | |
| {label} | |
| </span> | |
| ); | |
| } | |
| // βββ Loading skeleton βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function LoadingSkeleton() { | |
| return ( | |
| <div className="space-y-1.5"> | |
| {[1, 2, 3].map((i) => ( | |
| <div | |
| key={i} | |
| className="flex h-12 items-center gap-2.5 rounded-xl border border-white/[0.05] bg-white/[0.02] px-3" | |
| > | |
| <div className="size-8 animate-pulse rounded-lg bg-white/[0.05]" /> | |
| <div className="flex-1 space-y-1"> | |
| <div className="h-2.5 w-3/4 animate-pulse rounded bg-white/[0.05]" /> | |
| <div className="h-2 w-1/2 animate-pulse rounded bg-white/[0.04]" /> | |
| </div> | |
| <div className="h-5 w-12 animate-pulse rounded-md bg-white/[0.05]" /> | |
| </div> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| // βββ Empty state ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function EmptyState({ onUpload }: { onUpload: () => void }) { | |
| return ( | |
| <div className="flex flex-col items-center py-8 text-center"> | |
| <div className="mb-3 grid size-12 place-items-center rounded-2xl border border-white/[0.07] bg-white/[0.03]"> | |
| <FileText className="size-6 text-white/25" strokeWidth={1.5} /> | |
| </div> | |
| <p className="text-[12px] font-semibold text-white/50">Add your first study source</p> | |
| <p className="mt-1 text-[11px] leading-relaxed text-white/30"> | |
| Add notes, a chapter, syllabus, or a question paper. DocDoe will find study signals from your material. | |
| </p> | |
| <button | |
| type="button" | |
| onClick={onUpload} | |
| className="mt-4 flex items-center gap-1.5 rounded-xl bg-white px-3 py-1.5 text-[11.5px] font-bold text-black transition hover:bg-white/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30" | |
| > | |
| <Upload className="size-3" strokeWidth={2} /> | |
| Upload source | |
| </button> | |
| </div> | |
| ); | |
| } | |
| // βββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function FileTypeIcon({ fileType, className }: { fileType: string; className?: string }) { | |
| if (!fileType) return <File className={className} strokeWidth={1.8} />; | |
| const t = fileType.toLowerCase(); | |
| if (t.includes("pdf")) return <FileText className={className} strokeWidth={1.8} />; | |
| if (t.startsWith("image/")) return <ImageIcon className={className} strokeWidth={1.8} />; | |
| if (t.includes("html") || t.includes("url") || t.includes("link")) { | |
| return <Link className={className} strokeWidth={1.8} />; | |
| } | |
| return <File className={className} strokeWidth={1.8} />; | |
| } | |
| function filterBySearch(docs: SourceDoc[], search: string): SourceDoc[] { | |
| if (!search.trim()) return docs; | |
| const q = search.toLowerCase(); | |
| return docs.filter( | |
| (d) => | |
| d.title?.toLowerCase().includes(q) || | |
| d.subject?.toLowerCase().includes(q) || | |
| materialTypeLabel(d.material_type).toLowerCase().includes(q), | |
| ); | |
| } | |
| function sortDocuments(docs: SourceDoc[], activeDocumentId: string | null | undefined): SourceDoc[] { | |
| const statusOrder: Record<string, number> = { ready: 0, processing: 1, uploaded: 2, failed: 3 }; | |
| return [...docs].sort((a, b) => { | |
| if (a.id === activeDocumentId) return -1; | |
| if (b.id === activeDocumentId) return 1; | |
| return (statusOrder[a.status] ?? 2) - (statusOrder[b.status] ?? 2); | |
| }); | |
| } | |
| function groupByStatus(docs: SourceDoc[]): Array<{ label: string; docs: SourceDoc[] }> { | |
| const groups = [ | |
| { label: "Ready", docs: docs.filter((doc) => sourceReadinessLabel(doc) === "Ready") }, | |
| { | |
| label: "Processing", | |
| docs: docs.filter((doc) => sourceReadinessLabel(doc) === "Processing"), | |
| }, | |
| { | |
| label: "Needs classification", | |
| docs: docs.filter((doc) => sourceReadinessLabel(doc) === "Needs classification"), | |
| }, | |
| { | |
| label: "Not study material", | |
| docs: docs.filter((doc) => sourceReadinessLabel(doc) === "Not study material"), | |
| }, | |
| { label: "Failed", docs: docs.filter((doc) => sourceReadinessLabel(doc) === "Failed") }, | |
| ]; | |
| return groups.filter((group) => group.docs.length > 0); | |
| } | |