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