diff --git a/HANDOFF.md b/HANDOFF.md index e73c3ad..698889b 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -102,13 +102,15 @@ Visually verified — reply posts show a left-bordered parent snippet above the - Replies listed below with same card style as feed - Navigation between thread levels verified end-to-end -## Next: images + link previews +## Done: images + link previews ✓ -**Images:** `item.post.embed` with `$type === 'app.bsky.embed.images#view'` has an `images` array (`{ thumb, fullsize, alt }`). Map as `images` in the server fn, render below post text as a responsive grid. +`src/lib/types.ts` holds shared `Author`, `PostData`, and `EmbedData` types plus the `extractEmbed` helper. `src/components/EmbedBlock.tsx` renders both embed types. Both `feed.tsx` and `post.$uri.tsx` extract and render embeds. -**Link previews:** `$type === 'app.bsky.embed.external#view'` has `external: { uri, title, description, thumb? }`. Render as a bordered card below post text; full card is a link to `uri`. +- **Images** render as a responsive grid (1-up full-width, 2-up two columns, 3-up first image spans both columns). Clicking an image opens fullsize in a new tab without triggering card navigation. +- **Link previews** render as a bordered card with thumb, hostname kicker, title, and description. Clicking opens the URL in a new tab. +- Uses ` + ))} + + ) + } + + if (embed.type === 'external') { + let hostname = embed.uri + try { + hostname = new URL(embed.uri).hostname + } catch {} + return ( + + ) + } + + return null +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..57af1b3 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,60 @@ +export interface Author { + handle: string + displayName: string | null + avatar: string | null +} + +export type ImageEmbed = { + type: 'images' + images: { thumb: string; fullsize: string; alt: string }[] +} + +export type ExternalEmbed = { + type: 'external' + uri: string + title: string + description: string + thumb: string | null +} + +export type EmbedData = ImageEmbed | ExternalEmbed | null + +export interface PostData { + uri: string + text: string + author: Author + createdAt: string | undefined + embed: EmbedData +} + +export function extractEmbed(rawEmbed: unknown): EmbedData { + const embed = rawEmbed as { $type: string } | undefined + if (!embed) return null + + if (embed.$type === 'app.bsky.embed.images#view') { + const { images } = embed as unknown as { + images: { thumb: string; fullsize: string; alt: string }[] + } + return { type: 'images', images } + } + + if (embed.$type === 'app.bsky.embed.external#view') { + const { external } = embed as unknown as { + external: { + uri: string + title: string + description: string + thumb?: string + } + } + return { + type: 'external', + uri: external.uri, + title: external.title, + description: external.description, + thumb: external.thumb ?? null, + } + } + + return null +} diff --git a/src/routes/feed.tsx b/src/routes/feed.tsx index 56f4285..13e0bf1 100644 --- a/src/routes/feed.tsx +++ b/src/routes/feed.tsx @@ -2,7 +2,9 @@ import { Agent } from '@atproto/api' import { createFileRoute, Link, redirect } from '@tanstack/react-router' import { createServerFn } from '@tanstack/react-start' import { getCookie, setCookie } from '@tanstack/react-start/server' +import { EmbedBlock } from '#/components/EmbedBlock' import { client } from '#/lib/oauth-client' +import { extractEmbed } from '#/lib/types' const getTimeline = createServerFn({ method: 'GET' }).handler(async () => { const did = getCookie('did') @@ -30,6 +32,7 @@ const getTimeline = createServerFn({ method: 'GET' }).handler(async () => { avatar: item.post.author.avatar ?? null, }, createdAt: (item.post.record as { createdAt?: string }).createdAt, + embed: extractEmbed(item.post.embed), reply: (() => { const parent = item.reply?.parent as | { @@ -112,6 +115,7 @@ function FeedPage() {

{item.text}

+ ))} diff --git a/src/routes/post.$uri.tsx b/src/routes/post.$uri.tsx index 9ad6f3a..04539da 100644 --- a/src/routes/post.$uri.tsx +++ b/src/routes/post.$uri.tsx @@ -2,20 +2,16 @@ import { Agent } from '@atproto/api' import { createFileRoute, Link, redirect } from '@tanstack/react-router' import { createServerFn } from '@tanstack/react-start' import { getCookie, setCookie } from '@tanstack/react-start/server' +import { EmbedBlock } from '#/components/EmbedBlock' import { client } from '#/lib/oauth-client' - -interface PostData { - uri: string - text: string - author: { handle: string; displayName: string | null; avatar: string | null } - createdAt: string | undefined -} +import { type PostData, extractEmbed } from '#/lib/types' type ThreadViewPost = { $type: string post: { uri: string record: unknown + embed?: unknown author: { handle: string displayName?: string | null @@ -38,6 +34,7 @@ const toPost = (node: ThreadViewPost): PostData => ({ avatar: node.post.author.avatar ?? null, }, createdAt: (node.post.record as { createdAt?: string }).createdAt, + embed: extractEmbed(node.post.embed), }) const getThread = createServerFn({ method: 'GET' }) @@ -131,6 +128,7 @@ function PostPage() {

{ancestor.text}

+
))} @@ -144,6 +142,7 @@ function PostPage() {

{post.text}

+ {post.createdAt && (

{new Date(post.createdAt).toLocaleString()} @@ -169,6 +168,7 @@ function PostPage() {

{reply.text}

+
))}