disclosure-bureau/web/components/inline-citation.tsx

156 lines
5 KiB
TypeScript
Raw Normal View History

/**
* InlineCitation renders a [[doc/p007#c0042]] citation as a clickable mini-card
* with bbox thumbnail, page, type, classification, and snippet. Fetches chunk
* data lazily from /api/chunk on first render.
*
* Used by:
* - <MarkdownBody> to upgrade chunk-anchor wiki-links (in chat assistant messages
* and in any markdown body that cites chunks)
*/
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
interface ChunkData {
chunk_id: string;
doc_id: string;
page: number;
type: string;
bbox: { x: number; y: number; w: number; h: number } | null;
classification: string | null;
content_en: string | null;
content_pt: string | null;
}
const cache = new Map<string, ChunkData | "not_found">();
export function InlineCitation({
docId,
chunkId,
label,
lang = "pt",
}: {
docId: string;
chunkId: string;
label?: string;
lang?: "pt" | "en";
}) {
const key = `${docId}/${chunkId}`;
const cached = cache.get(key);
const [data, setData] = useState<ChunkData | null>(
cached && cached !== "not_found" ? cached : null,
);
const [missing, setMissing] = useState<boolean>(cached === "not_found");
const [open, setOpen] = useState(false);
useEffect(() => {
if (cached) return;
let cancelled = false;
(async () => {
try {
const res = await fetch(
`/api/chunk?doc=${encodeURIComponent(docId)}&chunk=${encodeURIComponent(chunkId)}`,
);
if (!res.ok) {
cache.set(key, "not_found");
if (!cancelled) setMissing(true);
return;
}
const payload = (await res.json()) as ChunkData;
cache.set(key, payload);
if (!cancelled) setData(payload);
} catch {
cache.set(key, "not_found");
if (!cancelled) setMissing(true);
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [docId, chunkId]);
const href = `/d/${docId}#${chunkId}`;
if (missing) {
return (
<Link href={href} className="wiki-link wiki-link--doc" title={`${docId}/${chunkId}`}>
{label ?? chunkId}
</Link>
);
}
if (!data) {
return (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-[rgba(127,219,255,0.08)] border border-[rgba(127,219,255,0.20)] font-mono text-[10px] text-[#7fdbff] align-baseline">
<span className="animate-pulse">·</span>
{label ?? chunkId}
</span>
);
}
const text = lang === "en" ? data.content_en || data.content_pt : data.content_pt || data.content_en;
const cropUrl = data.bbox
? `/api/crop?doc=${encodeURIComponent(docId)}&page=${data.page}` +
`&x=${data.bbox.x}&y=${data.bbox.y}&w=${data.bbox.w}&h=${data.bbox.h}&w_px=320`
: null;
return (
<span className="relative inline-block align-baseline">
<button
type="button"
onClick={() => setOpen(!open)}
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-[rgba(0,255,156,0.08)] border border-[rgba(0,255,156,0.32)] font-mono text-[10px] text-[#00ff9c] hover:bg-[rgba(0,255,156,0.18)] cursor-pointer"
title={text?.slice(0, 200)}
>
📎 {label ?? chunkId} <span className="text-[#5a6678]">p{data.page}</span>
</button>
{open && (
<span className="absolute left-0 top-full mt-1 z-30 w-80 max-w-[min(90vw,500px)] bg-[#0a121e] border border-[#00ff9c] rounded shadow-xl p-3 block">
<span className="flex items-center gap-2 text-[10px] font-mono mb-2">
<span className="text-[#00ff9c]">{data.chunk_id}</span>
<span className="text-[#5a6678]">p{data.page}</span>
<span className="text-[#5a6678]">{data.type}</span>
{data.classification && (
<span className="text-[#ff6b6b]">{data.classification}</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setOpen(false);
}}
className="ml-auto text-[#5a6678] hover:text-[#00ff9c]"
aria-label="close"
>
</button>
</span>
{cropUrl && (
<span className="block mb-2 border border-[rgba(0,255,156,0.20)] rounded overflow-hidden bg-black">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={cropUrl}
alt=""
className="block w-full h-auto max-h-48 object-contain"
loading="lazy"
/>
</span>
)}
{text && (
<span className="block text-[11px] text-[#c8d4e6] leading-snug whitespace-pre-line line-clamp-6">
{text}
</span>
)}
<Link
href={href}
className="mt-2 block text-center font-mono text-[10px] text-[#7fdbff] hover:text-[#00ff9c] underline"
>
abrir página inteira
</Link>
</span>
)}
</span>
);
}