Doc page (/d/[docId]/[page]) gains prev/next navigation bars (top + bottom): within a doc it steps page-by-page; at the first/last page it jumps to the previous/next document. Replaces the disabled-at-boundary links. Indexer tooling for the VPS repopulation: - 30-index-chunks-to-db.py: add --no-embed (fast BM25-only index; vectors backfilled separately) so the app is usable in minutes, not hours of CPU embedding. - 57_load_relations_from_json.py: load typed relations into public.relations from reextract structured fields (deterministic ids, no fuzzy guessing). - 58_backfill_embeddings.py: async pass to fill chunks.embedding (NULL rows) via the embed-service. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
179 lines
6.7 KiB
TypeScript
179 lines
6.7 KiB
TypeScript
/**
|
|
* /d/[docId]/[page] — single-page chunks view.
|
|
*
|
|
* Scoped to one page (e.g., p007). Shows the PNG of the page alongside
|
|
* the chunks for cross-reference.
|
|
*/
|
|
import Link from "next/link";
|
|
import Image from "next/image";
|
|
import { notFound } from "next/navigation";
|
|
import { readChunksByPage, readIndex, hasChunks } from "@/lib/chunks";
|
|
import { readDocument, listDocuments } from "@/lib/wiki";
|
|
import { AuthBar } from "@/components/auth-bar";
|
|
import { ChatBubble } from "@/components/chat-bubble";
|
|
import { DocRendererV2 } from "@/components/doc-renderer-v2";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
export default async function DocPageView({
|
|
params,
|
|
}: {
|
|
params: Promise<{ docId: string; page: string }>;
|
|
}) {
|
|
const { docId, page } = await params;
|
|
const stem = /^p\d{3}$/.test(page) ? page : `p${page.padStart(3, "0")}`;
|
|
const m = stem.match(/^p(\d{3})$/);
|
|
if (!m) notFound();
|
|
const pageNum = parseInt(m[1], 10);
|
|
|
|
if (!(await hasChunks(docId))) {
|
|
return (
|
|
<main className="min-h-screen p-6 md:p-10 max-w-4xl mx-auto">
|
|
<div className="flex items-start justify-between gap-4 mb-6">
|
|
<Link href={`/d/${docId}`} className="font-mono text-xs text-[#7fdbff] hover:text-[#00ff9c]">
|
|
← documento
|
|
</Link>
|
|
<AuthBar />
|
|
</div>
|
|
<div className="border border-[rgba(0,255,156,0.15)] bg-[#0a121e] rounded p-6">
|
|
<h1 className="font-mono text-lg text-[#00ff9c] mb-2">▍ documento ainda não indexado</h1>
|
|
<p className="text-[#c8d4e6] text-sm">
|
|
Este documento ainda não foi processado.
|
|
</p>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
const [idx, byPage, doc, docList] = await Promise.all([
|
|
readIndex(docId),
|
|
readChunksByPage(docId),
|
|
readDocument(docId),
|
|
listDocuments(),
|
|
]);
|
|
if (!idx) notFound();
|
|
|
|
const pageChunks = byPage.get(pageNum) ?? [];
|
|
const pngUrl = `/api/static/processing/png/${docId}/p-${m[1]}.png`;
|
|
const totalPages = idx.total_pages;
|
|
|
|
// ── Navigation: prev/next page within doc; at boundaries, prev/next document ──
|
|
const pp = (n: number) => `p${String(n).padStart(3, "0")}`;
|
|
const docIdx = docList.indexOf(docId);
|
|
const prevDoc = docIdx > 0 ? docList[docIdx - 1] : null;
|
|
const nextDoc = docIdx >= 0 && docIdx < docList.length - 1 ? docList[docIdx + 1] : null;
|
|
|
|
const prevNav =
|
|
pageNum > 1
|
|
? { href: `/d/${docId}/${pp(pageNum - 1)}`, label: `← página ${pageNum - 1}`, kind: "page" as const }
|
|
: prevDoc
|
|
? { href: `/d/${prevDoc}/${pp(1)}`, label: "← documento anterior", kind: "doc" as const }
|
|
: null;
|
|
const nextNav =
|
|
pageNum < totalPages
|
|
? { href: `/d/${docId}/${pp(pageNum + 1)}`, label: `página ${pageNum + 1} →`, kind: "page" as const }
|
|
: nextDoc
|
|
? { href: `/d/${nextDoc}/${pp(1)}`, label: "próximo documento →", kind: "doc" as const }
|
|
: null;
|
|
|
|
const navBar = (
|
|
<nav className="flex items-center justify-between gap-3 font-mono text-xs">
|
|
{prevNav ? (
|
|
<Link
|
|
href={prevNav.href}
|
|
className={`px-3 py-1.5 border rounded hover:bg-[rgba(0,255,156,0.10)] ${
|
|
prevNav.kind === "doc"
|
|
? "border-[#ffa500] text-[#ffa500] hover:bg-[rgba(255,165,0,0.10)]"
|
|
: "border-[rgba(0,255,156,0.30)] text-[#00ff9c]"
|
|
}`}
|
|
>
|
|
{prevNav.label}
|
|
</Link>
|
|
) : (
|
|
<span className="px-3 py-1.5 opacity-30">← início</span>
|
|
)}
|
|
<span className="text-[#5a6678] tabular-nums">
|
|
{pageNum} / {totalPages}
|
|
</span>
|
|
{nextNav ? (
|
|
<Link
|
|
href={nextNav.href}
|
|
className={`px-3 py-1.5 border rounded hover:bg-[rgba(0,255,156,0.10)] ${
|
|
nextNav.kind === "doc"
|
|
? "border-[#ffa500] text-[#ffa500] hover:bg-[rgba(255,165,0,0.10)]"
|
|
: "border-[rgba(0,255,156,0.30)] text-[#00ff9c]"
|
|
}`}
|
|
>
|
|
{nextNav.label}
|
|
</Link>
|
|
) : (
|
|
<span className="px-3 py-1.5 opacity-30">fim →</span>
|
|
)}
|
|
</nav>
|
|
);
|
|
|
|
return (
|
|
<main className="min-h-screen p-6 md:p-10 max-w-6xl mx-auto">
|
|
<div className="flex items-start justify-between gap-4 mb-6">
|
|
<div className="flex items-center gap-3 font-mono text-xs">
|
|
<Link href={`/d/${docId}`} className="text-[#7fdbff] hover:text-[#00ff9c]">
|
|
← documento inteiro
|
|
</Link>
|
|
</div>
|
|
<AuthBar />
|
|
</div>
|
|
|
|
<header className="mb-6 pb-4 border-b border-[rgba(0,255,156,0.32)]">
|
|
<div className="font-mono text-[10px] text-[#5a6678] tracking-widest uppercase mb-2">
|
|
página {pageNum} de {totalPages} · {pageChunks.length} trechos · doc:{" "}
|
|
<span className="text-[#7fdbff]">{docId}</span>
|
|
</div>
|
|
<h1 className="font-mono text-xl text-[#00ff9c]">
|
|
▍ {(doc?.fm.canonical_title as string) ?? docId} · p{pageNum}
|
|
</h1>
|
|
</header>
|
|
|
|
<div className="mb-6">{navBar}</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-[1fr_1fr] gap-8">
|
|
<aside className="lg:sticky lg:top-6 lg:self-start lg:max-h-[85vh] lg:overflow-y-auto">
|
|
<h2 className="font-mono text-xs uppercase tracking-widest text-[#7fdbff] mb-2">
|
|
scanned PNG · 72 DPI
|
|
</h2>
|
|
<div className="border border-[rgba(0,255,156,0.18)] rounded overflow-hidden bg-[#0a121e]">
|
|
<Image
|
|
src={pngUrl}
|
|
alt={`página ${pageNum}`}
|
|
width={800}
|
|
height={1100}
|
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
|
className="block w-full h-auto"
|
|
/>
|
|
</div>
|
|
</aside>
|
|
|
|
<article>
|
|
<h2 className="font-mono text-xs uppercase tracking-widest text-[#7fdbff] mb-2">
|
|
trechos (ordem de leitura)
|
|
</h2>
|
|
{pageChunks.length === 0 ? (
|
|
<div className="border border-[rgba(0,255,156,0.15)] bg-[#0a121e] rounded p-6 text-sm text-[#c8d4e6]">
|
|
<p className="font-mono text-[#7fdbff] mb-2">▍ página sem trechos extraídos</p>
|
|
<p className="text-[#5a6678] text-xs">
|
|
O scan existe (veja à esquerda) mas o processo de chunking não gerou trechos
|
|
para esta página específica. Pode ser página em branco, divisor de seção
|
|
ou conteúdo sem texto extraível. Próxima execução do chunker preencherá.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<DocRendererV2 docId={docId} chunksByPage={[[pageNum, pageChunks]]} />
|
|
)}
|
|
</article>
|
|
</div>
|
|
|
|
<div className="mt-10 pt-6 border-t border-[rgba(0,255,156,0.32)]">{navBar}</div>
|
|
|
|
<ChatBubble context={{ doc_id: docId, page_id: `${docId}/${stem}` }} />
|
|
</main>
|
|
);
|
|
}
|