an atproto based link aggregator
5
fork

Configure Feed

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

Redesign UI to look more like a link aggregator

- Replace blue accent with amber color scheme (HN-inspired)
- Simplify header to minimal text-based navigation
- Make post list more compact and text-focused
- Remove avatars from homepage, just show handles
- Add "new" and "discuss" links for future features
- Add ATProto footer credit
- Update login and submit pages to match new style

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

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

+94 -109
+27 -42
src/routes/+layout.svelte
··· 2 2 import '../app.css'; 3 3 import favicon from '$lib/assets/favicon.svg'; 4 4 import ThemeToggle from '$lib/components/ThemeToggle.svelte'; 5 - import Avatar from '$lib/components/Avatar.svelte'; 6 5 7 6 let { children, data } = $props(); 8 7 </script> ··· 11 10 <link rel="icon" href={favicon} /> 12 11 </svelte:head> 13 12 14 - <div class="min-h-screen bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100"> 15 - <header class="border-b border-gray-200 dark:border-gray-800"> 16 - <nav class="mx-auto flex max-w-4xl items-center justify-between px-4 py-3"> 17 - <a href="/" class="text-xl font-bold">papili</a> 18 - 19 - <div class="flex items-center gap-4"> 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> 27 - <Avatar handle={data.user.handle} avatar={data.user.avatar} did={data.user.did} /> 28 - <a 29 - href="/logout" 30 - class="rounded-lg px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-800" 31 - > 32 - Sign out 33 - </a> 34 - {:else if data.did} 35 - <span class="text-sm text-gray-600 dark:text-gray-400"> 36 - {data.did.slice(0, 20)}... 37 - </span> 38 - <a 39 - href="/logout" 40 - class="rounded-lg px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-800" 41 - > 42 - Sign out 43 - </a> 44 - {:else} 45 - <a 46 - href="/login" 47 - class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700" 48 - > 49 - Sign in 50 - </a> 51 - {/if} 52 - <ThemeToggle /> 53 - </div> 13 + <div class="min-h-screen bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100"> 14 + <header class="bg-amber-600 dark:bg-amber-700"> 15 + <nav class="mx-auto flex max-w-4xl items-center gap-4 px-4 py-2 text-sm"> 16 + <a href="/" class="font-bold text-white">papili</a> 17 + <a href="/new" class="text-amber-100 hover:text-white">new</a> 18 + <div class="flex-1"></div> 19 + {#if data.user} 20 + <a href="/submit" class="text-amber-100 hover:text-white">submit</a> 21 + <span class="text-amber-200">|</span> 22 + <a href="/profile/{data.user.did}" class="text-amber-100 hover:text-white">{data.user.handle}</a> 23 + <span class="text-amber-200">|</span> 24 + <a href="/logout" class="text-amber-100 hover:text-white">logout</a> 25 + {:else if data.did} 26 + <span class="text-amber-200">{data.did.slice(0, 16)}...</span> 27 + <span class="text-amber-200">|</span> 28 + <a href="/logout" class="text-amber-100 hover:text-white">logout</a> 29 + {:else} 30 + <a href="/login" class="text-amber-100 hover:text-white">login</a> 31 + {/if} 32 + <ThemeToggle /> 54 33 </nav> 55 34 </header> 56 35 57 - <main class="mx-auto max-w-4xl px-4 py-6"> 36 + <main class="mx-auto max-w-4xl px-4 py-4"> 58 37 {@render children()} 59 38 </main> 39 + 40 + <footer class="border-t border-gray-200 dark:border-gray-800 mt-8"> 41 + <div class="mx-auto max-w-4xl px-4 py-4 text-center text-xs text-gray-500 dark:text-gray-400"> 42 + <a href="https://atproto.com" class="hover:underline" target="_blank" rel="noopener">Powered by ATProto</a> 43 + </div> 44 + </footer> 60 45 </div>
+26 -35
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 - import Avatar from '$lib/components/Avatar.svelte'; 3 - 4 2 let { data } = $props(); 5 3 6 4 function formatTimeAgo(dateString: string): string { ··· 12 10 const diffDays = Math.floor(diffMs / 86400000); 13 11 14 12 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`; 13 + if (diffMins < 60) return `${diffMins} minutes ago`; 14 + if (diffHours < 24) return `${diffHours} hours ago`; 15 + if (diffDays === 1) return 'yesterday'; 16 + if (diffDays < 30) return `${diffDays} days ago`; 18 17 return date.toLocaleDateString(); 19 18 } 20 19 ··· 28 27 </script> 29 28 30 29 <svelte:head> 31 - <title>papili.one</title> 30 + <title>papili</title> 32 31 </svelte:head> 33 32 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} 33 + {#if data.posts.length === 0} 34 + <div class="py-12 text-center text-gray-500 dark:text-gray-400"> 35 + <p>No posts yet.</p> 36 + <p class="mt-2"> 37 + Be the first to <a href="/submit" class="text-amber-600 hover:underline dark:text-amber-400">submit a link</a>. 38 + </p> 39 + </div> 40 + {:else} 41 + <ol class="space-y-2"> 41 42 {#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> 43 + <li class="flex gap-2 text-sm"> 44 + <span class="w-6 text-right text-gray-400 dark:text-gray-500 select-none">{i + 1}.</span> 46 45 <div class="flex-1 min-w-0"> 47 - <div class="flex items-baseline gap-2 flex-wrap"> 46 + <div> 48 47 <a 49 48 href={post.url} 50 49 target="_blank" 51 50 rel="noopener noreferrer" 52 - class="text-gray-900 dark:text-gray-100 hover:underline font-medium" 51 + class="text-gray-900 dark:text-gray-100 visited:text-gray-500 dark:visited:text-gray-400 hover:underline" 53 52 > 54 53 {post.title} 55 54 </a> 56 - <span class="text-xs text-gray-400 dark:text-gray-500"> 55 + <span class="text-xs text-gray-400 dark:text-gray-500 ml-1"> 57 56 ({getDomain(post.url)}) 58 57 </span> 59 58 </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> 59 + <div class="text-xs text-gray-500 dark:text-gray-400"> 60 + by <a href="/profile/{post.author.did}" class="hover:underline">{post.author.handle}</a> 61 + {formatTimeAgo(post.createdAt)} 62 + | <a href="/post/{post.rkey}" class="hover:underline">discuss</a> 72 63 </div> 73 64 </div> 74 - </article> 65 + </li> 75 66 {/each} 76 - {/if} 77 - </div> 67 + </ol> 68 + {/if}
+14 -10
src/routes/login/+page.svelte
··· 7 7 let loading = $state(false); 8 8 </script> 9 9 10 - <div class="mx-auto max-w-sm space-y-6 py-12"> 11 - <div class="text-center"> 12 - <h1 class="text-2xl font-bold">Sign in</h1> 13 - <p class="mt-2 text-gray-600 dark:text-gray-400">Sign in with your <a href="https://internethandle.org/" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:underline dark:text-blue-400">Internet Handle</a></p> 14 - </div> 10 + <svelte:head> 11 + <title>Sign in - papili</title> 12 + </svelte:head> 13 + 14 + <div class="max-w-sm mx-auto py-12"> 15 + <h1 class="text-lg font-bold mb-2">Sign in</h1> 16 + <p class="text-sm text-gray-600 dark:text-gray-400 mb-6"> 17 + Sign in with your ATProto handle 18 + </p> 15 19 16 20 {#if form?.error} 17 - <div class="rounded-lg bg-red-50 p-4 text-red-700 dark:bg-red-900/20 dark:text-red-400"> 21 + <div class="mb-4 p-3 text-sm bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400 rounded"> 18 22 {form.error} 19 23 </div> 20 24 {/if} 21 25 22 26 {#if data.error} 23 - <div class="rounded-lg bg-red-50 p-4 text-red-700 dark:bg-red-900/20 dark:text-red-400"> 27 + <div class="mb-4 p-3 text-sm bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400 rounded"> 24 28 {data.error} 25 29 </div> 26 30 {/if} ··· 37 41 class="space-y-4" 38 42 > 39 43 <div> 40 - <label for="handle" class="block text-sm font-medium">Handle</label> 44 + <label for="handle" class="block text-sm font-medium mb-1">Handle</label> 41 45 <input 42 46 type="text" 43 47 id="handle" ··· 46 50 placeholder="you.bsky.social" 47 51 required 48 52 disabled={loading} 49 - class="mt-1 block w-full rounded-lg border border-gray-300 bg-white px-4 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50 dark:border-gray-700 dark:bg-gray-900" 53 + class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent disabled:opacity-50" 50 54 /> 51 55 </div> 52 56 53 57 <button 54 58 type="submit" 55 59 disabled={loading || !handle} 56 - class="w-full rounded-lg bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50" 60 + class="w-full px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 rounded disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" 57 61 > 58 62 {loading ? 'Signing in...' : 'Sign in'} 59 63 </button>
+9 -4
src/routes/page.svelte.spec.ts
··· 5 5 6 6 describe('/+page.svelte', () => { 7 7 it('should render empty state when no posts', async () => { 8 + // @ts-expect-error - vitest-browser-svelte types don't match runtime API 8 9 render(Page, { 9 10 props: { 10 11 data: { 12 + did: null, 13 + user: null, 11 14 posts: [] 12 15 } 13 16 } ··· 18 21 }); 19 22 20 23 it('should render posts list', async () => { 24 + // @ts-expect-error - vitest-browser-svelte types don't match runtime API 21 25 render(Page, { 22 26 props: { 23 27 data: { 28 + did: null, 29 + user: null, 24 30 posts: [ 25 31 { 26 32 uri: 'at://did:plc:test/one.papili.post/123', ··· 34 40 indexedAt: new Date().toISOString(), 35 41 author: { 36 42 did: 'did:plc:test', 37 - handle: 'test.bsky.social', 38 - avatar: undefined 43 + handle: 'test.bsky.social' 39 44 } 40 45 } 41 46 ] ··· 46 51 const postTitle = page.getByText('Test Article'); 47 52 await expect.element(postTitle).toBeInTheDocument(); 48 53 49 - const domain = page.getByText('(example.com)'); 50 - await expect.element(domain).toBeInTheDocument(); 54 + const authorHandle = page.getByText('test.bsky.social'); 55 + await expect.element(authorHandle).toBeInTheDocument(); 51 56 }); 52 57 });
+18 -18
src/routes/submit/+page.svelte
··· 6 6 </script> 7 7 8 8 <svelte:head> 9 - <title>Submit - papili.one</title> 9 + <title>Submit - papili</title> 10 10 </svelte:head> 11 11 12 - <div class="mx-auto max-w-2xl px-4 py-8"> 13 - <h1 class="mb-6 text-2xl font-bold text-gray-900 dark:text-gray-100">Submit a Link</h1> 12 + <div class="max-w-xl"> 13 + <h1 class="text-lg font-bold mb-4">Submit a Link</h1> 14 14 15 15 {#if form?.error} 16 - <div class="mb-4 rounded-md bg-red-50 p-4 text-red-700 dark:bg-red-900/20 dark:text-red-400"> 16 + <div class="mb-4 p-3 text-sm bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400 rounded"> 17 17 {form.error} 18 18 </div> 19 19 {/if} ··· 27 27 submitting = false; 28 28 }; 29 29 }} 30 - class="space-y-6" 30 + class="space-y-4" 31 31 > 32 32 <div> 33 - <label for="url" class="block text-sm font-medium text-gray-700 dark:text-gray-300"> 34 - URL <span class="text-red-500">*</span> 33 + <label for="url" class="block text-sm font-medium mb-1"> 34 + URL 35 35 </label> 36 36 <input 37 37 type="url" ··· 39 39 name="url" 40 40 required 41 41 value={form?.url ?? ''} 42 - placeholder="https://example.com/interesting-article" 43 - 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" 42 + placeholder="https://" 43 + class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent" 44 44 /> 45 45 </div> 46 46 47 47 <div> 48 - <label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300"> 49 - Title <span class="text-red-500">*</span> 48 + <label for="title" class="block text-sm font-medium mb-1"> 49 + Title 50 50 </label> 51 51 <input 52 52 type="text" ··· 55 55 required 56 56 maxlength="300" 57 57 value={form?.title ?? ''} 58 - placeholder="An interesting title for your submission" 59 - 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" 58 + placeholder="Title of the link" 59 + class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent" 60 60 /> 61 - <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Max 300 characters</p> 61 + <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Max 300 characters</p> 62 62 </div> 63 63 64 - <div class="flex items-center gap-4"> 64 + <div class="flex items-center gap-4 pt-2"> 65 65 <button 66 66 type="submit" 67 67 disabled={submitting} 68 - class="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed dark:focus:ring-offset-gray-900" 68 + class="px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 rounded disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" 69 69 > 70 70 {#if submitting} 71 71 Submitting... ··· 73 73 Submit 74 74 {/if} 75 75 </button> 76 - <a href="/" class="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"> 77 - Cancel 76 + <a href="/" class="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"> 77 + cancel 78 78 </a> 79 79 </div> 80 80 </form>