DocDoeAI / src /components /workspace /SourcesPanel.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
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);
}