Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 150 additions & 44 deletions apps/web/app/(dashboard)/dashboard/documents/[docId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState, useCallback, use } from "react";
import { useState, useCallback, use, useMemo } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import useSWR from "swr";
Expand All @@ -24,12 +24,13 @@ import {
FileType,
GitBranch,
Loader2,
ExternalLink,
Network,
FileText,
Download,
Copy,
Check,
ChevronRight,
ChevronDown,
} from "lucide-react";
import { MarkdownSummary } from "@/components/ui/markdown";
import {
Expand Down Expand Up @@ -58,6 +59,8 @@ interface DocumentDetail {
interface TocSection {
section_id: string;
order: number;
depth?: number;
parent_id?: string;
title: string;
summary: string;
page_range: string;
Expand All @@ -72,6 +75,148 @@ interface TocData {
sections: TocSection[];
}

// ── Nested-card document tree ────────────────────────────────────────
// Builds a parent→children index from the flat section list (each section
// carries parent_id + depth) and renders it as collapsible nested cards.
// Documents with no hierarchy degrade gracefully to a flat list of cards.

function buildSectionTree(sections: TocSection[]) {
const byId = new Map(sections.map((s) => [s.section_id, s]));
const childrenOf = new Map<string, TocSection[]>();
const roots: TocSection[] = [];
for (const s of sections) {
const pid = s.parent_id ?? "";
if (pid && pid !== s.section_id && byId.has(pid)) {
const arr = childrenOf.get(pid) ?? [];
arr.push(s);
childrenOf.set(pid, arr);
} else {
roots.push(s);
}
}
const byOrder = (a: TocSection, b: TocSection) =>
(a.order ?? 0) - (b.order ?? 0);
roots.sort(byOrder);
childrenOf.forEach((arr) => arr.sort(byOrder));
return { childrenOf, roots };
}

function SectionTree({
sections,
docId,
}: {
sections: TocSection[];
docId: string;
}) {
const { childrenOf, roots } = useMemo(
() => buildSectionTree(sections),
[sections],
);
return (
<div className="space-y-2">
{roots.map((s) => (
<SectionTreeNode
key={s.section_id}
section={s}
childrenOf={childrenOf}
docId={docId}
depth={0}
/>
))}
</div>
);
}

function SectionTreeNode({
section,
childrenOf,
docId,
depth,
}: {
section: TocSection;
childrenOf: Map<string, TocSection[]>;
docId: string;
depth: number;
}) {
const kids = childrenOf.get(section.section_id) ?? [];
const hasKids = kids.length > 0;
// Top two levels open by default; deeper levels start collapsed.
const [open, setOpen] = useState(depth < 2);

return (
<div>
<div className="rounded-2xl border border-border-light bg-white transition-all hover:border-primary/30 hover:shadow-[rgba(0,0,0,0.06)_0px_2px_10px]">
<div className="flex items-start gap-2 p-4">
{hasKids ? (
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-label={open ? "Collapse subsections" : "Expand subsections"}
className="mt-0.5 shrink-0 rounded-md p-0.5 text-muted-foreground transition-colors hover:bg-black/5 hover:text-foreground"
>
{open ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
) : (
<span className="mt-0.5 inline-block w-5 shrink-0" />
)}

<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-primary/10 font-data text-xs font-medium text-primary">
{section.order ?? "•"}
</span>

<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/dashboard/documents/${docId}/sections/${section.section_id}`}
className="text-sm font-medium text-foreground transition-colors hover:text-primary"
>
{section.title}
</Link>
{hasKids && (
<Badge variant="secondary" className="font-data text-[10px]">
{kids.length} sub
</Badge>
)}
</div>
{section.summary && (
<div className="text-secondary">
<MarkdownSummary content={section.summary} />
</div>
)}
</div>

{section.page_range && (
<Badge
variant="secondary"
className="shrink-0 font-data text-xs"
>
pp. {section.page_range}
</Badge>
)}
</div>
</div>

{hasKids && open && (
<div className="ml-[1.35rem] mt-2 space-y-2 border-l border-border-gray pl-4">
{kids.map((k) => (
<SectionTreeNode
key={k.section_id}
section={k}
childrenOf={childrenOf}
docId={docId}
depth={depth + 1}
/>
))}
</div>
)}
</div>
);
}

// Engine status enum: pending | parsing | summarizing | ready | failed.
// Anything that's not ready or failed counts as "in flight" — keep
// polling until it settles.
Expand Down Expand Up @@ -343,48 +488,9 @@ export default function DocumentDetailPage({
)}
</div>
)}
{sections.map((section, index) => (
<Link
key={section.section_id}
href={`/dashboard/documents/${docId}/sections/${section.section_id}`}
className="block rounded-2xl border border-border-light bg-white p-4 transition-all hover:border-primary/30 hover:shadow-[rgba(0,0,0,0.06)_0px_2px_10px]"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/10 text-xs font-medium text-primary">
{section.order ?? index + 1}
</span>
<h3 className="text-sm font-medium text-foreground">
{section.title}
</h3>
</div>
{section.summary && (
<div className="pl-8">
<MarkdownSummary content={section.summary} />
</div>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
{section.page_range && (
<Badge
variant="secondary"
className="text-xs font-mono"
>
pp. {section.page_range}
</Badge>
)}
<Badge
variant="outline"
className="text-[10px] font-mono text-muted-foreground"
>
<ExternalLink className="mr-1 h-2.5 w-2.5" />
{section.section_id}
</Badge>
</div>
</div>
</Link>
))}
{sections.length > 0 && (
<SectionTree sections={sections} docId={docId} />
)}
</CardContent>
</Card>
</div>
Expand Down
Loading