import { Archive, ExternalLink, FileText, Globe, MessageSquareQuote, Mic, Play, Video } from "lucide-react";
import type { CitationKind } from "../../lib/citations/schema";
import { ICON_PALETTES } from "../../lib/brand";
import { BrandMark } from "../BrandMark";
import {
CITATION_KIND_LABELS,
sourceAnchor,
spanLabel,
type CitationView,
type PageCitationView,
type PostCitationView,
type SourceCitationView,
type SpanCitationView,
} from "../../lib/report/views";
import { cn } from "../../lib/utils";
import { VerificationBadge } from "./VerificationBadge";
// ONE CITATION, as a reader sees it — kind-aware, from a resolved view
// (lib/report/views.ts), fetching nothing:
//
// video / audio the record's title, channel, date, the span ("52:06–52:31"),
// the speaker and quote, a poster when the view has one, and
// a play affordance that opens the moment page (its clip)
// post the author, date, quote (and the post's text when it says
// more), the captured shot when the view has one
// source the document's sentence: its still, the quote, and
// "from
" linking to the document's block on the
// page (sourceAnchor), where its archive links are — once
// page the page's title and URL, its archived copy
//
// Three densities: `card` (a claim's evidence list), `preview` (InlineCite's
// hover/tap card: the same facts, a smaller picture, the quote clamped) and
// `reference` (a ReferenceList entry: no picture, the number is the list's).
//
// Pure/presentational: server-rendered or in a client tree alike.
export type CitationCardVariant = "card" | "preview" | "reference";
const KIND_ICON: Record = {
video: Video,
audio: Mic,
post: MessageSquareQuote,
source: FileText,
page: Globe,
};
const linkClass =
"inline-flex items-center gap-1 text-brand underline decoration-brand/40 underline-offset-2 hover:decoration-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm";
function hostOf(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url;
}
}
function ExternalA({ href, children, title }: { href: string; children: React.ReactNode; title?: string }) {
return (
{children}
);
}
// The title line and its meta, per kind.
export function citationHeading(c: CitationView): { title: string; meta: string[] } {
switch (c.kind) {
case "video":
case "audio":
return {
title: c.record.title ?? `${c.record.channel}/${c.record.id}`,
meta: [c.record.channelTitle ?? c.record.channel, c.date ?? c.record.date, spanLabel(c.start, c.end)].filter(
(x): x is string => !!x,
),
};
case "post":
return {
title: c.author ?? c.record.channelTitle ?? c.record.channel,
meta: [c.author ? (c.record.channelTitle ?? c.record.channel) : undefined, c.date ?? c.record.date].filter(
(x): x is string => !!x,
),
};
case "source":
return {
title: c.sourceTitle,
meta: [c.date].filter((x): x is string => !!x),
};
case "page":
return { title: c.title ?? hostOf(c.href), meta: [hostOf(c.href), c.date].filter((x): x is string => !!x) };
}
}
function Picture({ c, variant }: { c: CitationView; variant: CitationCardVariant }) {
if (variant === "reference") return null;
const size = variant === "preview" ? "max-h-28" : "max-h-48";
if (c.kind === "video" || c.kind === "audio") {
if (!c.poster) return null;
return (
Play {spanLabel(c.start, c.end)}
);
}
if (c.kind === "post" && c.shot) {
return (
);
}
if (c.kind === "source" && c.image) {
return (
);
}
return null;
}
function SpanLinks({ c }: { c: SpanCitationView }) {
return (
<>
Play {spanLabel(c.start, c.end)}
{c.record.corpusUrl && (
Transcript
)}
>
);
}
// The record's original, then where it can be downloaded to check it
// ("YouTube · archive.org · torrent" for an archive.org mirror).
function RecordLinks({ r }: { r: SpanCitationView["record"] }) {
return (
<>
{r.originalUrl && {r.originalLabel ?? "Original"}}
{r.downloads?.map((d) => (
{d.label}
))}
>
);
}
function PostLinks({ c }: { c: PostCitationView }) {
return (
<>
Open post
{c.record.originalUrl && Original}
>
);
}
function SourceLinks({ c }: { c: SourceCitationView }) {
return (
from {c.sourceTitle}
);
}
function PageLinks({ c }: { c: PageCitationView }) {
return (
<>
{hostOf(c.href)}
{c.archiveUrl && (
Archived copy
)}
>
);
}
// Evidence the report's author found — the document under review did not give
// it (`origin: "added"`): the project's mark, never words on screen (the
// words, e.g. "Not in the article", are its tooltip and what a screen reader
// says). A card wears it under its number box, as wide as the box (with no
// number, as wide as a one-digit box); a reference entry or a preview, inline
// after the kind. The claim's flag pill wears the same mark.
export function AddedMark({ className }: { className?: string }) {
return ;
}
export function CitationCard({
citation: c,
variant = "card",
className,
addedLabel = "Not in the source",
}: {
citation: CitationView;
variant?: CitationCardVariant;
className?: string;
// The words for added evidence: "Not in the article", "Not in the source".
addedLabel?: string;
}) {
const added = c.origin === "added";
const Icon = KIND_ICON[c.kind];
const { title, meta } = citationHeading(c);
const showNumber = variant !== "reference" && c.number !== undefined;
const markUnder = added && variant === "card";
const postText = c.kind === "post" && c.text && c.text.trim() !== c.quote.trim() ? c.text : null;
return (
{(showNumber || markUnder) && (
{showNumber && (
{c.number}
)}
{markUnder && (
{addedLabel}
)}
)}
{CITATION_KIND_LABELS[c.kind]}
{added && !markUnder && (
{addedLabel}
)}
{c.label &&
· {c.label}}
{title}
{meta.length > 0 &&
{meta.join(" · ")}
}
{c.speaker && {c.speaker}: }
{c.quote}
{postText && variant !== "preview" && (
{postText}
)}
{c.note && variant !== "preview" &&
{c.note}
}
{(c.kind === "video" || c.kind === "audio") &&
}
{c.kind === "post" &&
}
{c.kind === "source" &&
}
{c.kind === "page" &&
}
);
}