155 lines
5 KiB
TypeScript
155 lines
5 KiB
TypeScript
/**
|
|
* 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>
|
|
);
|
|
}
|