SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
3.0 KB · 81 lines tsx
Raw Blame History
1import type { SourceHistoryItem } from "@dci/core";2import { ExternalLink } from "lucide-react";3import type { ReactNode } from "react";4import { fmtDate, fmtValue } from "@/lib/format";5import { SOURCE_KIND_LABEL } from "@/lib/labels";6import { cn } from "./cn";78export interface TimelineEntry {9  date: string;10  title: ReactNode;11  description?: ReactNode;12  meta?: ReactNode;13  href?: string | null;14  tone?: "neutral" | "accent" | "ok" | "warn" | "danger";15}1617const toneDot: Record<NonNullable<TimelineEntry["tone"]>, string> = {18  neutral: "bg-ink-4",19  accent: "bg-accent",20  ok: "bg-ok",21  warn: "bg-warn",22  danger: "bg-danger",23};2425/** Vertical timeline with hairline rail; dates in mono. */26export function TimelineList({ items, className, dense }: { items: TimelineEntry[]; className?: string; dense?: boolean }) {27  return (28    <ol className={cn("relative ml-1.5 border-l border-hair", className)}>29      {items.map((it, i) => (30        <li key={i} className={cn("relative pl-4", dense ? "pb-2.5" : "pb-4", "last:pb-0")}>31          <span aria-hidden className={cn("absolute -left-[4.5px] top-[6px] size-2 rounded-full ring-2 ring-surface", toneDot[it.tone ?? "neutral"])} />32          <div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">33            <time className="figure text-[11.5px] text-ink-3" dateTime={it.date}>34              {fmtDate(it.date)}35            </time>36            <span className="min-w-0 break-words text-[13px] font-medium text-ink">{it.title}</span>37          </div>38          {it.description && <div className="mt-0.5 text-[12.5px] text-ink-2">{it.description}</div>}39          {(it.meta || it.href) && (40            <div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11.5px] text-ink-3">41              {it.meta}42              {it.href && (43                <a href={it.href} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 hover:text-accent">44                  source <ExternalLink className="size-3" />45                </a>46              )}47            </div>48          )}49        </li>50      ))}51    </ol>52  );53}5455/** Adapter for SourceHistoryItem[] (entity pages). */56export function SourceHistory({ items, className }: { items: SourceHistoryItem[]; className?: string }) {57  const entries: TimelineEntry[] = items.map((s) => ({58    date: s.date,59    title: prettyDescription(s.description),60    meta: (61      <span>62        {s.sourceName} · {SOURCE_KIND_LABEL[s.sourceKind]} · {s.kind}63      </span>64    ),65    href: s.url,66    tone: s.kind === "changed" ? "accent" : s.kind === "verified" ? "ok" : "neutral",67  }));68  return <TimelineList items={entries} className={className} dense />;69}7071/** "field observed: {json}" → "field observed: human value" (structured payloads are otherwise one unbreakable token). */72function prettyDescription(d: string): string {73  const m = /^(.*?:\s*)([[{].*[\]}])\s*$/s.exec(d);74  if (!m) return d;75  try {76    return `${m[1]}${fmtValue(JSON.parse(m[2]!))}`;77  } catch {78    return d;79  }80}81