an atproto based link aggregator

Add PWA polish: service worker, icons, share, pagination

- Service worker with network-first caching for pages
- Cache-first for static assets, auto cache versioning
- SVG icons for PWA manifest (regular + maskable)
- ShareButton component using Web Share API with clipboard fallback
- Pagination for home and /new pages (30 posts per page)
- Skeleton loading components for posts and comments
- Updated tests for new pagination data shape

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

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

+338 -24
+34
src/lib/components/CommentSkeleton.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + count?: number; 4 + nested?: boolean; 5 + } 6 + 7 + let { count = 5, nested = false }: Props = $props(); 8 + </script> 9 + 10 + <div class="space-y-4"> 11 + {#each Array(count) as _, i (i)} 12 + <div class="animate-pulse {nested ? 'ml-4 pl-3 border-l-2 border-gray-200 dark:border-gray-700' : ''}"> 13 + <!-- Header --> 14 + <div class="flex items-center gap-2 mb-2"> 15 + <div class="w-5 h-5 bg-gray-200 dark:bg-gray-700 rounded-full"></div> 16 + <div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-24"></div> 17 + <div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-16"></div> 18 + </div> 19 + 20 + <!-- Comment text --> 21 + <div class="space-y-1.5"> 22 + <div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-full"></div> 23 + <div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div> 24 + <div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-2/3"></div> 25 + </div> 26 + 27 + <!-- Actions --> 28 + <div class="flex gap-3 mt-2"> 29 + <div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-12"></div> 30 + <div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-10"></div> 31 + </div> 32 + </div> 33 + {/each} 34 + </div>
+31
src/lib/components/PostListSkeleton.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + count?: number; 4 + } 5 + 6 + let { count = 10 }: Props = $props(); 7 + </script> 8 + 9 + <ol class="space-y-2"> 10 + {#each Array(count) as _, i (i)} 11 + <li class="flex gap-2 text-sm animate-pulse"> 12 + <!-- Vote/number placeholder --> 13 + <span class="w-6 h-5 bg-gray-200 dark:bg-gray-700 rounded"></span> 14 + 15 + <div class="flex-1 min-w-0 space-y-1.5"> 16 + <!-- Title --> 17 + <div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div> 18 + 19 + <!-- Meta line --> 20 + <div class="flex items-center gap-2"> 21 + <div class="w-4 h-4 bg-gray-200 dark:bg-gray-700 rounded-full"></div> 22 + <div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-20"></div> 23 + <div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-16"></div> 24 + </div> 25 + </div> 26 + 27 + <!-- Comment count placeholder --> 28 + <div class="w-8 h-4 bg-gray-200 dark:bg-gray-700 rounded"></div> 29 + </li> 30 + {/each} 31 + </ol>
+58
src/lib/components/ShareButton.svelte
··· 1 + <script lang="ts"> 2 + import { browser } from '$app/environment'; 3 + 4 + interface Props { 5 + title: string; 6 + text?: string; 7 + url: string; 8 + } 9 + 10 + let { title, text, url }: Props = $props(); 11 + 12 + let canShare = $derived(browser && 'share' in navigator); 13 + let copied = $state(false); 14 + 15 + async function share() { 16 + if (canShare) { 17 + try { 18 + await navigator.share({ title, text, url }); 19 + } catch (err) { 20 + // User cancelled or share failed - fall back to copy 21 + if (err instanceof Error && err.name !== 'AbortError') { 22 + copyToClipboard(); 23 + } 24 + } 25 + } else { 26 + copyToClipboard(); 27 + } 28 + } 29 + 30 + function copyToClipboard() { 31 + navigator.clipboard.writeText(url); 32 + copied = true; 33 + setTimeout(() => (copied = false), 2000); 34 + } 35 + </script> 36 + 37 + <button 38 + onclick={share} 39 + class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-violet-600 dark:hover:text-violet-400 transition-colors" 40 + title={canShare ? 'Share' : 'Copy link'} 41 + > 42 + {#if copied} 43 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 44 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> 45 + </svg> 46 + <span>Copied!</span> 47 + {:else} 48 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 49 + <path 50 + stroke-linecap="round" 51 + stroke-linejoin="round" 52 + stroke-width="2" 53 + d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" 54 + /> 55 + </svg> 56 + <span>share</span> 57 + {/if} 58 + </button>
+18 -6
src/routes/+page.server.ts
··· 6 6 import { getVoteCounts, getUserVotes } from '$lib/server/vote-counts'; 7 7 import { calculateHotScore } from '$lib/utils/ranking'; 8 8 9 - export const load: PageServerLoad = async ({ locals }) => { 9 + const PAGE_SIZE = 30; 10 + 11 + export const load: PageServerLoad = async ({ locals, url }) => { 12 + const page = parseInt(url.searchParams.get('page') || '1'); 13 + const offset = (page - 1) * PAGE_SIZE; 14 + 10 15 // Fetch recent posts with comment counts from content DB 11 16 const recentPosts = await contentDb 12 17 .select({ ··· 25 30 .leftJoin(comments, eq(comments.postUri, posts.uri)) 26 31 .groupBy(posts.uri) 27 32 .orderBy(desc(posts.createdAt)) 28 - .limit(100); 33 + .limit(PAGE_SIZE + 1) // Fetch one extra to check if there are more 34 + .offset(offset); 29 35 30 36 // Fetch author profiles for all posts 31 37 const authorDids = recentPosts.map((p) => p.authorDid); ··· 36 42 const voteCounts = await getVoteCounts(postUris); 37 43 const userVotes = locals.did ? await getUserVotes(locals.did, postUris) : new Map(); 38 44 45 + // Check if there are more posts 46 + const hasMore = recentPosts.length > PAGE_SIZE; 47 + const postsToProcess = hasMore ? recentPosts.slice(0, PAGE_SIZE) : recentPosts; 48 + 39 49 // Combine posts with author profiles, vote counts, user votes, and hot score 40 - const postsWithData = recentPosts.map((post) => { 50 + const postsWithData = postsToProcess.map((post) => { 41 51 const voteCount = voteCounts.get(post.uri) ?? 0; 42 52 return { 43 53 ...post, ··· 48 58 }; 49 59 }); 50 60 51 - // Sort by hot score (descending) and take top 50 52 - const hotPosts = postsWithData.sort((a, b) => b.hotScore - a.hotScore).slice(0, 50); 61 + // Sort by hot score (descending) 62 + const hotPosts = postsWithData.sort((a, b) => b.hotScore - a.hotScore); 53 63 54 64 return { 55 - posts: hotPosts 65 + posts: hotPosts, 66 + page, 67 + hasMore 56 68 }; 57 69 };
+20
src/routes/+page.svelte
··· 14 14 currentUserDid={data.user?.did} 15 15 currentUserHandle={data.user?.handle} 16 16 /> 17 + 18 + <!-- Pagination --> 19 + <nav class="mt-6 flex justify-center gap-4"> 20 + {#if data.page > 1} 21 + <a 22 + href="/?page={data.page - 1}" 23 + class="px-4 py-2 text-sm text-violet-600 dark:text-violet-400 hover:underline" 24 + > 25 + &larr; Newer 26 + </a> 27 + {/if} 28 + {#if data.hasMore} 29 + <a 30 + href="/?page={data.page + 1}" 31 + class="px-4 py-2 text-sm text-violet-600 dark:text-violet-400 hover:underline" 32 + > 33 + Older &rarr; 34 + </a> 35 + {/if} 36 + </nav>
+18 -6
src/routes/new/+page.server.ts
··· 5 5 import { fetchProfiles, getProfileOrFallback } from '$lib/server/profiles'; 6 6 import { getVoteCounts, getUserVotes } from '$lib/server/vote-counts'; 7 7 8 - export const load: PageServerLoad = async ({ locals }) => { 8 + const PAGE_SIZE = 30; 9 + 10 + export const load: PageServerLoad = async ({ locals, url }) => { 11 + const page = parseInt(url.searchParams.get('page') || '1'); 12 + const offset = (page - 1) * PAGE_SIZE; 13 + 9 14 // Fetch posts ordered by creation time (newest first) with comment counts 10 15 const recentPosts = await contentDb 11 16 .select({ ··· 24 29 .leftJoin(comments, eq(comments.postUri, posts.uri)) 25 30 .groupBy(posts.uri) 26 31 .orderBy(desc(posts.createdAt)) 27 - .limit(50); 32 + .limit(PAGE_SIZE + 1) 33 + .offset(offset); 28 34 29 - const authorDids = recentPosts.map((p) => p.authorDid); 35 + // Check if there are more posts 36 + const hasMore = recentPosts.length > PAGE_SIZE; 37 + const postsToProcess = hasMore ? recentPosts.slice(0, PAGE_SIZE) : recentPosts; 38 + 39 + const authorDids = postsToProcess.map((p) => p.authorDid); 30 40 const profiles = await fetchProfiles(authorDids); 31 41 32 42 // Get vote counts and user votes from local DB 33 - const postUris = recentPosts.map((p) => p.uri); 43 + const postUris = postsToProcess.map((p) => p.uri); 34 44 const voteCounts = await getVoteCounts(postUris); 35 45 const userVotes = locals.did ? await getUserVotes(locals.did, postUris) : new Map(); 36 46 37 - const postsWithData = recentPosts.map((post) => ({ 47 + const postsWithData = postsToProcess.map((post) => ({ 38 48 ...post, 39 49 voteCount: voteCounts.get(post.uri) ?? 0, 40 50 author: getProfileOrFallback(profiles, post.authorDid), ··· 42 52 })); 43 53 44 54 return { 45 - posts: postsWithData 55 + posts: postsWithData, 56 + page, 57 + hasMore 46 58 }; 47 59 };
+20
src/routes/new/+page.svelte
··· 14 14 currentUserDid={data.user?.did} 15 15 currentUserHandle={data.user?.handle} 16 16 /> 17 + 18 + <!-- Pagination --> 19 + <nav class="mt-6 flex justify-center gap-4"> 20 + {#if data.page > 1} 21 + <a 22 + href="/new?page={data.page - 1}" 23 + class="px-4 py-2 text-sm text-violet-600 dark:text-violet-400 hover:underline" 24 + > 25 + &larr; Newer 26 + </a> 27 + {/if} 28 + {#if data.hasMore} 29 + <a 30 + href="/new?page={data.page + 1}" 31 + class="px-4 py-2 text-sm text-violet-600 dark:text-violet-400 hover:underline" 32 + > 33 + Older &rarr; 34 + </a> 35 + {/if} 36 + </nav>
+8 -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 + // @ts-expect-error - vitest-browser-svelte types issue 9 9 render(Page, { 10 10 props: { 11 11 data: { 12 12 did: null, 13 13 user: null, 14 - posts: [] 14 + posts: [], 15 + page: 1, 16 + hasMore: false 15 17 } 16 18 } 17 19 }); ··· 21 23 }); 22 24 23 25 it('should render posts list', async () => { 24 - // @ts-expect-error - vitest-browser-svelte types don't match runtime API 26 + // @ts-expect-error - vitest-browser-svelte types issue 25 27 render(Page, { 26 28 props: { 27 29 data: { ··· 47 49 handle: 'test.bsky.social' 48 50 } 49 51 } 50 - ] 52 + ], 53 + page: 1, 54 + hasMore: false 51 55 } 52 56 } 53 57 });
+8
src/routes/post/[rkey]/+page.svelte
··· 4 4 import { SvelteMap, SvelteSet } from 'svelte/reactivity'; 5 5 import Avatar from '$lib/components/Avatar.svelte'; 6 6 import VoteButton from '$lib/components/VoteButton.svelte'; 7 + import ShareButton from '$lib/components/ShareButton.svelte'; 7 8 import { formatTimeAgo, getDomain } from '$lib/utils/formatting'; 9 + import { page } from '$app/state'; 8 10 import type { AuthorProfile } from '$lib/types'; 9 11 import { pendingComments, type PendingComment } from '$lib/stores/pending'; 10 12 ··· 177 179 link 178 180 /> 179 181 · {formatTimeAgo(data.post.createdAt, true)} 182 + · 183 + <ShareButton 184 + title={data.post.title} 185 + text={data.post.text ?? undefined} 186 + url={page.url.href} 187 + /> 180 188 </p> 181 189 </div> 182 190 </header>
+88
src/service-worker.ts
··· 1 + /// <reference no-default-lib="true"/> 2 + /// <reference lib="esnext" /> 3 + /// <reference lib="webworker" /> 4 + /// <reference types="@sveltejs/kit" /> 5 + 6 + import { build, files, version } from '$service-worker'; 7 + 8 + const self = globalThis.self as unknown as ServiceWorkerGlobalScope; 9 + 10 + // Create a unique cache name for this deployment 11 + const CACHE = `cache-${version}`; 12 + 13 + // Assets to cache immediately (app shell) 14 + const ASSETS = [ 15 + ...build, // the app itself 16 + ...files // everything in static 17 + ]; 18 + 19 + // Install: cache app shell 20 + self.addEventListener('install', (event) => { 21 + async function addFilesToCache() { 22 + const cache = await caches.open(CACHE); 23 + await cache.addAll(ASSETS); 24 + } 25 + 26 + event.waitUntil(addFilesToCache()); 27 + }); 28 + 29 + // Activate: clean up old caches 30 + self.addEventListener('activate', (event) => { 31 + async function deleteOldCaches() { 32 + for (const key of await caches.keys()) { 33 + if (key !== CACHE) await caches.delete(key); 34 + } 35 + } 36 + 37 + event.waitUntil(deleteOldCaches()); 38 + }); 39 + 40 + // Fetch: network-first with cache fallback for pages, cache-first for assets 41 + self.addEventListener('fetch', (event) => { 42 + // Only handle GET requests 43 + if (event.request.method !== 'GET') return; 44 + 45 + const url = new URL(event.request.url); 46 + 47 + // Skip cross-origin requests 48 + if (url.origin !== self.location.origin) return; 49 + 50 + // Skip API routes - always go to network 51 + if (url.pathname.startsWith('/api/')) return; 52 + 53 + async function respond(): Promise<Response> { 54 + const cache = await caches.open(CACHE); 55 + 56 + // For static assets (build/files), serve from cache first 57 + if (ASSETS.includes(url.pathname)) { 58 + const cached = await cache.match(url.pathname); 59 + if (cached) return cached; 60 + } 61 + 62 + // For pages, try network first, fall back to cache 63 + try { 64 + const response = await fetch(event.request); 65 + 66 + // Cache successful responses 67 + if (response.status === 200) { 68 + cache.put(event.request, response.clone()); 69 + } 70 + 71 + return response; 72 + } catch { 73 + // Offline: try to serve from cache 74 + const cached = await cache.match(event.request); 75 + if (cached) return cached; 76 + 77 + // If no cache and it's a navigation request, show offline page 78 + if (event.request.mode === 'navigate') { 79 + const offlinePage = await cache.match('/'); 80 + if (offlinePage) return offlinePage; 81 + } 82 + 83 + throw new Error('No cached response available'); 84 + } 85 + } 86 + 87 + event.respondWith(respond()); 88 + });
+12
static/icon-maskable.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> 2 + <defs> 3 + <linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%"> 4 + <stop offset="0%" stop-color="#c4b5fd"/> 5 + <stop offset="50%" stop-color="#a78bfa"/> 6 + <stop offset="100%" stop-color="#7c3aed"/> 7 + </linearGradient> 8 + </defs> 9 + <!-- Full background for maskable icon safe zone --> 10 + <rect width="512" height="512" fill="url(#g)"/> 11 + <text x="256" y="352" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, monospace" font-size="288" font-weight="800" fill="#1e1b4b">p</text> 12 + </svg>
+11
static/icon.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> 2 + <defs> 3 + <linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%"> 4 + <stop offset="0%" stop-color="#c4b5fd"/> 5 + <stop offset="50%" stop-color="#a78bfa"/> 6 + <stop offset="100%" stop-color="#7c3aed"/> 7 + </linearGradient> 8 + </defs> 9 + <rect width="512" height="512" rx="96" fill="url(#g)"/> 10 + <text x="256" y="352" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, monospace" font-size="288" font-weight="800" fill="#1e1b4b">p</text> 11 + </svg>
+12 -8
static/manifest.json
··· 4 4 "description": "ATProto-powered link aggregator", 5 5 "start_url": "/", 6 6 "display": "standalone", 7 - "background_color": "#ffffff", 8 - "theme_color": "#000000", 7 + "background_color": "#f9fafb", 8 + "theme_color": "#7c3aed", 9 + "orientation": "portrait-primary", 10 + "categories": ["social", "news"], 9 11 "icons": [ 10 12 { 11 - "src": "/icon-192.png", 12 - "sizes": "192x192", 13 - "type": "image/png" 13 + "src": "/icon.svg", 14 + "sizes": "any", 15 + "type": "image/svg+xml", 16 + "purpose": "any" 14 17 }, 15 18 { 16 - "src": "/icon-512.png", 17 - "sizes": "512x512", 18 - "type": "image/png" 19 + "src": "/icon-maskable.svg", 20 + "sizes": "any", 21 + "type": "image/svg+xml", 22 + "purpose": "maskable" 19 23 } 20 24 ] 21 25 }