disclosure-bureau/web/components/fm/badges.tsx

253 lines
11 KiB
TypeScript
Raw Permalink Normal View History

/**
* Semantic badges for enum-valued frontmatter fields.
* - ConfidenceBand: high/medium/low/speculation
* - ClassificationLevel: UNCLASSIFIED/CUI/CONFIDENTIAL/SECRET/TOP SECRET (+caveats)
* - EnrichmentStatus: deep/shallow/none
* - ContentClass: 11 content_classification enum values
* - PageType: cover/body/signature/... 14 types
* - Generic chip with optional icon
*/
import {
Eye, EyeOff, FileText, FileImage, FileWarning, ShieldAlert, ShieldCheck,
Check, Clock, AlertTriangle, Stamp, PenSquare, Table2, Map as MapIcon,
Image as ImageIcon, Calendar, MapPin, Tag, Network, FileSearch, Globe,
} from "lucide-react";
import type {
ConfidenceBand, ClassificationLevel, EnrichmentStatus, ContentClass,
} from "@/lib/fm-types";
/* ── ConfidenceBand ─────────────────────────────────────────── */
const CONF_STYLES: Record<ConfidenceBand, string> = {
high: "bg-[rgba(0,255,156,0.12)] text-[#00ff9c] border-[#00ff9c]",
medium: "bg-[rgba(127,219,255,0.10)] text-[#7fdbff] border-[#7fdbff]",
low: "bg-[rgba(245,197,66,0.10)] text-[#f5c542] border-[#f5c542]",
speculation: "bg-[rgba(187,107,217,0.10)] text-[#bb6bd9] border-[#bb6bd9]",
};
export function FmConfidence({ band }: { band?: ConfidenceBand }) {
if (!band) return null;
return (
<span className={`inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-widest px-1.5 py-0.5 rounded border ${CONF_STYLES[band]}`}>
{band}
</span>
);
}
/* ── ClassificationLevel ────────────────────────────────────── */
const CLASS_STYLES: Record<ClassificationLevel, string> = {
"UNCLASSIFIED": "bg-[rgba(63,222,106,0.10)] text-[#3fde6a] border-[#3fde6a]",
"CUI": "bg-[rgba(127,219,255,0.10)] text-[#7fdbff] border-[#7fdbff]",
"CONFIDENTIAL": "bg-[rgba(245,197,66,0.10)] text-[#f5c542] border-[#f5c542]",
"SECRET": "bg-[rgba(255,138,77,0.12)] text-[#ff8a4d] border-[#ff8a4d]",
"TOP SECRET": "bg-[rgba(255,51,68,0.15)] text-[#ff3344] border-[#ff3344] font-bold",
};
export function FmClassification({ level, caveats }: { level?: ClassificationLevel; caveats?: string[] }) {
if (!level) return null;
return (
<span className={`inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-widest px-1.5 py-0.5 rounded border ${CLASS_STYLES[level] ?? CLASS_STYLES.UNCLASSIFIED}`}>
<ShieldAlert size={10} /> {level}
{caveats && caveats.length > 0 && <span> // {caveats.join(", ")}</span>}
</span>
);
}
/* ── EnrichmentStatus ──────────────────────────────────────── */
const ENR_STYLES: Record<EnrichmentStatus, { cls: string; icon: React.ReactNode }> = {
deep: { cls: "bg-[rgba(0,255,156,0.12)] text-[#00ff9c] border-[#00ff9c]", icon: <ShieldCheck size={10} /> },
shallow: { cls: "bg-[rgba(127,219,255,0.10)] text-[#7fdbff] border-[#7fdbff]", icon: <Eye size={10} /> },
none: { cls: "bg-[rgba(90,102,120,0.10)] text-[#5a6678] border-[#5a6678]", icon: <EyeOff size={10} /> },
};
export function FmEnrichmentBadge({ status }: { status?: EnrichmentStatus }) {
if (!status) return null;
const s = ENR_STYLES[status];
return (
<span className={`inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-widest px-1.5 py-0.5 rounded border ${s.cls}`}>
{s.icon} {status}
</span>
);
}
/* ── ContentClass ──────────────────────────────────────────── */
const CONTENT_ICON: Record<ContentClass, React.ReactNode> = {
"text-only": <FileText size={10} />,
"contains-photos": <ImageIcon size={10} />,
"contains-sketches": <PenSquare size={10} />,
"contains-diagrams": <Network size={10} />,
"contains-maps": <MapIcon size={10} />,
"contains-tables": <Table2 size={10} />,
"contains-signatures":<PenSquare size={10} />,
"contains-stamps": <Stamp size={10} />,
"redaction-heavy": <FileWarning size={10} />,
"mixed": <Tag size={10} />,
"blank": <FileText size={10} />,
};
export function FmContentChip({ kind }: { kind: ContentClass }) {
const icon = CONTENT_ICON[kind] ?? <Tag size={10} />;
return (
<span className="inline-flex items-center gap-1 font-mono text-[10px] tracking-wide bg-[rgba(127,219,255,0.06)] text-[#7fdbff] border border-[rgba(127,219,255,0.32)] rounded px-1.5 py-0.5">
{icon} {kind}
</span>
);
}
/* ── PageType ──────────────────────────────────────────────── */
export function FmPageTypeChip({ type }: { type?: string }) {
if (!type) return null;
const accent =
type === "redaction-heavy" ? "text-[#ff3344] border-[#ff3344]" :
type === "signature" ? "text-[#bb6bd9] border-[#bb6bd9]" :
type === "table-page" ? "text-[#1e9eb5] border-[#1e9eb5]" :
type === "map" ? "text-[#3fde6a] border-[#3fde6a]" :
type === "photo" ? "text-[#ffeb99] border-[#ffeb99]" :
type === "sketch" ? "text-[#ff8a4d] border-[#ff8a4d]" :
type === "cover" ? "text-[#f5c542] border-[#f5c542]" :
type === "blank" ? "text-[#5a6678] border-[#5a6678]" :
"text-[#7fdbff] border-[#7fdbff]";
return (
<span className={`inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-widest border rounded px-1.5 py-0.5 ${accent}`}>
<FileSearch size={10} /> {type}
</span>
);
}
/* ── Generic chips ─────────────────────────────────────────── */
export function FmChip({ icon, label, color = "cyan" }: {
icon?: React.ReactNode;
label: React.ReactNode;
color?: "cyan" | "amber" | "green" | "red" | "violet" | "soft";
}) {
const cls =
color === "amber" ? "text-[#f5c542] border-[rgba(245,197,66,0.32)] bg-[rgba(245,197,66,0.06)]" :
color === "green" ? "text-[#3fde6a] border-[rgba(63,222,106,0.32)] bg-[rgba(63,222,106,0.06)]" :
color === "red" ? "text-[#ff3344] border-[rgba(255,51,68,0.32)] bg-[rgba(255,51,68,0.06)]" :
color === "violet" ? "text-[#bb6bd9] border-[rgba(187,107,217,0.32)] bg-[rgba(187,107,217,0.06)]" :
color === "soft" ? "text-[#8896aa] border-[rgba(136,150,170,0.32)] bg-[rgba(136,150,170,0.04)]" :
"text-[#7fdbff] border-[rgba(127,219,255,0.32)] bg-[rgba(127,219,255,0.06)]";
return (
<span className={`inline-flex items-center gap-1 font-mono text-[10px] tracking-wide border rounded px-1.5 py-0.5 ${cls}`}>
{icon}
<span>{label}</span>
</span>
);
}
export function FmStat({ icon, label, value, color = "cyan" }: {
icon?: React.ReactNode;
label: string;
value: React.ReactNode;
color?: "cyan" | "amber" | "green" | "red" | "violet" | "soft";
}) {
const c =
color === "amber" ? "text-[#f5c542]" :
color === "green" ? "text-[#3fde6a]" :
color === "red" ? "text-[#ff3344]" :
color === "violet" ? "text-[#bb6bd9]" :
color === "soft" ? "text-[#8896aa]" :
"text-[#7fdbff]";
return (
<div className="inline-flex flex-col items-start gap-0.5 px-2.5 py-1.5 border border-[rgba(0,255,156,0.12)] bg-[#060a13] rounded">
<span className="font-mono text-[9px] uppercase tracking-widest text-[#5a6678] inline-flex items-center gap-1">
{icon} {label}
</span>
<span className={`font-mono text-sm font-semibold ${c}`}>{value}</span>
</div>
);
}
/* ── Language code ─────────────────────────────────────────── */
const LANG_FLAG: Record<string, string> = {
en: "🇬🇧", pt: "🇧🇷", es: "🇪🇸", fr: "🇫🇷", de: "🇩🇪", ru: "🇷🇺", unknown: "❓",
};
export function FmLanguageChip({ code }: { code?: string }) {
if (!code) return null;
return (
<span className="inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-widest text-[#8896aa] border border-[rgba(136,150,170,0.32)] rounded px-1.5 py-0.5">
<Globe size={10} /> {LANG_FLAG[code] ?? "🌐"} {code}
</span>
);
}
/* ── Date ──────────────────────────────────────────────────── */
export function FmDate({ value }: { value?: string }) {
if (!value) return null;
return (
<span className="inline-flex items-center gap-1 font-mono text-[10px] text-[#8896aa]">
<Calendar size={10} /> {value}
</span>
);
}
/* ── Coordinates ───────────────────────────────────────────── */
export function FmCoordinates({ lat, lon, raw }: { lat?: number | null; lon?: number | null; raw?: string }) {
const ll = (lat !== null && lat !== undefined && lon !== null && lon !== undefined)
? `${lat.toFixed(5)}, ${lon.toFixed(5)}`
: raw;
if (!ll) return null;
const href = (lat !== null && lat !== undefined && lon !== null && lon !== undefined)
? `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=8/${lat}/${lon}`
: undefined;
const inner = (
<span className="inline-flex items-center gap-1 font-mono text-[10px] text-[#3fde6a] border border-[rgba(63,222,106,0.32)] bg-[rgba(63,222,106,0.06)] rounded px-1.5 py-0.5">
<MapPin size={10} /> {ll}
</span>
);
return href
? <a href={href} target="_blank" rel="noopener noreferrer" className="no-underline">{inner}</a>
: inner;
}
/* ── Quality dot (0..1) ────────────────────────────────────── */
export function FmQualityDot({ value, label }: { value?: number; label?: string }) {
if (value === undefined || value === null) return null;
const pct = Math.round(value * 100);
const c =
pct >= 90 ? "text-[#00ff9c]" :
pct >= 75 ? "text-[#7fdbff]" :
pct >= 50 ? "text-[#f5c542]" :
"text-[#ff3344]";
return (
<span className={`inline-flex items-center gap-1 font-mono text-[10px] ${c}`}>
{label && <span className="text-[#5a6678]">{label}</span>}
<Check size={10} /> {pct}%
</span>
);
}
/* ── Generic flag chip ─────────────────────────────────────── */
export function FmFlag({ flag }: { flag: string }) {
const isWarn = /low|miss|fail|heavy|rotat/i.test(flag);
return (
<span className={`inline-flex items-center gap-1 font-mono text-[10px] tracking-wide rounded px-1.5 py-0.5 border
${isWarn ? "text-[#f5c542] border-[rgba(245,197,66,0.32)] bg-[rgba(245,197,66,0.06)]"
: "text-[#5a6678] border-[rgba(136,150,170,0.32)]"}`}>
<AlertTriangle size={10} /> {flag}
</span>
);
}
export function FmTimestamp({ value, label }: { value?: string; label?: string }) {
if (!value) return null;
return (
<span className="inline-flex items-center gap-1 font-mono text-[9px] text-[#5a6678]">
<Clock size={9} />
{label && <span>{label}:</span>}
{value.replace("T", " ").replace("Z", " UTC")}
</span>
);
}