experimental bluesky client
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add profile pages

+275 -9
+27 -1
HANDOFF.md
··· 110 110 - **Link previews** render as a bordered card with thumb, hostname kicker, title, and description. Clicking opens the URL in a new tab. 111 111 - Uses `<button type="button">` + `window.open` instead of `<a>` to avoid nested anchor invalid HTML (feed cards are themselves `<Link>` → `<a>` wrappers). 112 112 113 - ## Next: ??? 113 + ## Done: profile pages ✓ 114 + 115 + `src/routes/profile.$handle.tsx` — route at `/profile/$handle`. 116 + 117 + - `getProfile` server fn calls `agent.getProfile()` and `agent.getAuthorFeed()` in parallel via `Promise.all` 118 + - Profile header: large avatar, display name in `display-title` Fraunces serif, `@handle`, bio paragraph, follower/following/post counts formatted with `toLocaleString()` 119 + - Posts feed below: same `island-shell` cards as feed, reply-parent snippets, `EmbedBlock` embeds, each card links to `/post/$uri` 120 + - Author name/avatar in feed cards (`feed.tsx`) and thread view (`PostHeader` in `post.$uri.tsx`) are now `<button>` elements that call `useNavigate()` + `e.stopPropagation()` to navigate to the profile without triggering the outer card link 121 + - Playwright-verified: feed → profile, profile post → thread, thread → profile all work 122 + 123 + ## Next: UI overhaul 124 + 125 + The current card/feed UI is still leaning on TanStack Start default CSS and the `island-shell` card style isn't landing right. Goal: rethink the visual treatment of feed cards, thread view, and profile — make it feel more intentional and less default-kit. 126 + 127 + ### Areas to revisit 128 + 129 + - **Post cards** — the `island-shell` frosted-glass style may not be the right move for a dense feed; consider a flatter, more typographic approach (subtle dividers instead of cards, or a tighter card with less padding and a stronger border treatment) 130 + - **Author line** — timestamp formatting is currently raw ISO string; format it as relative time ("2h", "Apr 10") like every other social client 131 + - **Thread view** — connector lines between ancestor/focal/replies work but the visual weight may need tuning 132 + - **Profile header** — generally fine but could be more distinctive; consider a banner area or a stronger typographic hierarchy 133 + - **Overall spacing/density** — feed feels a bit loose; tighten up vertical rhythm 134 + 135 + ### What to preserve 136 + 137 + - The design token system in `src/styles.css` (color tokens, fonts) is solid — work within it 138 + - `island-shell`, `island-kicker`, `display-title`, etc. utility classes can be modified or supplemented, not thrown away 139 + - Tailwind v4 token syntax: `text-[--sea-ink]`, `bg-[--surface]`, etc. 114 140 115 141 ## Conventions 116 142
+21
src/routeTree.gen.ts
··· 17 17 import { Route as CallbackRouteImport } from './routes/callback' 18 18 import { Route as AboutRouteImport } from './routes/about' 19 19 import { Route as IndexRouteImport } from './routes/index' 20 + import { Route as ProfileHandleRouteImport } from './routes/profile.$handle' 20 21 import { Route as PostUriRouteImport } from './routes/post.$uri' 21 22 22 23 const LogoutRoute = LogoutRouteImport.update({ ··· 57 58 const IndexRoute = IndexRouteImport.update({ 58 59 id: '/', 59 60 path: '/', 61 + getParentRoute: () => rootRouteImport, 62 + } as any) 63 + const ProfileHandleRoute = ProfileHandleRouteImport.update({ 64 + id: '/profile/$handle', 65 + path: '/profile/$handle', 60 66 getParentRoute: () => rootRouteImport, 61 67 } as any) 62 68 const PostUriRoute = PostUriRouteImport.update({ ··· 75 81 '/login': typeof LoginRoute 76 82 '/logout': typeof LogoutRoute 77 83 '/post/$uri': typeof PostUriRoute 84 + '/profile/$handle': typeof ProfileHandleRoute 78 85 } 79 86 export interface FileRoutesByTo { 80 87 '/': typeof IndexRoute ··· 86 93 '/login': typeof LoginRoute 87 94 '/logout': typeof LogoutRoute 88 95 '/post/$uri': typeof PostUriRoute 96 + '/profile/$handle': typeof ProfileHandleRoute 89 97 } 90 98 export interface FileRoutesById { 91 99 __root__: typeof rootRouteImport ··· 98 106 '/login': typeof LoginRoute 99 107 '/logout': typeof LogoutRoute 100 108 '/post/$uri': typeof PostUriRoute 109 + '/profile/$handle': typeof ProfileHandleRoute 101 110 } 102 111 export interface FileRouteTypes { 103 112 fileRoutesByFullPath: FileRoutesByFullPath ··· 111 120 | '/login' 112 121 | '/logout' 113 122 | '/post/$uri' 123 + | '/profile/$handle' 114 124 fileRoutesByTo: FileRoutesByTo 115 125 to: 116 126 | '/' ··· 122 132 | '/login' 123 133 | '/logout' 124 134 | '/post/$uri' 135 + | '/profile/$handle' 125 136 id: 126 137 | '__root__' 127 138 | '/' ··· 133 144 | '/login' 134 145 | '/logout' 135 146 | '/post/$uri' 147 + | '/profile/$handle' 136 148 fileRoutesById: FileRoutesById 137 149 } 138 150 export interface RootRouteChildren { ··· 145 157 LoginRoute: typeof LoginRoute 146 158 LogoutRoute: typeof LogoutRoute 147 159 PostUriRoute: typeof PostUriRoute 160 + ProfileHandleRoute: typeof ProfileHandleRoute 148 161 } 149 162 150 163 declare module '@tanstack/react-router' { ··· 205 218 preLoaderRoute: typeof IndexRouteImport 206 219 parentRoute: typeof rootRouteImport 207 220 } 221 + '/profile/$handle': { 222 + id: '/profile/$handle' 223 + path: '/profile/$handle' 224 + fullPath: '/profile/$handle' 225 + preLoaderRoute: typeof ProfileHandleRouteImport 226 + parentRoute: typeof rootRouteImport 227 + } 208 228 '/post/$uri': { 209 229 id: '/post/$uri' 210 230 path: '/post/$uri' ··· 225 245 LoginRoute: LoginRoute, 226 246 LogoutRoute: LogoutRoute, 227 247 PostUriRoute: PostUriRoute, 248 + ProfileHandleRoute: ProfileHandleRoute, 228 249 } 229 250 export const routeTree = rootRouteImport 230 251 ._addFileChildren(rootRouteChildren)
+16 -4
src/routes/feed.tsx
··· 1 1 import { Agent } from '@atproto/api' 2 - import { createFileRoute, Link, redirect } from '@tanstack/react-router' 2 + import { createFileRoute, Link, redirect, useNavigate } from '@tanstack/react-router' 3 3 import { createServerFn } from '@tanstack/react-start' 4 4 import { getCookie, setCookie } from '@tanstack/react-start/server' 5 5 import { EmbedBlock } from '#/components/EmbedBlock' ··· 69 69 70 70 function FeedPage() { 71 71 const { feed } = Route.useLoaderData() 72 + const navigate = useNavigate() 72 73 73 74 return ( 74 75 <div className="bg-[--bg-base] max-w-2xl mx-auto py-8 px-4 space-y-4"> ··· 94 95 ↩ replying to @{item.reply.author.handle} 95 96 </p> 96 97 )} 97 - <div className="flex items-center gap-2"> 98 + <button 99 + type="button" 100 + className="flex items-center gap-2 cursor-pointer bg-transparent border-0 p-0 text-left hover:opacity-80 transition-opacity" 101 + onClick={(e) => { 102 + e.stopPropagation() 103 + e.preventDefault() 104 + navigate({ 105 + to: '/profile/$handle', 106 + params: { handle: item.author.handle }, 107 + }) 108 + }} 109 + > 98 110 {item.author.avatar && ( 99 111 <img 100 112 src={item.author.avatar} ··· 103 115 /> 104 116 )} 105 117 <div> 106 - <span className="font-semibold"> 118 + <span className="font-semibold text-[--sea-ink]"> 107 119 {item.author.displayName ?? item.author.handle} 108 120 </span> 109 121 <span className="text-sm text-[--sea-ink-soft] mx-1"> ··· 113 125 {item.createdAt} 114 126 </span> 115 127 </div> 116 - </div> 128 + </button> 117 129 <p className="whitespace-pre-wrap m-0">{item.text}</p> 118 130 <EmbedBlock embed={item.embed} /> 119 131 </article>
+16 -4
src/routes/post.$uri.tsx
··· 1 1 import { Agent } from '@atproto/api' 2 - import { createFileRoute, Link, redirect } from '@tanstack/react-router' 2 + import { createFileRoute, Link, redirect, useNavigate } from '@tanstack/react-router' 3 3 import { createServerFn } from '@tanstack/react-start' 4 4 import { getCookie, setCookie } from '@tanstack/react-start/server' 5 5 import { EmbedBlock } from '#/components/EmbedBlock' ··· 88 88 post: PostData 89 89 large?: boolean 90 90 }) { 91 + const navigate = useNavigate() 91 92 return ( 92 - <div className="flex items-center gap-2"> 93 + <button 94 + type="button" 95 + className="flex items-center gap-2 cursor-pointer bg-transparent border-0 p-0 text-left hover:opacity-80 transition-opacity" 96 + onClick={(e) => { 97 + e.stopPropagation() 98 + e.preventDefault() 99 + navigate({ 100 + to: '/profile/$handle', 101 + params: { handle: post.author.handle }, 102 + }) 103 + }} 104 + > 93 105 {post.author.avatar && ( 94 106 <img 95 107 src={post.author.avatar} ··· 100 112 <div> 101 113 <span 102 114 className={ 103 - large ? 'font-semibold text-base' : 'font-semibold text-sm' 115 + large ? 'font-semibold text-base text-[--sea-ink]' : 'font-semibold text-sm text-[--sea-ink]' 104 116 } 105 117 > 106 118 {post.author.displayName ?? post.author.handle} ··· 109 121 @{post.author.handle} 110 122 </span> 111 123 </div> 112 - </div> 124 + </button> 113 125 ) 114 126 } 115 127
+195
src/routes/profile.$handle.tsx
··· 1 + import { Agent } from '@atproto/api' 2 + import { createFileRoute, Link, redirect } from '@tanstack/react-router' 3 + import { createServerFn } from '@tanstack/react-start' 4 + import { getCookie, setCookie } from '@tanstack/react-start/server' 5 + import { EmbedBlock } from '#/components/EmbedBlock' 6 + import { client } from '#/lib/oauth-client' 7 + import { extractEmbed } from '#/lib/types' 8 + 9 + const getProfile = createServerFn({ method: 'GET' }) 10 + .inputValidator((data: { handle: string }) => data) 11 + .handler(async ({ data }) => { 12 + const did = getCookie('did') 13 + if (!did) throw redirect({ to: '/login' }) 14 + 15 + let session: Awaited<ReturnType<typeof client.restore>> 16 + try { 17 + session = await client.restore(did) 18 + } catch { 19 + setCookie('did', '', { maxAge: 0, path: '/' }) 20 + throw redirect({ to: '/login' }) 21 + } 22 + 23 + const agent = new Agent(session) 24 + 25 + const [profileRes, feedRes] = await Promise.all([ 26 + agent.getProfile({ actor: data.handle }), 27 + agent.getAuthorFeed({ actor: data.handle, limit: 50 }), 28 + ]) 29 + 30 + const profile = { 31 + handle: profileRes.data.handle, 32 + displayName: profileRes.data.displayName ?? null, 33 + avatar: profileRes.data.avatar ?? null, 34 + description: profileRes.data.description ?? null, 35 + followersCount: profileRes.data.followersCount ?? 0, 36 + followsCount: profileRes.data.followsCount ?? 0, 37 + postsCount: profileRes.data.postsCount ?? 0, 38 + } 39 + 40 + const feed = feedRes.data.feed.map((item) => ({ 41 + uri: item.post.uri, 42 + text: (item.post.record as { text?: string }).text ?? '', 43 + author: { 44 + handle: item.post.author.handle, 45 + displayName: item.post.author.displayName ?? null, 46 + avatar: item.post.author.avatar ?? null, 47 + }, 48 + createdAt: (item.post.record as { createdAt?: string }).createdAt, 49 + embed: extractEmbed(item.post.embed), 50 + reply: (() => { 51 + const parent = item.reply?.parent as 52 + | { 53 + $type: string 54 + uri: string 55 + record: unknown 56 + author: { 57 + handle: string 58 + displayName?: string | null 59 + avatar?: string | null 60 + } 61 + } 62 + | undefined 63 + if (!parent || parent.$type !== 'app.bsky.feed.defs#postView') return null 64 + return { 65 + uri: parent.uri, 66 + text: (parent.record as { text?: string }).text ?? '', 67 + author: { 68 + handle: parent.author.handle, 69 + displayName: parent.author.displayName ?? null, 70 + avatar: parent.author.avatar ?? null, 71 + }, 72 + } 73 + })(), 74 + })) 75 + 76 + return { profile, feed } 77 + }) 78 + 79 + export const Route = createFileRoute('/profile/$handle')({ 80 + loader: ({ params }) => getProfile({ data: { handle: params.handle } }), 81 + component: ProfilePage, 82 + }) 83 + 84 + function fmt(n: number) { 85 + return n.toLocaleString() 86 + } 87 + 88 + function ProfilePage() { 89 + const { profile, feed } = Route.useLoaderData() 90 + 91 + return ( 92 + <div className="bg-[--bg-base] max-w-2xl mx-auto py-8 px-4 space-y-4"> 93 + {/* Profile header */} 94 + <div className="island-shell p-6 space-y-4"> 95 + <div className="flex items-start gap-4"> 96 + {profile.avatar ? ( 97 + <img 98 + src={profile.avatar} 99 + alt="" 100 + className="w-16 h-16 rounded-full flex-shrink-0" 101 + /> 102 + ) : ( 103 + <div className="w-16 h-16 rounded-full bg-[--surface-strong] flex-shrink-0" /> 104 + )} 105 + <div className="min-w-0 space-y-0.5"> 106 + <h1 className="display-title text-xl m-0 leading-tight"> 107 + {profile.displayName ?? profile.handle} 108 + </h1> 109 + <p className="text-sm text-[--sea-ink-soft] m-0">@{profile.handle}</p> 110 + </div> 111 + </div> 112 + 113 + {profile.description && ( 114 + <p className="text-sm text-[--sea-ink] m-0 whitespace-pre-wrap leading-relaxed"> 115 + {profile.description} 116 + </p> 117 + )} 118 + 119 + <div className="flex gap-4 text-sm text-[--sea-ink-soft]"> 120 + <span> 121 + <span className="font-semibold text-[--sea-ink]"> 122 + {fmt(profile.followersCount)} 123 + </span>{' '} 124 + followers 125 + </span> 126 + <span> 127 + <span className="font-semibold text-[--sea-ink]"> 128 + {fmt(profile.followsCount)} 129 + </span>{' '} 130 + following 131 + </span> 132 + <span> 133 + <span className="font-semibold text-[--sea-ink]"> 134 + {fmt(profile.postsCount)} 135 + </span>{' '} 136 + posts 137 + </span> 138 + </div> 139 + </div> 140 + 141 + {/* Posts feed */} 142 + {feed.map((item) => ( 143 + <Link 144 + key={item.uri} 145 + to="/post/$uri" 146 + params={{ uri: encodeURIComponent(item.uri) }} 147 + className="block no-underline" 148 + > 149 + <article className="island-shell p-4 space-y-2 hover:border-[--lagoon-deep]/40"> 150 + {item.reply && ( 151 + <div className="border-l-2 border-[--line] pl-3 text-sm text-[--sea-ink-soft] space-y-0.5"> 152 + <span className="font-medium"> 153 + {item.reply.author.displayName ?? item.reply.author.handle} 154 + </span>{' '} 155 + <span>@{item.reply.author.handle}</span> 156 + <p className="line-clamp-2 m-0">{item.reply.text}</p> 157 + </div> 158 + )} 159 + {item.reply && ( 160 + <p className="island-kicker text-xs m-0"> 161 + ↩ replying to @{item.reply.author.handle} 162 + </p> 163 + )} 164 + <div className="flex items-center gap-2"> 165 + {item.author.avatar && ( 166 + <img 167 + src={item.author.avatar} 168 + alt="" 169 + className="w-8 h-8 rounded-full" 170 + /> 171 + )} 172 + <div> 173 + <span className="font-semibold"> 174 + {item.author.displayName ?? item.author.handle} 175 + </span> 176 + <span className="text-sm text-[--sea-ink-soft] mx-1"> 177 + @{item.author.handle} 178 + </span> 179 + <span className="text-sm text-[--sea-ink-soft] ml-1"> 180 + {item.createdAt} 181 + </span> 182 + </div> 183 + </div> 184 + <p className="whitespace-pre-wrap m-0">{item.text}</p> 185 + <EmbedBlock embed={item.embed} /> 186 + </article> 187 + </Link> 188 + ))} 189 + 190 + {feed.length === 0 && ( 191 + <p className="text-center text-[--sea-ink-soft] py-8">No posts yet.</p> 192 + )} 193 + </div> 194 + ) 195 + }