diff --git a/HANDOFF.md b/HANDOFF.md index 698889b..83a1950 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -110,7 +110,33 @@ Visually verified — reply posts show a left-bordered parent snippet above the - **Link previews** render as a bordered card with thumb, hostname kicker, title, and description. Clicking opens the URL in a new tab. - Uses `

{item.text}

diff --git a/src/routes/post.$uri.tsx b/src/routes/post.$uri.tsx index 04539da..62a1e49 100644 --- a/src/routes/post.$uri.tsx +++ b/src/routes/post.$uri.tsx @@ -1,5 +1,5 @@ import { Agent } from '@atproto/api' -import { createFileRoute, Link, redirect } from '@tanstack/react-router' +import { createFileRoute, Link, redirect, useNavigate } from '@tanstack/react-router' import { createServerFn } from '@tanstack/react-start' import { getCookie, setCookie } from '@tanstack/react-start/server' import { EmbedBlock } from '#/components/EmbedBlock' @@ -88,8 +88,20 @@ function PostHeader({ post: PostData large?: boolean }) { + const navigate = useNavigate() return ( -
+
- + ) } diff --git a/src/routes/profile.$handle.tsx b/src/routes/profile.$handle.tsx new file mode 100644 index 0000000..0f74ca0 --- /dev/null +++ b/src/routes/profile.$handle.tsx @@ -0,0 +1,195 @@ +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 getProfile = createServerFn({ method: 'GET' }) + .inputValidator((data: { handle: string }) => data) + .handler(async ({ data }) => { + const did = getCookie('did') + if (!did) throw redirect({ to: '/login' }) + + let session: Awaited> + try { + session = await client.restore(did) + } catch { + setCookie('did', '', { maxAge: 0, path: '/' }) + throw redirect({ to: '/login' }) + } + + const agent = new Agent(session) + + const [profileRes, feedRes] = await Promise.all([ + agent.getProfile({ actor: data.handle }), + agent.getAuthorFeed({ actor: data.handle, limit: 50 }), + ]) + + const profile = { + handle: profileRes.data.handle, + displayName: profileRes.data.displayName ?? null, + avatar: profileRes.data.avatar ?? null, + description: profileRes.data.description ?? null, + followersCount: profileRes.data.followersCount ?? 0, + followsCount: profileRes.data.followsCount ?? 0, + postsCount: profileRes.data.postsCount ?? 0, + } + + const feed = feedRes.data.feed.map((item) => ({ + uri: item.post.uri, + text: (item.post.record as { text?: string }).text ?? '', + author: { + handle: item.post.author.handle, + displayName: item.post.author.displayName ?? null, + 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 + | { + $type: string + uri: string + record: unknown + author: { + handle: string + displayName?: string | null + avatar?: string | null + } + } + | undefined + if (!parent || parent.$type !== 'app.bsky.feed.defs#postView') return null + return { + uri: parent.uri, + text: (parent.record as { text?: string }).text ?? '', + author: { + handle: parent.author.handle, + displayName: parent.author.displayName ?? null, + avatar: parent.author.avatar ?? null, + }, + } + })(), + })) + + return { profile, feed } + }) + +export const Route = createFileRoute('/profile/$handle')({ + loader: ({ params }) => getProfile({ data: { handle: params.handle } }), + component: ProfilePage, +}) + +function fmt(n: number) { + return n.toLocaleString() +} + +function ProfilePage() { + const { profile, feed } = Route.useLoaderData() + + return ( +
+ {/* Profile header */} +
+
+ {profile.avatar ? ( + + ) : ( +
+ )} +
+

+ {profile.displayName ?? profile.handle} +

+

@{profile.handle}

+
+
+ + {profile.description && ( +

+ {profile.description} +

+ )} + +
+ + + {fmt(profile.followersCount)} + {' '} + followers + + + + {fmt(profile.followsCount)} + {' '} + following + + + + {fmt(profile.postsCount)} + {' '} + posts + +
+
+ + {/* Posts feed */} + {feed.map((item) => ( + +
+ {item.reply && ( +
+ + {item.reply.author.displayName ?? item.reply.author.handle} + {' '} + @{item.reply.author.handle} +

{item.reply.text}

+
+ )} + {item.reply && ( +

+ ↩ replying to @{item.reply.author.handle} +

+ )} +
+ {item.author.avatar && ( + + )} +
+ + {item.author.displayName ?? item.author.handle} + + + @{item.author.handle} + + + {item.createdAt} + +
+
+

{item.text}

+ +
+ + ))} + + {feed.length === 0 && ( +

No posts yet.

+ )} +
+ ) +}