146 lines
5.7 KiB
TypeScript
146 lines
5.7 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import * as Dialog from "@radix-ui/react-dialog";
|
||
|
|
import { useEffect, useState } from "react";
|
||
|
|
import { X, ExternalLink } from "lucide-react";
|
||
|
|
import { MarkdownBody } from "./markdown-body";
|
||
|
|
|
||
|
|
interface EntityModalProps {
|
||
|
|
cls: string;
|
||
|
|
id: string;
|
||
|
|
open: boolean;
|
||
|
|
onClose: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface EntityResponse {
|
||
|
|
entity_id: string;
|
||
|
|
class: string;
|
||
|
|
frontmatter: Record<string, unknown>;
|
||
|
|
body: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function EntityModal({ cls, id, open, onClose }: EntityModalProps) {
|
||
|
|
const [data, setData] = useState<EntityResponse | null>(null);
|
||
|
|
const [loading, setLoading] = useState(false);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!open) return;
|
||
|
|
setLoading(true);
|
||
|
|
setError(null);
|
||
|
|
setData(null);
|
||
|
|
fetch(`/api/entities/${cls}/${id}`)
|
||
|
|
.then((r) => (r.ok ? r.json() : Promise.reject(r.statusText)))
|
||
|
|
.then((d: EntityResponse) => setData(d))
|
||
|
|
.catch((e: string) => setError(String(e)))
|
||
|
|
.finally(() => setLoading(false));
|
||
|
|
}, [open, cls, id]);
|
||
|
|
|
||
|
|
const fm = data?.frontmatter as Record<string, unknown> | undefined;
|
||
|
|
const sources = (fm?.external_sources as Array<{
|
||
|
|
url?: string; title?: string; publisher?: string; reliability_band?: string;
|
||
|
|
key_facts?: string[];
|
||
|
|
}> | undefined) ?? [];
|
||
|
|
const status = (fm?.enrichment_status as string | undefined) ?? "none";
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Dialog.Root open={open} onOpenChange={(o) => !o && onClose()}>
|
||
|
|
<Dialog.Portal>
|
||
|
|
<Dialog.Overlay className="fixed inset-0 bg-black/70 backdrop-blur-sm z-40" />
|
||
|
|
<Dialog.Content
|
||
|
|
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50
|
||
|
|
w-[92vw] max-w-3xl max-h-[88vh] overflow-y-auto
|
||
|
|
bg-[#0a121e] border border-[rgba(0,255,156,0.32)] rounded
|
||
|
|
p-6 text-[#c8d4e6] font-sans"
|
||
|
|
>
|
||
|
|
<div className="flex items-start justify-between mb-3">
|
||
|
|
<div>
|
||
|
|
<span className="font-mono text-[10px] text-[#5a6678] tracking-widest uppercase">
|
||
|
|
ENTITY · {data?.class ?? cls} · status: {status}
|
||
|
|
</span>
|
||
|
|
<Dialog.Title className="font-mono text-xl text-[#00ff9c] mt-1">
|
||
|
|
{(fm?.canonical_name as string) ?? id}
|
||
|
|
</Dialog.Title>
|
||
|
|
</div>
|
||
|
|
<Dialog.Close asChild>
|
||
|
|
<button className="p-1 hover:bg-[rgba(0,255,156,0.08)] rounded" aria-label="Close">
|
||
|
|
<X size={18} />
|
||
|
|
</button>
|
||
|
|
</Dialog.Close>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{loading && <div className="text-[#8896aa] text-sm">Loading…</div>}
|
||
|
|
{error && <div className="text-[#ff3344] text-sm">Error: {error}</div>}
|
||
|
|
|
||
|
|
{fm && (
|
||
|
|
<div className="space-y-4 text-sm">
|
||
|
|
{Array.isArray(fm.aliases) && (fm.aliases as string[]).length > 0 && (
|
||
|
|
<section>
|
||
|
|
<h3 className="font-mono text-[10px] text-[#8896aa] uppercase tracking-widest mb-1">
|
||
|
|
Aliases
|
||
|
|
</h3>
|
||
|
|
<div className="flex flex-wrap gap-1">
|
||
|
|
{(fm.aliases as string[]).slice(0, 12).map((a) => (
|
||
|
|
<span key={a} className="px-2 py-0.5 bg-[rgba(127,219,255,0.08)]
|
||
|
|
text-[#7fdbff] text-xs font-mono rounded">
|
||
|
|
{a}
|
||
|
|
</span>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{typeof fm.disambiguation_note === "string" && fm.disambiguation_note && (
|
||
|
|
<section className="border-l-2 border-[#f5c542] pl-3 text-[#f5c542]">
|
||
|
|
<strong className="font-mono text-[10px] uppercase">Disambiguation:</strong>{" "}
|
||
|
|
{fm.disambiguation_note}
|
||
|
|
</section>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{data?.body && (
|
||
|
|
<section>
|
||
|
|
<MarkdownBody>{data.body}</MarkdownBody>
|
||
|
|
</section>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{sources.length > 0 && (
|
||
|
|
<section>
|
||
|
|
<h3 className="font-mono text-[11px] text-[#00ff9c] uppercase tracking-widest mb-2">
|
||
|
|
External Sources
|
||
|
|
</h3>
|
||
|
|
<ul className="space-y-2">
|
||
|
|
{sources.map((s, i) => (
|
||
|
|
<li key={i} className="border-l-2 border-[rgba(0,255,156,0.32)] pl-3">
|
||
|
|
<a href={s.url} target="_blank" rel="noopener noreferrer"
|
||
|
|
className="text-[#7fdbff] hover:text-[#00ff9c] inline-flex items-center gap-1">
|
||
|
|
{s.title || s.url}
|
||
|
|
<ExternalLink size={12} />
|
||
|
|
</a>
|
||
|
|
<div className="text-[10px] text-[#8896aa] mt-0.5">
|
||
|
|
{s.publisher} · reliability: <span className="font-mono">{s.reliability_band}</span>
|
||
|
|
</div>
|
||
|
|
{s.key_facts && s.key_facts.length > 0 && (
|
||
|
|
<ul className="mt-1 text-xs text-[#c8d4e6] list-disc list-inside">
|
||
|
|
{s.key_facts.slice(0, 4).map((k, j) => (
|
||
|
|
<li key={j}>{k}</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
)}
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
</section>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className="pt-2 text-[10px] text-[#5a6678] font-mono tracking-widest uppercase">
|
||
|
|
total mentions: {(fm.total_mentions as number) ?? 0} ·
|
||
|
|
docs: {(fm.documents_count as number) ?? 0}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</Dialog.Content>
|
||
|
|
</Dialog.Portal>
|
||
|
|
</Dialog.Root>
|
||
|
|
);
|
||
|
|
}
|