File size: 4,813 Bytes
baf8a7b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 | import { useEffect, useState } from "react";
import {
WarningCircle,
CircleNotch,
FileText,
Info,
} from "@phosphor-icons/react";
import type { Fact, Provenance } from "../lib/api";
export const ICON = { size: 16, weight: "regular" as const };
/** Error state. Inline, specific, never a bare "something went wrong". */
export function ErrorNote({ message, onRetry }: { message: string; onRetry?: () => void }) {
return (
<div className="err" role="alert">
<WarningCircle size={17} weight="fill" style={{ flexShrink: 0, marginTop: 1 }} />
<div>
<div>{message}</div>
{onRetry && (
<button className="btn btn-ghost btn-sm" style={{ marginTop: 8 }} onClick={onRetry}>
Try again
</button>
)}
</div>
</div>
);
}
/** Loading state shaped like the table it replaces, not a spinner in a void. */
export function TableSkeleton({ rows = 8 }: { rows?: number }) {
return (
<div aria-busy="true" aria-label="Loading">
{Array.from({ length: rows }).map((_, i) => (
<div className="skel-row" key={i}>
<div className="skel" style={{ width: `${58 + ((i * 13) % 34)}%` }} />
<div className="skel" style={{ width: "68%" }} />
<div className="skel" style={{ width: `${44 + ((i * 21) % 42)}%` }} />
<div className="skel" style={{ width: "56%" }} />
</div>
))}
</div>
);
}
export function Empty({
icon, title, children,
}: { icon?: React.ReactNode; title: string; children?: React.ReactNode }) {
return (
<div className="empty">
<div className="ico">{icon ?? <FileText size={26} />}</div>
<h3>{title}</h3>
{children && <p>{children}</p>}
</div>
);
}
export function Spinner() {
return <CircleNotch size={15} className="spin" aria-hidden />;
}
export function Note({
tone = "accent", children,
}: { tone?: "accent" | "warn" | "quiet"; children: React.ReactNode }) {
const cls = tone === "warn" ? "note note-warn" : tone === "quiet" ? "note note-quiet" : "note";
return <div className={cls}>{children}</div>;
}
/** Citation popover. Follows the pointer, driven by native hover events. */
export function CitationPopover({ container }: { container: React.RefObject<HTMLElement | null> }) {
const [pop, setPop] = useState<{
x: number; y: number; source: string; locator: string; raw: string; status: string;
} | null>(null);
useEffect(() => {
// Listen on the document, not on container.current. The container renders
// only once its content has loaded, so an effect that reads the ref on mount
// finds null and silently never attaches: the popover would never appear.
const onOver = (e: Event) => {
const t = (e.target as HTMLElement)?.closest?.("cite");
if (!t) return;
if (container.current && !container.current.contains(t)) return;
const r = t.getBoundingClientRect();
setPop({
x: Math.min(r.left, window.innerWidth - 440),
y: r.bottom + 8,
source: t.getAttribute("data-source") ?? "",
locator: t.getAttribute("data-locator") ?? "",
raw: t.getAttribute("data-raw") ?? "",
status: t.getAttribute("data-status") ?? "",
});
};
const onOut = (e: Event) => {
if ((e.target as HTMLElement)?.closest?.("cite")) setPop(null);
};
document.addEventListener("mouseover", onOver);
document.addEventListener("mouseout", onOut);
return () => {
document.removeEventListener("mouseover", onOver);
document.removeEventListener("mouseout", onOut);
};
}, [container]);
if (!pop) return null;
return (
<div className="pop" style={{ left: pop.x, top: pop.y }} role="tooltip">
<div className="pop-t">
{pop.status === "proposed" ? "proposed issue term" : `sourced · ${pop.status}`}
</div>
<div style={{ fontWeight: 500 }}>{pop.source}</div>
<div style={{ color: "#93a5b8", marginTop: 2 }}>{pop.locator}</div>
{pop.raw && <div className="pop-raw">{pop.raw}</div>}
</div>
);
}
/** Provenance rendered as a list, used in the ledger drawer. */
export function ProvenanceList({ provenance }: { provenance: Provenance[] }) {
return (
<div style={{ display: "grid", gap: 6 }}>
{provenance.map((p, i) => (
<div key={i} style={{ fontSize: 11.5, lineHeight: 1.5 }}>
<div style={{ fontWeight: 500 }}>{p.source_title}</div>
<div className="f-src">{p.locator}</div>
{p.raw_line && <div className="ev-raw">{p.raw_line}</div>}
</div>
))}
</div>
);
}
export function StatusChip({ status }: { status: string }) {
return <span className={`chip chip-${status}`}>{status}</span>;
}
export function factTone(f: Fact): string {
return f.status;
}
export { Info };
|