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<CitationKind, typeof Video> = { 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 ( <a href={href} target="_blank" rel="noopener noreferrer" title={title} className={linkClass}> {children} <ExternalLink className="size-3 shrink-0" aria-hidden /> </a> ); } // 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 ( <a href={c.href} className="group relative block overflow-hidden rounded-md border border-border bg-muted"> <img src={c.poster} alt="" loading="lazy" className={cn("w-full object-cover", size)} /> <span className="absolute inset-0 flex items-center justify-center bg-black/10 transition-colors group-hover:bg-black/25"> <span className="flex size-10 items-center justify-center rounded-full bg-background/90 text-foreground shadow"> <Play className="size-4 translate-x-px" aria-hidden /> </span> </span> <span className="sr-only">Play {spanLabel(c.start, c.end)}</span> </a> ); } if (c.kind === "post" && c.shot) { return ( <img src={c.shot} alt={`Screenshot of the post${c.author ? ` by ${c.author}` : ""}`} loading="lazy" className={cn("w-full rounded-md border border-border object-contain object-left-top", size)} /> ); } if (c.kind === "source" && c.image) { return ( <img src={c.image} alt={`The sentence as ${c.sourceTitle} shows it: ${c.quote}`} loading="lazy" className={cn("w-full rounded-md border border-border bg-white object-contain object-left-top", size)} /> ); } return null; } function SpanLinks({ c }: { c: SpanCitationView }) { return ( <> <a href={c.href} className={linkClass}> <Play className="size-3 shrink-0" aria-hidden /> Play {spanLabel(c.start, c.end)} </a> <RecordLinks r={c.record} /> {c.record.corpusUrl && ( <a href={c.record.corpusUrl} className={linkClass}> Transcript </a> )} </> ); } // 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 && <ExternalA href={r.originalUrl}>{r.originalLabel ?? "Original"}</ExternalA>} {r.downloads?.map((d) => ( <ExternalA key={d.url} href={d.url}> {d.label} </ExternalA> ))} </> ); } function PostLinks({ c }: { c: PostCitationView }) { return ( <> <a href={c.href} className={linkClass}> Open post </a> {c.record.originalUrl && <ExternalA href={c.record.originalUrl}>Original</ExternalA>} </> ); } function SourceLinks({ c }: { c: SourceCitationView }) { return ( <a href={`#${sourceAnchor(c.sourceId)}`} className={linkClass}> from {c.sourceTitle} </a> ); } function PageLinks({ c }: { c: PageCitationView }) { return ( <> <ExternalA href={c.href}>{hostOf(c.href)}</ExternalA> {c.archiveUrl && ( <ExternalA href={c.archiveUrl}> <Archive className="size-3 shrink-0" aria-hidden /> Archived copy </ExternalA> )} </> ); } // 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 <BrandMark palette={ICON_PALETTES.archilyzer} className={cn("size-3.5 shrink-0", className)} />; } 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 ( <div data-citation={c.id} data-citation-kind={c.kind} className={cn( "flex min-w-0 flex-col gap-2 text-sm", variant === "card" && "rounded-lg border border-border bg-card p-3 sm:p-4", variant === "preview" && "gap-1.5", className, )} > <div className="flex min-w-0 items-start gap-2"> {(showNumber || markUnder) && ( <span className="mt-px inline-flex shrink-0 flex-col items-stretch gap-1"> {showNumber && ( <span className="rounded bg-brand-soft px-1.5 font-mono text-xs font-semibold leading-5 text-brand-strong"> {c.number} </span> )} {markUnder && ( <span data-citation-added="" title={addedLabel} // Sized by the number box, never sizing it: no width of its own // (the svg would bring 300px) and at least the column's. className={cn("block opacity-80", showNumber ? "w-0 min-w-full" : "w-[calc(1ch+0.75rem)] font-mono text-xs")} > <BrandMark palette={ICON_PALETTES.archilyzer} className="block h-auto w-full" /> <span className="sr-only">{addedLabel}</span> </span> )} </span> )} <div className="min-w-0 flex-1"> <div className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground"> <Icon className="size-3 shrink-0" aria-hidden /> {CITATION_KIND_LABELS[c.kind]} {added && !markUnder && ( <span data-citation-added="" title={addedLabel} className="inline-flex items-center"> <AddedMark className="size-3" /> <span className="sr-only">{addedLabel}</span> </span> )} {c.label && <span className="truncate normal-case tracking-normal">· {c.label}</span>} </div> <div className="font-medium leading-snug text-foreground break-words">{title}</div> {meta.length > 0 && <div className="font-mono text-xs text-muted-foreground">{meta.join(" · ")}</div>} </div> </div> <Picture c={c} variant={variant} /> <blockquote className={cn( "border-l-2 border-brand/50 pl-3 leading-relaxed text-foreground", variant === "preview" && "line-clamp-4", )} > {c.speaker && <span className="font-medium text-muted-foreground">{c.speaker}: </span>} <q className="italic">{c.quote}</q> </blockquote> {postText && variant !== "preview" && ( <p className="line-clamp-4 whitespace-pre-line text-muted-foreground">{postText}</p> )} {c.note && variant !== "preview" && <p className="text-xs text-muted-foreground">{c.note}</p>} <VerificationBadge verification={c.verification} /> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs"> {(c.kind === "video" || c.kind === "audio") && <SpanLinks c={c} />} {c.kind === "post" && <PostLinks c={c} />} {c.kind === "source" && <SourceLinks c={c} />} {c.kind === "page" && <PageLinks c={c} />} </div> </div> ); }