DocDoeAI / src /components /study-chat /SourceRail.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
9.47 kB
"use client";
import { useMemo } from "react";
import { FileText, Upload, Edit2 } from "lucide-react";
import type { ApiDocument } from "@/lib/api/types";
import { cn } from "@/lib/utils";
import { documentKindLabel, documentStatusLabel } from "./utils";
import { WorkspaceSearch } from "./WorkspacePanelPrimitives";
function formatMaterialType(materialType: string | null | undefined): string {
if (!materialType || materialType === "unknown") return "Unclassified";
return materialType.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}
function getMaterialTypeBadgeColor(materialType: string | null | undefined): string {
if (!materialType || materialType === "unknown") {
return "border-white/12 bg-white/[0.04] text-white/46";
}
const colors: Record<string, string> = {
notes: "border-white/14 bg-white/[0.06] text-white/72",
chapter: "border-blue-300/16 bg-blue-400/[0.08] text-blue-200/80",
textbook: "border-emerald-300/16 bg-emerald-400/[0.08] text-emerald-200/80",
handwritten_notes: "border-amber-300/16 bg-amber-400/[0.08] text-amber-200/80",
question_paper: "border-rose-300/16 bg-rose-400/[0.08] text-rose-200/80",
pyq_collection: "border-orange-300/16 bg-orange-400/[0.08] text-orange-200/80",
syllabus: "border-cyan-300/16 bg-cyan-400/[0.08] text-cyan-200/80",
formula_sheet: "border-pink-300/16 bg-pink-400/[0.08] text-pink-200/80",
mixed: "border-white/14 bg-white/[0.06] text-white/72",
};
return colors[materialType] || colors.notes;
}
function formatEducationSummary(document: ApiDocument): string {
const status = document.education_extraction_status;
const warnings = document.education_warnings_json ?? [];
if (status === "extraction_failed") return "Extraction failed";
if (status === "needs_review") return warnings[0] ? `Needs review - ${warnings[0]}` : "Needs review";
if (document.material_type === "syllabus") {
const count = document.syllabus_items_count ?? 0;
return count > 0 ? `Syllabus - ${count} chapter${count === 1 ? "" : "s"} extracted` : "Syllabus - extracting topics";
}
if (document.material_type === "question_paper") {
const count = document.pyq_questions_count ?? 0;
const years = (document.pyq_years_json ?? []).join(", ");
if (count > 0) {
return `PYQ paper${years ? ` - ${years}` : ""} - ${count} question${count === 1 ? "" : "s"} extracted`;
}
return "Question paper - no questions extracted yet";
}
if (document.status === "ready") return "Generic notes - no PYQ data";
return documentStatusLabel(document.status);
}
type SourceRailProps = {
documents: ApiDocument[];
selectedSourceIds: string[];
isLoading: boolean;
search: string;
onSearchChange: (value: string) => void;
onToggleSource: (id: string) => void;
onUpload: () => void;
onEditClassification?: (documentId: string, documentTitle: string, currentMaterialType: string | null) => void;
};
export function SourceRail({
documents,
selectedSourceIds,
isLoading,
search,
onSearchChange,
onToggleSource,
onUpload,
onEditClassification,
}: SourceRailProps) {
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return documents;
return documents.filter((document) =>
[document.title, document.subject, document.chapter, document.syllabus, document.file_type, document.material_type]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query)),
);
}, [documents, search]);
return (
<div className="flex h-full min-h-0 flex-col rounded-[1.25rem] border border-white/[0.075] bg-[#080808]/88">
<div className="border-b border-white/[0.06] p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-white">Sources</p>
<p className="mt-0.5 text-xs text-white/36">
{documents.length ? `${documents.filter((document) => document.status === "ready").length} ready of ${documents.length}` : "Upload study material"}
</p>
</div>
<button
type="button"
onClick={onUpload}
className="grid size-9 shrink-0 place-items-center rounded-xl bg-white text-black transition hover:bg-white/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/28"
aria-label="Upload source"
>
<Upload className="size-4" />
</button>
</div>
<div className="mt-3">
<WorkspaceSearch value={search} onChange={onSearchChange} placeholder="Search sources" />
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="h-16 animate-pulse rounded-2xl border border-white/[0.06] bg-white/[0.025]" />
))}
</div>
) : filtered.length === 0 ? (
<div className="flex min-h-[14rem] flex-col items-center justify-center rounded-2xl border border-dashed border-white/[0.08] px-5 text-center">
<FileText className="size-7 text-white/32" />
<p className="mt-3 text-sm font-semibold text-white/72">No sources yet</p>
<p className="mt-1 text-xs leading-5 text-white/36">Upload a selectable-text PDF or TXT file, or paste syllabus text.</p>
<button
type="button"
onClick={onUpload}
className="mt-4 inline-flex min-h-10 items-center gap-2 rounded-xl bg-white px-3 text-xs font-bold text-black transition hover:bg-white/90"
>
<Upload className="size-3.5" />
Add source
</button>
</div>
) : (
<div className="space-y-2">
{filtered.map((document) => {
const active = selectedSourceIds.includes(document.id);
return (
<div
key={document.id}
className={cn(
"group flex w-full min-w-0 items-start gap-2 rounded-2xl border p-3 text-left transition",
active
? "border-white/[0.22] bg-white/[0.08]"
: "border-white/[0.065] bg-white/[0.025] hover:border-white/[0.14] hover:bg-white/[0.045]",
)}
>
<button
type="button"
onClick={() => onToggleSource(document.id)}
className="flex min-w-0 flex-1 items-start gap-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20"
>
<span className={cn("mt-0.5 grid size-9 shrink-0 place-items-center rounded-xl border", active ? "border-white/20 bg-white text-black" : "border-white/[0.08] bg-white/[0.035] text-white/58")}>
<FileText className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="line-clamp-2 text-sm font-semibold leading-5 text-white/86">{document.title}</span>
<span className="mt-1 flex items-center flex-wrap gap-1.5 text-[11px] text-white/36">
<span className="truncate">{document.subject || documentKindLabel(document)}</span>
<span className="size-1 rounded-full bg-white/24" />
<span>{documentStatusLabel(document.status)}</span>
{document.material_type && document.material_type !== "unknown" && (
<>
<span className="size-1 rounded-full bg-white/24" />
<span className={cn(
"rounded-full border px-1.5 py-0.5 text-[9px] font-medium",
getMaterialTypeBadgeColor(document.material_type)
)}>
{formatMaterialType(document.material_type)}
</span>
</>
)}
</span>
<span className="mt-1 block truncate text-[10.5px] leading-4 text-white/28">
{formatEducationSummary(document)}
</span>
</span>
</button>
{document.status === "ready" && onEditClassification && (
<button
type="button"
onClick={() => onEditClassification(document.id, document.title, document.material_type ?? null)}
className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg border border-white/[0.08] bg-white/[0.02] text-white/50 transition-all hover:border-white/20 hover:bg-white/[0.06] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20"
aria-label={`Edit classification for ${document.title}`}
>
<Edit2 className="size-3" strokeWidth={2} />
</button>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}