This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import type { FactBand, FactStatus } from '@pig/core';
|
||||
import { ExternalLink, Link2, ScanSearch } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Badge } from '@/components/ui';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SourcedFact {
|
||||
id: string;
|
||||
field: string;
|
||||
value: string;
|
||||
score: string | number;
|
||||
band: FactBand;
|
||||
status: FactStatus;
|
||||
evidence: Record<string, unknown> | null;
|
||||
sourceUrl: string | null;
|
||||
method: string | null;
|
||||
observedAt: string;
|
||||
}
|
||||
|
||||
interface SourcedValueProps {
|
||||
value: ReactNode;
|
||||
fact: SourcedFact;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const BAND_TONE = {
|
||||
verified: 'positive',
|
||||
probable: 'info',
|
||||
possible: 'warning',
|
||||
} as const;
|
||||
|
||||
function safeSourceUrl(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function evidenceSummary(evidence: Record<string, unknown> | null): string | null {
|
||||
if (!evidence) return null;
|
||||
for (const key of ['excerpt', 'quote', 'summary', 'snippet', 'reason']) {
|
||||
const value = evidence[key];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
}
|
||||
const firstText = Object.values(evidence).find(
|
||||
(value): value is string => typeof value === 'string' && Boolean(value.trim()),
|
||||
);
|
||||
return firstText?.trim() ?? null;
|
||||
}
|
||||
|
||||
function humanise(value: string): string {
|
||||
return value.replaceAll('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
export function SourcedValue({ value, fact, className }: SourcedValueProps) {
|
||||
const sourceUrl = safeSourceUrl(fact.sourceUrl);
|
||||
const summary = evidenceSummary(fact.evidence);
|
||||
const score = Number(fact.score);
|
||||
const confidence = Number.isFinite(score) ? `${Math.round(score * 100)}%` : 'Not scored';
|
||||
|
||||
return (
|
||||
<span className={cn('inline-flex min-w-0 items-center gap-1.5', className)}>
|
||||
<span className="min-w-0 break-words font-medium text-fg">{value}</span>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="tap -m-2 inline-flex shrink-0 items-center justify-center rounded-md p-2 text-accent-fg hover:bg-accent-subtle"
|
||||
aria-label={`View evidence for ${fact.field}`}
|
||||
>
|
||||
<Link2 className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[min(22rem,calc(100vw-2rem))] border-border bg-surface text-fg"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted">
|
||||
{humanise(fact.field)}
|
||||
</p>
|
||||
<p className="mt-1 break-words text-sm font-medium">{fact.value}</p>
|
||||
</div>
|
||||
<Badge tone={BAND_TONE[fact.band]}>{confidence}</Badge>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex gap-2 text-sm">
|
||||
<ScanSearch className="mt-0.5 size-4 shrink-0 text-muted" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">Evidence</p>
|
||||
<p className="mt-0.5 break-words text-muted">
|
||||
{summary ?? 'No evidence excerpt was recorded.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted">
|
||||
<Badge tone="neutral">{humanise(fact.band)}</Badge>
|
||||
<Badge tone="neutral">{humanise(fact.status)}</Badge>
|
||||
{fact.method ? <span>via {humanise(fact.method)}</span> : null}
|
||||
</div>
|
||||
{sourceUrl ? (
|
||||
<a
|
||||
href={sourceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex min-h-11 items-center gap-2 break-all text-sm font-medium text-accent-fg hover:underline"
|
||||
>
|
||||
Open source
|
||||
<ExternalLink className="size-3.5 shrink-0" aria-hidden />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user