/** * RecordPreview Component * Displays a preview of a generic ATProto record */ 'use client'; import { createContext, useContext, useEffect, useState } from 'react'; import Link from 'next/link'; import { GenericRecord } from '@/utils/recordFetcher'; import { sanitizeHandle, sanitizeUrl } from '@/utils/sanitize'; import { Check, ChevronDown, ChevronRight, Copy, Telescope } from 'lucide-react'; import { encodeRepo, explorePathFromAtUri } from '@/utils/atproto/urls'; import { resolveDidHandle } from '@/utils/atproto/identity'; import RecordImageThumb from '@/components/RecordImageThumb'; import { didFromAtUri, getBlobUrl, imageBlobFromValue, imageUrlFromValue, } from '@/utils/recordImages'; type RecordPreviewProps = { record: GenericRecord; collection: string; handle: string; rkey: string; /** * When true, suppress the "View Full Record" JSON-modal button and the * "Open in Explorer" cross-link. Used inside the explorer's record view, * which already shows the raw (linkified) JSON inline and is itself the * Explorer destination. */ hideExplorerCtas?: boolean; /** * Optional action UI rendered in the card's footer next to the CID * (e.g. the explorer's "Edit record" button). Only shown when * hideExplorerCtas is true — the universal-link version keeps a * cleaner footer with just the CID. Passing this turns the card's * bottom strip into a flex row that fits the CID on the left and * the actions on the right. */ footerActions?: import('react').ReactNode; /** * Owning repo's PDS endpoint. When present, blob-backed image fields render * an inline thumbnail (served via `com.atproto.sync.getBlob`); without it, * only direct image URLs preview. The explorer passes it; the universal-link * page omits it (it doesn't resolve the PDS), so blob thumbnails are * explorer-only for now. */ pds?: string; }; /** * Supplies the repo coordinates a blob image needs to build its getBlob URL. * Read by the recursive FieldRow so we don't thread `pds`/`did` through every * nested level by hand. `did` comes from the record URI; both are optional so * blob previews simply no-op when either is missing. */ const RecordImageContext = createContext<{ pds?: string; did: string | null }>({ did: null, }); export default function RecordPreview({ record, collection, handle, rkey, hideExplorerCtas, footerActions, pds, }: RecordPreviewProps) { const { value, cid } = record; const did = didFromAtUri(record.uri); // Format the record type nicely const recordType = value.$type || collection; const displayType = recordType.replace('app.bsky.', '').replace('com.atproto.', '').replace('net.anisota.', ''); // Show every field in the record value — including $type, createdAt, and // updatedAt. The preview is meant to be a faithful, readable rendering of // the record (and, inside the explorer, the rich counterpart to the raw // JSON view), so dropping fields would make the two disagree and hide real // data. Universal-link pages still cap the count for a compact teaser. const allFields = Object.entries(value); const previewFields = hideExplorerCtas ? allFields : allFields.slice(0, 6); const hasMoreFields = !hideExplorerCtas && allFields.length > 6; // Format date if available const createdAt = value.createdAt ? new Date(value.createdAt) : null; const formattedDate = createdAt ? createdAt.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', }) : null; return (
{/* Header: URI Structure. Suppressed inside the explorer, where the breadcrumb already shows the at:// segments and the collection chip — repeating them here is the redundancy this prop targets. */} {!hideExplorerCtas && (
{/* AT URI Path */}
at:// { e.currentTarget.style.opacity = '0.7'; }} onMouseLeave={(e) => { e.currentTarget.style.opacity = '1'; }} > {handle} / {collection} / {rkey}
{/* Record Type Badge */}
{displayType}
{formattedDate && (
{formattedDate}
)}
)} {/* Preview Fields. Each row is a stateful FieldRow — tapping an object/array chip slides a full-width panel down BELOW the row (not into the right column), so nested content gets the full container width instead of compounding indentation. */}
{/* container-query host: field rows below read this wrapper's width (not the viewport's) to decide whether the value sits beside its label or drops onto its own line — see .record-fields in globals.css. Lets the card stack correctly inside narrow split panes, not just on small screens. */}
{previewFields.map(([key, val]) => ( // Inside the explorer (hideExplorerCtas) this card IS the // canonical record view, so top-level fields start expanded — // the visitor came here to read the record, not to click each // object/array chip open. Nested rows stay collapsed so deep // structures don't unfurl all at once. On universal-link pages // the preview stays compact (everything collapsed). ))}
{/* Single CTA: navigates into the explorer's record page, which shows the full record JSON (linkified), backlinks, identity, and editing affordances. Replaces the previous \"View Full Record\" modal + secondary \"Open in Explorer\" link — one click instead of two surfaces. Hidden when we're already rendering inside the explorer. */} {!hideExplorerCtas && ( { e.currentTarget.style.background = 'var(--bg-elevated)'; e.currentTarget.style.borderColor = 'var(--text-accent)'; e.currentTarget.style.transform = 'translateY(-2px)'; }} onMouseLeave={(e) => { e.currentTarget.style.background = 'var(--bg-tertiary)'; e.currentTarget.style.borderColor = 'var(--border-medium)'; e.currentTarget.style.transform = 'translateY(0)'; }} > {hasMoreFields ? `View full record (${allFields.length} fields) in Explorer →` : 'View full record in Explorer →'} )} {/* Quick action: grab the raw record JSON without leaving the universal link page. Sits beneath the primary CTA as a quieter secondary affordance. Hidden inside the explorer — the consolidated copy row already covers JSON. */} {!hideExplorerCtas && }
{/* Footer: CID + caller-supplied action slot. Universal-link pages get just the CID display; the explorer passes its Edit button in via footerActions so editing affordances live with the record they apply to. */} {(cid || footerActions) && (
{cid ? ( CID: ) : ( )} {footerActions && ( {footerActions} )}
)}
); } async function writeToClipboard(text: string): Promise { try { await navigator.clipboard.writeText(text); return; } catch { /* fall through */ } const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } finally { document.body.removeChild(ta); } } /** * A single field row: `[LABEL] [value-or-expand-chip]` on top, and — when * the value is an object/array and the chip is open — a full-width panel * below the row containing nested FieldRows. * * The expansion lives BELOW the row rather than inside the right column, * so deeply-nested data gets the parent container's full width instead of * compounding indentation. The slide-down uses CSS grid-template-rows * (0fr -> 1fr), which animates height changes without needing a fixed * max-height ceiling. */ function FieldRow({ label, value, isLast, defaultOpen = false, suppressBlobImage = false, }: { label: string; value: unknown; /** When true, the row's separator is suppressed — used for the last * child inside an expansion so it doesn't double up against the parent * row's bottom border. */ isLast?: boolean; /** Initial expansion state. Top-level rows in the explorer pass true so * the record opens already unfurled; nested rows default to collapsed. */ defaultOpen?: boolean; /** When true, don't render a blob thumbnail for this row — set by the parent * when a sibling field already previews the same image as a direct URL (e.g. * arena mirror's `image.src` next to `image.blob`), so we show it once. */ suppressBlobImage?: boolean; }) { const [open, setOpen] = useState(defaultOpen); const { pds, did } = useContext(RecordImageContext); const expandable = isExpandable(value); const children = expandable ? childEntries(value) : []; // A sibling direct-URL image means this object's blob is (almost always) the // same asset; let the URL row own the single preview. const childrenHaveImageUrl = children.some( ([, v]) => imageUrlFromValue(v) != null, ); // Blob-backed image preview (needs the repo coordinates to build getBlob). const blob = suppressBlobImage ? null : imageBlobFromValue(value); const blobSrc = blob && pds && did ? getBlobUrl(pds, did, blob.cid) : null; return (
{label}
{expandable ? ( ) : ( )}
{blobSrc && (
)} {expandable && (
{/* paddingBottom lives on the overflow wrapper, not on the bordered indent below — so the left vertical line ends at the last child's content instead of running past it down to the parent row's separator. */}
{children.map(([k, v], i) => ( ))}
)}
); } function FieldPrimitive({ value }: { value: unknown }) { if (value === null) { return null; } const full = typeof value === 'string' ? value : String(value); const display = typeof value === 'string' && value.length > 280 ? `${value.substring(0, 280)}…` : full; // AT URI / DID values in record fields are cross-references (e.g. // gallery.item -> photo, follow.subject -> repo). Click-to-copy is the // wrong default — users want to follow the link into the explorer. The // copy affordance moves onto the trailing clipboard icon. if (typeof value === 'string') { if (isDid(value)) { return ; } const href = explorePathFromAtUri(value); if (href && value.startsWith('at://')) { return ; } // Direct image URL: keep the copyable text and preview it beneath. The // URL still renders as a plain external string (like other non-AT URLs); // the thumbnail is the added affordance. const imageUrl = imageUrlFromValue(value); if (imageUrl) { const safe = sanitizeUrl(imageUrl); if (safe !== '#') { return ( <> ); } } } return ; } const DID_RE = /^did:[a-z]+:[A-Za-z0-9._:%-]+$/; function isDid(v: string): boolean { return DID_RE.test(v); } /** * Resolve a DID to its handle (cached/de-duped), re-running whenever the * DID changes. Returns null until resolved, or when the DID has no handle. */ function useDidHandle(did: string): string | null { // Keyed by DID: when it changes, the derived return value below is null // until the new resolution lands — no reset-setState inside the effect. const [entry, setEntry] = useState<{ did: string; handle: string | null } | null>(null); useEffect(() => { let cancelled = false; resolveDidHandle(did).then((h) => { if (!cancelled) setEntry({ did, handle: h }); }); return () => { cancelled = true; }; }, [did]); return entry && entry.did === did ? entry.handle : null; } /** * A DID-valued field: the DID links into its repo explorer page, the * resolved handle trails it for readability, and the clipboard icon copies * the raw DID. Delegates the link + copy chrome to LinkableValue. */ function DidValue({ did }: { did: string }) { const handle = useDidHandle(did); return ( @{handle} ) : null } /> ); } /** * Field value that is also an AT URI: the text navigates into the * explorer, the trailing clipboard icon copies the raw value. Mirrors * CopyableValue's visual treatment so non-AT-URI rows still feel * consistent next to it. */ function LinkableValue({ display, copy, href, suffix, linkTitle = 'Open in Explorer', copyLabel = 'Copy to clipboard', }: { display: string; copy: string; href: string; /** Optional trailing node (e.g. a resolved handle) shown after the link. */ suffix?: import('react').ReactNode; linkTitle?: string; copyLabel?: string; }) { const [copied, setCopied] = useState(false); async function onCopy(e: React.MouseEvent) { e.preventDefault(); e.stopPropagation(); await writeToClipboard(copy); setCopied(true); window.setTimeout(() => setCopied(false), 1400); } return ( {display} {suffix} ); } /** * Tap-anywhere-to-copy wrapper for a primitive field value. Renders the * text inline with a faint copy icon trailing it; clicking copies the * full (untruncated) value to the clipboard and flashes a check mark. */ function CopyableValue({ display, copy }: { display: string; copy: string }) { const [copied, setCopied] = useState(false); async function onClick() { await writeToClipboard(copy); setCopied(true); window.setTimeout(() => setCopied(false), 1400); } function onKeyDown(e: React.KeyboardEvent) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(); } } return (
{display} {copied ? : }
); } function isExpandable(v: unknown): boolean { return typeof v === 'object' && v !== null; } function summarizeContainer(v: unknown): string { if (Array.isArray(v)) { return v.length === 0 ? '[ ]' : `[${v.length} ${v.length === 1 ? 'item' : 'items'}]`; } if (typeof v === 'object' && v !== null) { const n = Object.keys(v).length; return n === 0 ? '{ }' : `{${n} ${n === 1 ? 'field' : 'fields'}}`; } return ''; } function childEntries(v: unknown): [string, unknown][] { if (Array.isArray(v)) return v.map((item, i) => [String(i), item]); if (typeof v === 'object' && v !== null) { return Object.entries(v as Record); } return []; } const fieldLabelStyle: React.CSSProperties = { // Width lives in .field-row-label (globals.css), not here — a container // query collapses it to 0 when the card is narrow so the value can drop // onto its own full-width line. An inline minWidth would override that. fontSize: '0.75rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-tertiary)', paddingTop: '0.125rem', fontFamily: 'var(--font-mono)', wordBreak: 'break-all', }; const fieldChipStyle: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', gap: '0.4rem', padding: '0.3rem 0.625rem', background: 'var(--bg-tertiary)', border: '1px solid var(--border-subtle)', color: 'var(--text-accent)', fontFamily: 'var(--font-mono)', fontSize: '0.85rem', cursor: 'pointer', userSelect: 'none', }; function CopyJsonRow({ record }: { record: GenericRecord }) { const [copied, setCopied] = useState(false); async function onClick() { await writeToClipboard(JSON.stringify(record, null, 2)); setCopied(true); window.setTimeout(() => setCopied(false), 1600); } return (
); }