an atproto based link aggregator
5
fork

Configure Feed

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

Add homepage posts display and dev improvements

- Display posts on homepage with author profiles
- Add click-to-copy DID on avatar (dev mode only)
- Remove description field from submit form (keep lexicon flexible)
- Fix AT Data Model undefined value issue in post records
- Add tmux dev server instructions to CLAUDE.md
- Update page tests for new posts display

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+241 -34
+17
CLAUDE.md
··· 37 37 2. Run `pnpm dev:wrangler` to start with D1 bindings 38 38 3. Visit `http://127.0.0.1:5173` (NOT localhost) 39 39 40 + ## Dev Server Management 41 + 42 + Use tmux to manage the dev server (not background shell processes): 43 + 44 + ```bash 45 + # Start dev server in tmux session 46 + tmux new-session -d -s papili 'pnpm dev' 47 + 48 + # Check server output 49 + tmux capture-pane -t papili -p 50 + 51 + # Kill dev server 52 + tmux kill-session -t papili 53 + ``` 54 + 55 + The user may have an existing tmux session running the dev server. 56 + 40 57 ## Key Files 41 58 42 59 - `wrangler.toml` - Cloudflare config (D1 binding)
+24 -3
src/lib/components/Avatar.svelte
··· 1 1 <script lang="ts"> 2 + import { dev } from '$app/environment'; 3 + 2 4 interface Props { 3 5 handle: string; 4 6 avatar?: string | null; ··· 19 21 const ringClasses = 'ring-2 ring-gray-200 ring-offset-1 ring-offset-white dark:ring-gray-600 dark:ring-offset-gray-950'; 20 22 21 23 const profileUrl = did ? `/profile/${did}` : `/profile/${handle}`; 24 + 25 + let copied = $state(false); 26 + 27 + async function copyDid() { 28 + if (dev && did) { 29 + await navigator.clipboard.writeText(did); 30 + copied = true; 31 + setTimeout(() => (copied = false), 1500); 32 + } 33 + } 22 34 </script> 23 35 24 36 {#if link} ··· 41 53 {/if} 42 54 </a> 43 55 {:else} 44 - <div class="flex items-center gap-2"> 56 + <!-- svelte-ignore a11y_click_events_have_key_events --> 57 + <!-- svelte-ignore a11y_no_static_element_interactions --> 58 + <div 59 + class="flex items-center gap-2 {dev && did ? 'cursor-pointer' : ''}" 60 + onclick={copyDid} 61 + title={dev && did ? `Click to copy DID: ${did}` : undefined} 62 + > 45 63 {#if avatar} 46 64 <img 47 65 src={avatar} 48 66 alt={handle} 49 - class="{sizeClasses[size]} {ringClasses} rounded-full object-cover" 67 + class="{sizeClasses[size]} {ringClasses} rounded-full object-cover {copied ? 'ring-green-500 dark:ring-green-400' : ''}" 50 68 /> 51 69 {:else} 52 - <div class="{sizeClasses[size]} {ringClasses} flex items-center justify-center rounded-full bg-gray-200 dark:bg-gray-700"> 70 + <div class="{sizeClasses[size]} {ringClasses} flex items-center justify-center rounded-full bg-gray-200 dark:bg-gray-700 {copied ? 'ring-green-500 dark:ring-green-400' : ''}"> 53 71 <span class="font-medium text-gray-600 dark:text-gray-300"> 54 72 {handle.charAt(0).toUpperCase()} 55 73 </span> ··· 57 75 {/if} 58 76 {#if showHandle} 59 77 <span class="text-sm font-medium">@{handle}</span> 78 + {/if} 79 + {#if copied} 80 + <span class="text-xs text-green-600 dark:text-green-400">Copied!</span> 60 81 {/if} 61 82 </div> 62 83 {/if}
+6
src/routes/+layout.svelte
··· 18 18 19 19 <div class="flex items-center gap-4"> 20 20 {#if data.user} 21 + <a 22 + href="/submit" 23 + class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700" 24 + > 25 + Submit 26 + </a> 21 27 <Avatar handle={data.user.handle} avatar={data.user.avatar} did={data.user.did} /> 22 28 <a 23 29 href="/logout"
+69
src/routes/+page.server.ts
··· 1 + import type { PageServerLoad } from './$types'; 2 + import { db } from '$lib/server/db'; 3 + import { posts } from '$lib/server/db/schema'; 4 + import { desc } from 'drizzle-orm'; 5 + 6 + interface AuthorProfile { 7 + did: string; 8 + handle: string; 9 + avatar?: string; 10 + } 11 + 12 + async function fetchProfiles(dids: string[]): Promise<Map<string, AuthorProfile>> { 13 + const profiles = new Map<string, AuthorProfile>(); 14 + 15 + // Fetch profiles in parallel (batch of unique DIDs) 16 + const uniqueDids = [...new Set(dids)]; 17 + const results = await Promise.all( 18 + uniqueDids.map(async (did) => { 19 + try { 20 + const res = await fetch( 21 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}` 22 + ); 23 + if (!res.ok) return null; 24 + const data = await res.json(); 25 + return { 26 + did: data.did, 27 + handle: data.handle, 28 + avatar: data.avatar 29 + } as AuthorProfile; 30 + } catch { 31 + return null; 32 + } 33 + }) 34 + ); 35 + 36 + for (const profile of results) { 37 + if (profile) { 38 + profiles.set(profile.did, profile); 39 + } 40 + } 41 + 42 + return profiles; 43 + } 44 + 45 + export const load: PageServerLoad = async () => { 46 + // Fetch recent posts, ordered by creation time (newest first) 47 + const recentPosts = await db 48 + .select() 49 + .from(posts) 50 + .orderBy(desc(posts.createdAt)) 51 + .limit(50); 52 + 53 + // Fetch author profiles for all posts 54 + const authorDids = recentPosts.map((p) => p.authorDid); 55 + const profiles = await fetchProfiles(authorDids); 56 + 57 + // Combine posts with author profiles 58 + const postsWithAuthors = recentPosts.map((post) => ({ 59 + ...post, 60 + author: profiles.get(post.authorDid) ?? { 61 + did: post.authorDid, 62 + handle: post.authorDid.slice(0, 20) + '...' 63 + } 64 + })); 65 + 66 + return { 67 + posts: postsWithAuthors 68 + }; 69 + };
+76 -5
src/routes/+page.svelte
··· 1 - <div class="space-y-4"> 2 - <h1 class="text-3xl font-bold">papili.one</h1> 3 - <p class="text-gray-600 dark:text-gray-400"> 4 - An ATProto-powered link aggregator. Coming soon. 5 - </p> 1 + <script lang="ts"> 2 + import Avatar from '$lib/components/Avatar.svelte'; 3 + 4 + let { data } = $props(); 5 + 6 + function formatTimeAgo(dateString: string): string { 7 + const date = new Date(dateString); 8 + const now = new Date(); 9 + const diffMs = now.getTime() - date.getTime(); 10 + const diffMins = Math.floor(diffMs / 60000); 11 + const diffHours = Math.floor(diffMs / 3600000); 12 + const diffDays = Math.floor(diffMs / 86400000); 13 + 14 + if (diffMins < 1) return 'just now'; 15 + if (diffMins < 60) return `${diffMins}m ago`; 16 + if (diffHours < 24) return `${diffHours}h ago`; 17 + if (diffDays < 30) return `${diffDays}d ago`; 18 + return date.toLocaleDateString(); 19 + } 20 + 21 + function getDomain(url: string): string { 22 + try { 23 + return new URL(url).hostname.replace(/^www\./, ''); 24 + } catch { 25 + return url; 26 + } 27 + } 28 + </script> 29 + 30 + <svelte:head> 31 + <title>papili.one</title> 32 + </svelte:head> 33 + 34 + <div class="space-y-1"> 35 + {#if data.posts.length === 0} 36 + <div class="py-12 text-center text-gray-500 dark:text-gray-400"> 37 + <p class="text-lg">No posts yet.</p> 38 + <p class="mt-2">Be the first to <a href="/submit" class="text-blue-600 hover:underline dark:text-blue-400">submit a link</a>!</p> 39 + </div> 40 + {:else} 41 + {#each data.posts as post, i (post.uri)} 42 + <article class="flex gap-3 py-3 border-b border-gray-100 dark:border-gray-800 last:border-0"> 43 + <div class="flex-shrink-0 w-6 pt-0.5 text-right text-sm text-gray-400 dark:text-gray-500"> 44 + {i + 1}. 45 + </div> 46 + <div class="flex-1 min-w-0"> 47 + <div class="flex items-baseline gap-2 flex-wrap"> 48 + <a 49 + href={post.url} 50 + target="_blank" 51 + rel="noopener noreferrer" 52 + class="text-gray-900 dark:text-gray-100 hover:underline font-medium" 53 + > 54 + {post.title} 55 + </a> 56 + <span class="text-xs text-gray-400 dark:text-gray-500"> 57 + ({getDomain(post.url)}) 58 + </span> 59 + </div> 60 + <div class="mt-1 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400"> 61 + <span>by</span> 62 + <Avatar 63 + handle={post.author.handle} 64 + avatar={post.author.avatar} 65 + did={post.author.did} 66 + size="sm" 67 + showHandle 68 + link 69 + /> 70 + <span class="text-gray-300 dark:text-gray-600">|</span> 71 + <span>{formatTimeAgo(post.createdAt)}</span> 72 + </div> 73 + </div> 74 + </article> 75 + {/each} 76 + {/if} 6 77 </div>
+44 -5
src/routes/page.svelte.spec.ts
··· 4 4 import Page from './+page.svelte'; 5 5 6 6 describe('/+page.svelte', () => { 7 - it('should render h1', async () => { 8 - render(Page); 9 - 10 - const heading = page.getByRole('heading', { level: 1 }); 11 - await expect.element(heading).toBeInTheDocument(); 7 + it('should render empty state when no posts', async () => { 8 + render(Page, { 9 + props: { 10 + data: { 11 + posts: [] 12 + } 13 + } 14 + }); 15 + 16 + const emptyMessage = page.getByText('No posts yet.'); 17 + await expect.element(emptyMessage).toBeInTheDocument(); 18 + }); 19 + 20 + it('should render posts list', async () => { 21 + render(Page, { 22 + props: { 23 + data: { 24 + posts: [ 25 + { 26 + uri: 'at://did:plc:test/one.papili.post/123', 27 + cid: 'bafytest', 28 + authorDid: 'did:plc:test', 29 + rkey: '123', 30 + url: 'https://example.com/article', 31 + title: 'Test Article', 32 + text: null, 33 + createdAt: new Date().toISOString(), 34 + indexedAt: new Date().toISOString(), 35 + author: { 36 + did: 'did:plc:test', 37 + handle: 'test.bsky.social', 38 + avatar: undefined 39 + } 40 + } 41 + ] 42 + } 43 + } 44 + }); 45 + 46 + const postTitle = page.getByText('Test Article'); 47 + await expect.element(postTitle).toBeInTheDocument(); 48 + 49 + const domain = page.getByText('(example.com)'); 50 + await expect.element(domain).toBeInTheDocument(); 12 51 }); 13 52 });
+5 -5
src/routes/submit/+page.server.ts
··· 64 64 const rkey = generateTid(); 65 65 const uri = `at://${authorDid}/one.papili.post/${rkey}`; 66 66 67 - // Build the record 67 + // Build the record (AT Data Model doesn't allow undefined, so omit text if empty) 68 68 const postRecord: post.Main = { 69 69 $type: 'one.papili.post', 70 70 url: url as l.UriString, 71 71 title, 72 - text, 73 - createdAt: now as l.DatetimeString 72 + createdAt: now as l.DatetimeString, 73 + ...(text ? { text } : {}) 74 74 }; 75 75 76 76 // Calculate CID for optimistic write ··· 96 96 { 97 97 url: url as l.UriString, 98 98 title, 99 - text, 100 - createdAt: now as l.DatetimeString 99 + createdAt: now as l.DatetimeString, 100 + ...(text ? { text } : {}) 101 101 }, 102 102 { rkey } 103 103 )
-16
src/routes/submit/+page.svelte
··· 61 61 <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Max 300 characters</p> 62 62 </div> 63 63 64 - <div> 65 - <label for="text" class="block text-sm font-medium text-gray-700 dark:text-gray-300"> 66 - Description <span class="text-gray-400">(optional)</span> 67 - </label> 68 - <textarea 69 - id="text" 70 - name="text" 71 - rows="4" 72 - maxlength="10000" 73 - placeholder="Add context or commentary about this link..." 74 - class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" 75 - >{form?.text ?? ''}</textarea 76 - > 77 - <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Max 10,000 characters</p> 78 - </div> 79 - 80 64 <div class="flex items-center gap-4"> 81 65 <button 82 66 type="submit"