diff --git a/src/components/RecordImageThumb.tsx b/src/components/RecordImageThumb.tsx
new file mode 100644
index 0000000..af94580
--- /dev/null
+++ b/src/components/RecordImageThumb.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import { useState } from 'react';
+
+/**
+ * A bounded inline image preview for the rich JSON record view. Used for both
+ * direct image URLs and blob-backed images (the caller resolves the src).
+ *
+ * Behaviour:
+ * - Lazy-loaded and `no-referrer` — record fields can point at arbitrary
+ * third-party hosts, so we don't eagerly fetch off-screen images or leak
+ * the referrer to them.
+ * - Fails closed: a URL that isn't actually an image (or 404s) removes the
+ * element instead of leaving a broken-image glyph in the field table.
+ * - Click opens the full image in a new tab; `stopPropagation` keeps that
+ * click from tripping the surrounding copy-on-click / expand affordances.
+ */
+export default function RecordImageThumb({
+ src,
+ alt = '',
+}: {
+ src: string;
+ alt?: string;
+}) {
+ const [failed, setFailed] = useState(false);
+ if (failed) return null;
+ return (
+ e.stopPropagation()}
+ title="Open full image in a new tab"
+ style={{
+ display: 'inline-block',
+ marginTop: '0.5rem',
+ maxWidth: '100%',
+ lineHeight: 0,
+ }}
+ >
+ {/* Plain
(not next/image) so arbitrary record image hosts render
+ without being enumerated in next.config's remotePatterns. */}
+
setFailed(true)}
+ style={{
+ display: 'block',
+ maxWidth: '100%',
+ maxHeight: '320px',
+ width: 'auto',
+ height: 'auto',
+ objectFit: 'contain',
+ border: '1px solid var(--border-medium)',
+ background: 'var(--bg-tertiary)',
+ }}
+ />
+
+ );
+}
diff --git a/src/components/RecordPreview.tsx b/src/components/RecordPreview.tsx
index 7db4299..6f3bc15 100644
--- a/src/components/RecordPreview.tsx
+++ b/src/components/RecordPreview.tsx
@@ -5,13 +5,20 @@
'use client';
-import { useEffect, useState } from 'react';
+import { createContext, useContext, useEffect, useState } from 'react';
import Link from 'next/link';
import { GenericRecord } from '@/utils/recordFetcher';
-import { sanitizeHandle } from '@/utils/sanitize';
+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;
@@ -34,8 +41,26 @@ type RecordPreviewProps = {
* 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,
@@ -43,8 +68,10 @@ export default function RecordPreview({
rkey,
hideExplorerCtas,
footerActions,
+ pds,
}: RecordPreviewProps) {
const { value, cid } = record;
+ const did = didFromAtUri(record.uri);
// Format the record type nicely
const recordType = value.$type || collection;
@@ -163,22 +190,24 @@ export default function RecordPreview({
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).
-
- ))}
-
+
+
+ {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,
@@ -325,6 +354,7 @@ function FieldRow({
value,
isLast,
defaultOpen = false,
+ suppressBlobImage = false,
}: {
label: string;
value: unknown;
@@ -335,10 +365,24 @@ function FieldRow({
/** 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 (
+ {blobSrc && (
+
+
+
+ )}
{expandable && (
))}
@@ -449,6 +499,21 @@ function FieldPrimitive({ value }: { value: unknown }) {
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 ;
}
diff --git a/src/components/explore/RecordExplorer.tsx b/src/components/explore/RecordExplorer.tsx
index 048b5d2..dc14d14 100644
--- a/src/components/explore/RecordExplorer.tsx
+++ b/src/components/explore/RecordExplorer.tsx
@@ -300,6 +300,7 @@ export default function RecordExplorer({ repo, collection, rkey }: Props) {
collection={collection}
handle={identity.handle || identity.did}
rkey={decodedRkey}
+ pds={identity.pds}
hideExplorerCtas
/>
)}
diff --git a/src/utils/recordImages.ts b/src/utils/recordImages.ts
new file mode 100644
index 0000000..b931554
--- /dev/null
+++ b/src/utils/recordImages.ts
@@ -0,0 +1,79 @@
+/**
+ * Image detection for the rich JSON record preview.
+ *
+ * Records reference images two ways, and both should render a thumbnail in the
+ * structured field view:
+ *
+ * 1. A direct HTTP(S) URL in a string field (e.g. arena mirror's `image.src`,
+ * an external `thumbnail`, etc.). Rendered straight from the URL.
+ * 2. An AT Protocol blob with an image `mimeType` (e.g. a profile avatar, or
+ * arena mirror's `image.blob`). Rendered via `com.atproto.sync.getBlob`
+ * against the owning PDS, so it needs the repo's DID + PDS endpoint.
+ *
+ * These are pure helpers (no React, no fetch) so both the preview component and
+ * any future caller share one definition of "is this field an image".
+ */
+
+/** Path extensions we're willing to render inline as an image. */
+const IMAGE_EXT_RE =
+ /\.(jpe?g|png|gif|webp|avif|svg|bmp|ico|apng|jfif|heic|heif|tiff?)$/i;
+
+/**
+ * If `value` is a string that points at a renderable image over HTTP(S),
+ * return the URL; otherwise null. The extension is checked against the URL's
+ * pathname only, so query strings (arena appends `?`) and fragments
+ * don't defeat the match. The raw URL is returned un-sanitized — callers pass
+ * it through `sanitizeUrl` before putting it in the DOM.
+ */
+export function imageUrlFromValue(value: unknown): string | null {
+ if (typeof value !== 'string') return null;
+ const s = value.trim();
+ if (!/^https?:\/\//i.test(s)) return null;
+ let pathname: string;
+ try {
+ pathname = new URL(s).pathname;
+ } catch {
+ return null;
+ }
+ return IMAGE_EXT_RE.test(pathname) ? s : null;
+}
+
+export type ImageBlobRef = { cid: string; mimeType: string };
+
+/**
+ * If `value` is an AT Protocol blob describing an image, return its CID +
+ * mimeType; otherwise null. Handles both the current blob shape
+ * (`{ $type: 'blob', ref: { $link }, mimeType }`) and the legacy inline-CID
+ * shape (`{ cid, mimeType }`). Non-image blobs (video, etc.) return null so we
+ * never try to render them as `
`.
+ */
+export function imageBlobFromValue(value: unknown): ImageBlobRef | null {
+ if (!value || typeof value !== 'object') return null;
+ const obj = value as Record;
+ const mimeType = typeof obj.mimeType === 'string' ? obj.mimeType : null;
+ if (!mimeType || !mimeType.startsWith('image/')) return null;
+
+ const ref = obj.ref;
+ if (ref && typeof ref === 'object') {
+ const link = (ref as Record)['$link'];
+ if (typeof link === 'string' && link) return { cid: link, mimeType };
+ }
+ // Legacy shape: the CID sits directly on the blob.
+ if (typeof obj.cid === 'string' && obj.cid) return { cid: obj.cid, mimeType };
+ return null;
+}
+
+/**
+ * Build the public `com.atproto.sync.getBlob` URL that serves a blob's bytes
+ * straight from the owning PDS.
+ */
+export function getBlobUrl(pds: string, did: string, cid: string): string {
+ const params = new URLSearchParams({ did, cid });
+ return `${pds.replace(/\/$/, '')}/xrpc/com.atproto.sync.getBlob?${params}`;
+}
+
+/** Pull the DID authority out of an `at://did:.../…` URI (null if not a DID). */
+export function didFromAtUri(uri: string): string | null {
+ const match = /^at:\/\/(did:[^/]+)/.exec(uri);
+ return match ? match[1] : null;
+}