Download src/components/library/SourcesView.tsx from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 30.7 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/library/SourcesView.tsx
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/components/library/SourcesView.tsx
-
curl -L -o SourcesView.tsx https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/library/SourcesView.tsx
30.7 kB
| "use client"; | |
| import { useCallback, useEffect, useRef, useState } from "react"; | |
| import Link from "next/link"; | |
| import { | |
| ArrowRight, | |
| CheckCircle2, | |
| Clock3, | |
| FileText, | |
| Flame, | |
| LibraryBig, | |
| RefreshCw, | |
| Search, | |
| Trash2, | |
| UploadCloud, | |
| XCircle, | |
| } from "lucide-react"; | |
| import { deleteDocument, listDocuments } from "@/lib/api/documents"; | |
| import type { ApiDocument, DocumentStatus, PreviousPaper } from "@/lib/api/types"; | |
| import { buildDemoSeedDocuments } from "@/lib/demo-seed-documents"; | |
| import { getLocalPreviousPapers } from "@/lib/frontend-local-store"; | |
| import { buildStudyIntentUrl } from "@/lib/study-intents"; | |
| import { cn } from "@/lib/utils"; | |
| import { getPublicDemoMode } from "@/lib/env"; | |
| import { hasLiveSession } from "@/lib/api/mode"; | |
| import { MaterialDeletePrompt } from "@/components/study-chat/MaterialDeletePrompt"; | |
| type SourcesSurface = "light" | "dark"; | |
| type HeadingLevel = "h1" | "h2"; | |
| type SourceFilter = "all" | "notes" | "chapter" | "question_paper"; | |
| const FILTERS: Array<{ value: SourceFilter; label: string }> = [ | |
| { value: "all", label: "All" }, | |
| { value: "notes", label: "Notes" }, | |
| { value: "chapter", label: "Chapters" }, | |
| { value: "question_paper", label: "Question papers" }, | |
| ]; | |
| type SourcePalette = { | |
| root: string; | |
| heading: string; | |
| description: string; | |
| primaryButton: string; | |
| secondaryButton: string; | |
| toolbar: string; | |
| input: string; | |
| inputPlaceholder: string; | |
| icon: string; | |
| filterActive: string; | |
| filterInactive: string; | |
| count: string; | |
| subtleLink: string; | |
| card: string; | |
| cardTitle: string; | |
| cardMeta: string; | |
| divider: string; | |
| action: string; | |
| actionMuted: string; | |
| empty: string; | |
| emptyIcon: string; | |
| emptyTitle: string; | |
| emptyText: string; | |
| paperPanel: string; | |
| paperHeading: string; | |
| paperText: string; | |
| paperItem: string; | |
| infoPanel: string; | |
| infoText: string; | |
| skeleton: string; | |
| status: { | |
| ready: string; | |
| processing: string; | |
| failed: string; | |
| default: string; | |
| }; | |
| }; | |
| function getPalette(surface: SourcesSurface): SourcePalette { | |
| if (surface === "light") { | |
| return { | |
| root: "text-[#15161b]", | |
| heading: "text-[#15161b]", | |
| description: "text-[#737580]", | |
| primaryButton: | |
| "border border-[#17181b] bg-[#17181b] text-white hover:bg-[#303139] focus-visible:ring-[#17181b]/30", | |
| secondaryButton: | |
| "border border-[#dfe1e7] bg-white text-[#34363f] hover:border-[#c8cbd4] hover:bg-[#fafafa] focus-visible:ring-[#17181b]/20", | |
| toolbar: "border-[#dfe1e7] bg-white", | |
| input: "text-[#202128]", | |
| inputPlaceholder: "placeholder:text-[#989aa4]", | |
| icon: "text-[#747680]", | |
| filterActive: "border-[#b9bbc3] bg-[#17181b] text-white", | |
| filterInactive: | |
| "border-[#dfe1e7] bg-white text-[#666873] hover:border-[#bfc2cb] hover:text-[#25262c]", | |
| count: "text-[#737580]", | |
| subtleLink: "text-[#5b5d66] hover:text-[#17181b]", | |
| card: "border-[#e1e3e8] bg-white shadow-[0_8px_24px_rgba(20,22,29,0.035)] hover:border-[#c9ccd5] hover:shadow-[0_12px_30px_rgba(20,22,29,0.06)]", | |
| cardTitle: "text-[#202128] hover:text-[#17181b]", | |
| cardMeta: "text-[#858792]", | |
| divider: "border-[#ececf0]", | |
| action: | |
| "border-[#dfe1e7] bg-white text-[#34363f] hover:border-[#bfc2cb] hover:bg-[#fafafa] hover:text-[#17181b] focus-visible:ring-[#17181b]/20", | |
| actionMuted: | |
| "border-[#e7e8ed] text-[#62646e] hover:border-[#c9ccd5] hover:text-[#17181b] focus-visible:ring-[#17181b]/20", | |
| empty: "border-[#dedfe5] bg-[#fefefd]", | |
| emptyIcon: "border-[#e1e3e8] bg-[#f5f6f8] text-[#767883]", | |
| emptyTitle: "text-[#202128]", | |
| emptyText: "text-[#737580]", | |
| paperPanel: "border-[#e1e3e8] bg-white", | |
| paperHeading: "text-[#34363f]", | |
| paperText: "text-[#737580]", | |
| paperItem: | |
| "border-[#e5e6eb] bg-[#fafafa] text-[#4d4f58] hover:border-[#c9ccd5] hover:bg-white hover:text-[#17181b] focus-visible:ring-[#17181b]/20", | |
| infoPanel: "border-[#e1e3e8] bg-[#fafafa]", | |
| infoText: "text-[#737580]", | |
| skeleton: "border-[#e7e8ed] bg-white", | |
| status: { | |
| ready: "border-[#cfe4d7] bg-[#f3faf5] text-[#2f6b49]", | |
| processing: "border-[#ead9b8] bg-[#fffaf0] text-[#86621f]", | |
| failed: "border-[#ead1d1] bg-[#fff6f6] text-[#9b454d]", | |
| default: "border-[#e1e3e8] bg-[#f7f7f8] text-[#656772]", | |
| }, | |
| }; | |
| } | |
| return { | |
| root: "text-white", | |
| heading: "text-white", | |
| description: "text-white/52", | |
| primaryButton: | |
| "border border-white/[0.08] bg-white text-black hover:bg-white/90 focus-visible:ring-white/35", | |
| secondaryButton: | |
| "border border-white/[0.1] bg-white/[0.02] text-white/70 hover:border-white/20 hover:bg-white/[0.05] hover:text-white focus-visible:ring-white/25", | |
| toolbar: "border-white/[0.08] bg-white/[0.02]", | |
| input: "text-white", | |
| inputPlaceholder: "placeholder:text-white/30", | |
| icon: "text-white/40", | |
| filterActive: "border-white/20 bg-white/[0.07] text-white", | |
| filterInactive: | |
| "border-white/[0.08] bg-transparent text-white/50 hover:border-white/15 hover:text-white/80", | |
| count: "text-white/50", | |
| subtleLink: "text-white/50 hover:text-white", | |
| card: "border-white/[0.08] bg-[#0b0b0c] hover:border-white/[0.15]", | |
| cardTitle: "text-white hover:text-white", | |
| cardMeta: "text-white/50", | |
| divider: "border-white/[0.08]", | |
| action: | |
| "border-white/[0.1] bg-white/[0.02] text-white/70 hover:border-white/20 hover:bg-white/[0.05] hover:text-white focus-visible:ring-white/25", | |
| actionMuted: | |
| "border-white/[0.08] text-white/55 hover:border-white/15 hover:text-white focus-visible:ring-white/25", | |
| empty: "border-white/[0.12] bg-black/30", | |
| emptyIcon: "border-white/[0.1] bg-white/[0.035] text-white/40", | |
| emptyTitle: "text-white", | |
| emptyText: "text-white/52", | |
| paperPanel: "border-white/[0.08] bg-white/[0.02]", | |
| paperHeading: "text-white/70", | |
| paperText: "text-white/60", | |
| paperItem: | |
| "border-white/[0.06] text-white/70 hover:border-white/20 hover:text-white focus-visible:ring-white/25", | |
| infoPanel: "border-white/[0.08] bg-white/[0.02]", | |
| infoText: "text-white/50", | |
| skeleton: "border-white/[0.06] bg-white/[0.02]", | |
| status: { | |
| ready: "border-emerald-300/20 bg-emerald-300/[0.08] text-emerald-200/80", | |
| processing: "border-amber-300/20 bg-amber-300/[0.08] text-amber-200/80", | |
| failed: "border-rose-300/20 bg-rose-300/[0.08] text-rose-200/80", | |
| default: "border-white/[0.08] bg-white/[0.05] text-white/50", | |
| }, | |
| }; | |
| } | |
| function shouldShowDemoSources(): boolean { | |
| const mode = getPublicDemoMode(); | |
| if (mode === "demo") return true; | |
| if (mode === "live") return false; | |
| return !hasLiveSession(); | |
| } | |
| async function fetchSources() { | |
| let docs = await listDocuments(); | |
| if (docs.length === 0 && shouldShowDemoSources()) { | |
| docs = buildDemoSeedDocuments(); | |
| } | |
| return { docs, papers: getLocalPreviousPapers() }; | |
| } | |
| function statusText(status: DocumentStatus) { | |
| if (status === "ready") return "Ready to study"; | |
| if (status === "processing" || status === "uploaded") return "Reading material"; | |
| if (status === "failed") return "Could not read"; | |
| return "Saved source"; | |
| } | |
| function formatDate(iso: string) { | |
| const date = new Date(iso); | |
| if (Number.isNaN(date.getTime())) return "Date unavailable"; | |
| return new Intl.DateTimeFormat("en", { dateStyle: "medium" }).format(date); | |
| } | |
| function getDocumentMeta(document: ApiDocument) { | |
| if (document.chunk_count > 0) { | |
| return `${document.chunk_count} ${document.chunk_count === 1 ? "section" : "sections"}`; | |
| } | |
| const fileName = document.file_name.toLowerCase(); | |
| if (document.file_type.includes("pdf") || fileName.endsWith(".pdf")) return "PDF"; | |
| if (document.file_type.includes("text") || fileName.endsWith(".txt")) return "TXT"; | |
| return "Study material"; | |
| } | |
| function matchesFilter(document: ApiDocument, filter: SourceFilter) { | |
| if (filter === "all") return true; | |
| const materialType = (document.material_type ?? "").toLowerCase(); | |
| if (filter === "notes") return materialType.includes("note"); | |
| return materialType.includes(filter); | |
| } | |
| function FilterButton({ | |
| active, | |
| label, | |
| onClick, | |
| palette, | |
| }: { | |
| active: boolean; | |
| label: string; | |
| onClick: () => void; | |
| palette: SourcePalette; | |
| }) { | |
| return ( | |
| <button | |
| type="button" | |
| aria-pressed={active} | |
| onClick={onClick} | |
| className={cn( | |
| "inline-flex min-h-10 shrink-0 items-center justify-center rounded-xl border px-3.5 text-xs font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", | |
| active ? palette.filterActive : palette.filterInactive, | |
| )} | |
| > | |
| {label} | |
| </button> | |
| ); | |
| } | |
| function StatusBadge({ | |
| status, | |
| palette, | |
| }: { | |
| status: DocumentStatus; | |
| palette: SourcePalette; | |
| }) { | |
| const tone = | |
| status === "ready" | |
| ? palette.status.ready | |
| : status === "processing" || status === "uploaded" | |
| ? palette.status.processing | |
| : status === "failed" | |
| ? palette.status.failed | |
| : palette.status.default; | |
| return ( | |
| <span | |
| className={cn( | |
| "inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-[10px] font-semibold", | |
| tone, | |
| )} | |
| > | |
| {status === "ready" ? ( | |
| <CheckCircle2 aria-hidden="true" className="size-3" /> | |
| ) : status === "processing" || status === "uploaded" ? ( | |
| <Clock3 aria-hidden="true" className="size-3" /> | |
| ) : status === "failed" ? ( | |
| <XCircle aria-hidden="true" className="size-3" /> | |
| ) : ( | |
| <FileText aria-hidden="true" className="size-3" /> | |
| )} | |
| {statusText(status)} | |
| </span> | |
| ); | |
| } | |
| export function SourcesView({ | |
| surface = "dark", | |
| headingLevel = "h2", | |
| }: { | |
| surface?: SourcesSurface; | |
| headingLevel?: HeadingLevel; | |
| }) { | |
| const palette = getPalette(surface); | |
| const [documents, setDocuments] = useState<ApiDocument[]>([]); | |
| const [papers, setPapers] = useState<PreviousPaper[]>([]); | |
| const [error, setError] = useState<string | null>(null); | |
| const [isLoading, setIsLoading] = useState(true); | |
| const [query, setQuery] = useState(""); | |
| const [filter, setFilter] = useState<SourceFilter>("all"); | |
| const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null); | |
| const [deletingId, setDeletingId] = useState<string | null>(null); | |
| const [deleteError, setDeleteError] = useState<{ | |
| id: string; | |
| message: string; | |
| } | null>(null); | |
| const requestRef = useRef(0); | |
| const canDeleteSources = !shouldShowDemoSources() && hasLiveSession(); | |
| const loadSources = useCallback(async () => { | |
| const requestId = ++requestRef.current; | |
| setIsLoading(true); | |
| setError(null); | |
| try { | |
| const { docs, papers: nextPapers } = await fetchSources(); | |
| if (requestId !== requestRef.current) return; | |
| setDocuments(docs); | |
| setPapers(nextPapers); | |
| } catch { | |
| if (requestId !== requestRef.current) return; | |
| if (shouldShowDemoSources()) { | |
| setDocuments(buildDemoSeedDocuments()); | |
| setPapers(getLocalPreviousPapers()); | |
| } else { | |
| setError("Could not load your sources. Check your connection and retry."); | |
| } | |
| } finally { | |
| if (requestId === requestRef.current) setIsLoading(false); | |
| } | |
| }, []); | |
| useEffect(() => { | |
| const timeoutId = window.setTimeout(() => void loadSources(), 0); | |
| return () => { | |
| window.clearTimeout(timeoutId); | |
| requestRef.current += 1; | |
| }; | |
| }, [loadSources]); | |
| const normalizedQuery = query.trim().toLowerCase(); | |
| const filteredDocs = documents | |
| .filter((document) => { | |
| const searchableText = [ | |
| document.title, | |
| document.file_name, | |
| document.subject, | |
| document.chapter, | |
| ] | |
| .filter(Boolean) | |
| .join(" ") | |
| .toLowerCase(); | |
| return ( | |
| (!normalizedQuery || searchableText.includes(normalizedQuery)) && | |
| matchesFilter(document, filter) | |
| ); | |
| }) | |
| .sort((a, b) => b.created_at.localeCompare(a.created_at)); | |
| async function removeSource(documentId: string) { | |
| setDeletingId(documentId); | |
| setDeleteError(null); | |
| try { | |
| await deleteDocument(documentId); | |
| setDocuments((current) => | |
| current.filter((document) => document.id !== documentId), | |
| ); | |
| setConfirmDeleteId(null); | |
| } catch { | |
| setDeleteError({ | |
| id: documentId, | |
| message: | |
| "Could not delete this source. It is still available. Check your connection and retry.", | |
| }); | |
| setConfirmDeleteId(documentId); | |
| } finally { | |
| setDeletingId(null); | |
| } | |
| } | |
| const Heading = headingLevel === "h1" ? "h1" : "h2"; | |
| const filterLabel = FILTERS.find((item) => item.value === filter)?.label; | |
| const hasDocuments = documents.length > 0; | |
| return ( | |
| <div className={cn("w-full", palette.root)} aria-busy={isLoading}> | |
| <header className="mb-7 flex flex-col gap-4 sm:mb-8 sm:flex-row sm:items-end sm:justify-between"> | |
| <div className="min-w-0"> | |
| <Heading | |
| id="sources-title" | |
| className={cn("text-2xl font-semibold tracking-[-0.025em] sm:text-3xl", palette.heading)} | |
| > | |
| Sources | |
| </Heading> | |
| <p className={cn("mt-2 max-w-2xl text-sm leading-6", palette.description)}> | |
| Uploaded PDFs and TXT files available to your study workspace. Study actions unlock after a source is ready. | |
| </p> | |
| </div> | |
| <Link | |
| href="/upload" | |
| className={cn( | |
| "inline-flex min-h-11 shrink-0 items-center justify-center gap-2 rounded-xl px-5 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", | |
| palette.primaryButton, | |
| )} | |
| > | |
| <UploadCloud aria-hidden="true" className="size-4" /> | |
| Add material | |
| </Link> | |
| </header> | |
| <div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center"> | |
| <div | |
| role="search" | |
| aria-label="Search sources" | |
| className={cn( | |
| "flex min-w-0 flex-1 items-center gap-2 rounded-xl border px-3.5 transition focus-within:ring-4", | |
| palette.toolbar, | |
| surface === "light" | |
| ? "focus-within:border-[#17181b] focus-within:ring-[#17181b]/10" | |
| : "focus-within:border-white/20 focus-within:ring-white/10", | |
| )} | |
| > | |
| <Search aria-hidden="true" className={cn("size-4 shrink-0", palette.icon)} /> | |
| <label htmlFor="sources-search" className="sr-only"> | |
| Search sources | |
| </label> | |
| <input | |
| id="sources-search" | |
| value={query} | |
| onChange={(event) => setQuery(event.target.value)} | |
| placeholder="Search by title, file, subject, or chapter" | |
| className={cn( | |
| "h-11 min-w-0 flex-1 bg-transparent text-sm outline-none", | |
| palette.input, | |
| palette.inputPlaceholder, | |
| )} | |
| /> | |
| {query ? ( | |
| <button | |
| type="button" | |
| onClick={() => setQuery("")} | |
| aria-label="Clear source search" | |
| className={cn( | |
| "inline-flex min-h-9 shrink-0 items-center rounded-lg px-2 text-xs font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", | |
| palette.actionMuted, | |
| )} | |
| > | |
| Clear | |
| </button> | |
| ) : null} | |
| </div> | |
| <div | |
| aria-label="Filter sources by material type" | |
| className="flex max-w-full gap-2 overflow-x-auto pb-1 sm:pb-0" | |
| > | |
| {FILTERS.map((item) => ( | |
| <FilterButton | |
| key={item.value} | |
| active={filter === item.value} | |
| label={item.label} | |
| onClick={() => setFilter(item.value)} | |
| palette={palette} | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| <p className="sr-only" role="status" aria-live="polite"> | |
| {isLoading | |
| ? "Loading sources." | |
| : error | |
| ? error | |
| : normalizedQuery || filter !== "all" | |
| ? `${filteredDocs.length} ${filteredDocs.length === 1 ? "source" : "sources"} shown.` | |
| : `${documents.length} ${documents.length === 1 ? "source" : "sources"} available.`} | |
| </p> | |
| {error ? ( | |
| <div | |
| role="alert" | |
| className={cn( | |
| "mb-5 flex flex-col gap-3 rounded-2xl border px-4 py-4 text-sm sm:flex-row sm:items-center sm:justify-between", | |
| surface === "light" | |
| ? "border-[#ead1d1] bg-[#fff6f6] text-[#7f3d45]" | |
| : "border-rose-300/18 bg-rose-300/[0.06] text-rose-50/82", | |
| )} | |
| > | |
| <p>{hasDocuments ? "Could not refresh your sources. Showing the last available list." : error}</p> | |
| <button | |
| type="button" | |
| onClick={() => void loadSources()} | |
| className={cn( | |
| "inline-flex min-h-10 shrink-0 items-center justify-center gap-2 rounded-xl border px-4 text-xs font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", | |
| palette.secondaryButton, | |
| )} | |
| > | |
| <RefreshCw aria-hidden="true" className="size-3.5" /> | |
| Retry | |
| </button> | |
| </div> | |
| ) : null} | |
| <div className="grid gap-6 lg:grid-cols-12"> | |
| <section className="min-w-0 lg:col-span-8" aria-labelledby="sources-list-title"> | |
| <div className="mb-4 flex items-center justify-between gap-3"> | |
| <h3 id="sources-list-title" className={cn("text-xs font-semibold uppercase tracking-[0.12em]", palette.count)}> | |
| Study materials · {filteredDocs.length} | |
| </h3> | |
| <Link | |
| href="/documents" | |
| className={cn("inline-flex min-h-9 items-center gap-1 text-xs font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", palette.subtleLink)} | |
| > | |
| Full library | |
| <ArrowRight aria-hidden="true" className="size-3.5" /> | |
| </Link> | |
| </div> | |
| {isLoading && !hasDocuments ? ( | |
| <div role="status" aria-label="Loading sources" className="grid gap-4 sm:grid-cols-2"> | |
| <span className="sr-only">Loading your study material</span> | |
| {Array.from({ length: 4 }).map((_, index) => ( | |
| <div | |
| key={index} | |
| aria-hidden="true" | |
| className={cn("h-44 animate-pulse rounded-2xl border", palette.skeleton)} | |
| /> | |
| ))} | |
| </div> | |
| ) : null} | |
| {!isLoading && filteredDocs.length === 0 && (!error || hasDocuments) ? ( | |
| <div className={cn("flex min-h-64 flex-col items-center justify-center rounded-2xl border border-dashed px-6 py-12 text-center", palette.empty)}> | |
| <span className={cn("mb-4 grid size-12 place-items-center rounded-2xl border", palette.emptyIcon)}> | |
| <LibraryBig aria-hidden="true" className="size-6" /> | |
| </span> | |
| <h3 className={cn("text-base font-semibold", palette.emptyTitle)}> | |
| {normalizedQuery ? `No sources match “${query.trim()}”` : filter !== "all" ? `No ${filterLabel?.toLowerCase()} available` : "No uploaded sources yet"} | |
| </h3> | |
| <p className={cn("mx-auto mt-2 max-w-md text-sm leading-6", palette.emptyText)}> | |
| {normalizedQuery || filter !== "all" | |
| ? "Try a different search or clear the material filter." | |
| : "Upload a selectable-text PDF or TXT file. Images and scanned PDFs are not supported yet."} | |
| </p> | |
| {normalizedQuery || filter !== "all" ? ( | |
| <button | |
| type="button" | |
| onClick={() => { | |
| setQuery(""); | |
| setFilter("all"); | |
| }} | |
| className={cn( | |
| "mt-5 inline-flex min-h-10 items-center justify-center rounded-xl px-4 text-xs font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", | |
| palette.secondaryButton, | |
| )} | |
| > | |
| Clear filters | |
| </button> | |
| ) : ( | |
| <Link | |
| href="/upload" | |
| className={cn( | |
| "mt-5 inline-flex min-h-11 items-center justify-center gap-2 rounded-xl px-5 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", | |
| palette.primaryButton, | |
| )} | |
| > | |
| <UploadCloud aria-hidden="true" className="size-4" /> | |
| Upload first material | |
| </Link> | |
| )} | |
| </div> | |
| ) : null} | |
| {!isLoading && filteredDocs.length > 0 ? ( | |
| <div className="grid gap-4 sm:grid-cols-2"> | |
| {filteredDocs.map((document) => { | |
| const sourceTitle = document.title?.trim() || document.file_name || "Untitled source"; | |
| const isReady = document.status === "ready"; | |
| return ( | |
| <article | |
| key={document.id} | |
| className={cn("flex min-w-0 flex-col rounded-2xl border p-4 transition", palette.card)} | |
| aria-labelledby={`source-title-${document.id}`} | |
| > | |
| <div className="flex items-start justify-between gap-3"> | |
| <span className={cn("grid size-10 shrink-0 place-items-center rounded-xl border", palette.emptyIcon)}> | |
| <FileText aria-hidden="true" className="size-5" /> | |
| </span> | |
| <StatusBadge status={document.status} palette={palette} /> | |
| </div> | |
| <div className="mt-4 min-w-0 flex-1"> | |
| <h4 id={`source-title-${document.id}`} className="min-w-0"> | |
| <Link | |
| href={`/documents/${document.id}`} | |
| title={sourceTitle} | |
| className={cn("block truncate text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", palette.cardTitle)} | |
| > | |
| {sourceTitle} | |
| </Link> | |
| </h4> | |
| <p className={cn("mt-1 truncate text-xs", palette.cardMeta)}> | |
| {[document.subject, document.chapter].filter(Boolean).join(" · ") || "General study material"} | |
| </p> | |
| </div> | |
| <div className={cn("mt-4 flex items-center justify-between gap-3 border-t pt-3 text-[11px]", palette.divider, palette.cardMeta)}> | |
| <span>{getDocumentMeta(document)}</span> | |
| <time dateTime={document.created_at}>{formatDate(document.created_at)}</time> | |
| </div> | |
| <div className={cn("mt-3 flex flex-wrap gap-2 border-t pt-3", palette.divider)}> | |
| {isReady ? ( | |
| <> | |
| <Link | |
| href={buildStudyIntentUrl({ | |
| intent: "explain_source", | |
| sourceId: document.id, | |
| prompt: `Explain ${sourceTitle} in simple exam language`, | |
| })} | |
| aria-label={`Ask DocDoe about ${sourceTitle}`} | |
| className={cn("inline-flex min-h-10 items-center gap-1.5 rounded-xl px-3 text-xs font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", palette.action)} | |
| > | |
| Ask DocDoe | |
| <ArrowRight aria-hidden="true" className="size-3.5" /> | |
| </Link> | |
| <Link | |
| href={buildStudyIntentUrl({ | |
| intent: "generate_quiz", | |
| sourceId: document.id, | |
| prompt: `Quiz me from ${sourceTitle}`, | |
| })} | |
| aria-label={`Quiz me from ${sourceTitle}`} | |
| className={cn("inline-flex min-h-10 items-center rounded-xl px-3 text-xs font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", palette.actionMuted)} | |
| > | |
| Quiz | |
| </Link> | |
| <Link | |
| href={buildStudyIntentUrl({ | |
| intent: "create_video_plan", | |
| sourceId: document.id, | |
| prompt: `Create a study video plan from ${sourceTitle}`, | |
| })} | |
| aria-label={`Plan a video from ${sourceTitle}`} | |
| className={cn("inline-flex min-h-10 items-center rounded-xl px-3 text-xs font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", palette.actionMuted)} | |
| > | |
| Video plan | |
| </Link> | |
| </> | |
| ) : ( | |
| <Link | |
| href={`/documents/${document.id}`} | |
| className={cn("inline-flex min-h-10 items-center gap-1.5 rounded-xl px-3 text-xs font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", palette.action)} | |
| > | |
| {document.status === "failed" ? "Review source" : "View progress"} | |
| <ArrowRight aria-hidden="true" className="size-3.5" /> | |
| </Link> | |
| )} | |
| {canDeleteSources && confirmDeleteId !== document.id ? ( | |
| <button | |
| type="button" | |
| onClick={() => { | |
| setDeleteError(null); | |
| setConfirmDeleteId(document.id); | |
| }} | |
| className={cn("ml-auto inline-flex min-h-10 items-center gap-1.5 rounded-xl px-3 text-xs font-semibold text-[#a13f49] transition hover:bg-[#fff2f3] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b43d4b]/30 focus-visible:ring-offset-2", surface === "light" ? "" : "text-red-300/70 hover:bg-red-400/10 hover:text-red-200")} | |
| aria-label={`Delete ${sourceTitle}`} | |
| > | |
| <Trash2 aria-hidden="true" className="size-3.5" /> | |
| Delete | |
| </button> | |
| ) : null} | |
| </div> | |
| {confirmDeleteId === document.id ? ( | |
| <div className="mt-3"> | |
| <MaterialDeletePrompt | |
| title={sourceTitle} | |
| deleting={deletingId === document.id} | |
| error={deleteError?.id === document.id ? deleteError.message : null} | |
| onConfirm={() => void removeSource(document.id)} | |
| onCancel={() => { | |
| if (deletingId) return; | |
| setConfirmDeleteId(null); | |
| setDeleteError(null); | |
| }} | |
| /> | |
| </div> | |
| ) : null} | |
| </article> | |
| ); | |
| })} | |
| </div> | |
| ) : null} | |
| </section> | |
| <aside className="space-y-5 lg:col-span-4" aria-label="Source guidance"> | |
| <section className={cn("rounded-2xl border p-5", palette.paperPanel)} aria-labelledby="pyq-intelligence-title"> | |
| <div className={cn("flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.12em]", palette.paperHeading)}> | |
| <Flame aria-hidden="true" className="size-3.5" /> | |
| <h3 id="pyq-intelligence-title">Question-paper evidence</h3> | |
| </div> | |
| <p className={cn("mt-2 text-sm leading-6", palette.paperText)}> | |
| Question papers you add here can support pattern analysis. DocDoe does not infer PYQ signals without paper evidence. | |
| </p> | |
| <div className="mt-4 space-y-2 text-sm"> | |
| {papers.length > 0 ? ( | |
| papers.slice(0, 5).map((paper) => ( | |
| <Link | |
| key={paper.id} | |
| href={buildStudyIntentUrl({ intent: "analyze_pyq", sourceId: paper.id })} | |
| className={cn("block rounded-xl border p-3 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", palette.paperItem)} | |
| > | |
| {paper.title?.trim() || "Question paper"} | |
| </Link> | |
| )) | |
| ) : ( | |
| <p className={cn("text-xs leading-5", palette.paperText)}> | |
| No question papers are available yet. | |
| </p> | |
| )} | |
| </div> | |
| <Link | |
| href="/previous-papers" | |
| className={cn("mt-4 inline-flex min-h-9 items-center gap-1 text-xs font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", palette.subtleLink)} | |
| > | |
| Open paper analyzer | |
| <ArrowRight aria-hidden="true" className="size-3.5" /> | |
| </Link> | |
| </section> | |
| <section className={cn("rounded-2xl border p-5 text-xs leading-5", palette.infoPanel, palette.infoText)} aria-labelledby="source-actions-title"> | |
| <h3 id="source-actions-title" className={cn("font-semibold", palette.heading)}> | |
| What happens when a source is ready? | |
| </h3> | |
| <p className="mt-2"> | |
| Open the document to inspect its reading status. Once ready, Ask DocDoe, Quiz, and Video plan hand off to the current Study Chat with this source selected. | |
| </p> | |
| </section> | |
| </aside> | |
| </div> | |
| </div> | |
| ); | |
| } | |