an atproto based link aggregator
5
fork

Configure Feed

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

Add upvote-only voting system for posts and comments

- Add votes table to schema with user/target tracking
- Add voteCount column to posts and comments tables
- Create /api/vote endpoint with optimistic write pattern
- Build VoteButton component with optimistic UI updates
- Add PostList component for reusable post listings
- Integrate voting into home, /new, and post detail pages
- Remove dark mode toggle (now follows system preference)
- Reduce mobile horizontal padding for better space usage
- Fix dark mode CSS to use prefers-color-scheme media queries

Voting is upvote-only to keep things simple - no karma system,
content naturally ages out. Users can toggle their vote on/off.

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

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

+1354 -101
-7
src/app.css
··· 1 1 @import 'tailwindcss'; 2 - 3 - @theme { 4 - /* Use class-based dark mode */ 5 - --color-scheme: light dark; 6 - } 7 - 8 - @custom-variant dark (&:where(.dark, .dark *));
+3 -2
src/lib/components/Avatar.svelte
··· 5 5 handle: string; 6 6 avatar?: string | null; 7 7 did?: string; 8 - size?: 'sm' | 'md' | 'lg'; 8 + size?: 'xs' | 'sm' | 'md' | 'lg'; 9 9 showHandle?: boolean; 10 10 link?: boolean; 11 11 } ··· 13 13 let { handle, avatar, did, size = 'md', showHandle = false, link = false }: Props = $props(); 14 14 15 15 const sizeClasses = { 16 + xs: 'h-4 w-4 text-[0.5rem]', 16 17 sm: 'h-6 w-6 text-xs', 17 18 md: 'h-8 w-8 text-sm', 18 19 lg: 'h-10 w-10 text-base' 19 20 }; 20 21 21 - const ringClasses = 'ring-2 ring-gray-200 ring-offset-1 ring-offset-white dark:ring-gray-600 dark:ring-offset-gray-950'; 22 + const ringClasses = size === 'xs' ? '' : 'ring-2 ring-gray-200 ring-offset-1 ring-offset-white dark:ring-gray-600 dark:ring-offset-gray-950'; 22 23 23 24 const profileUrl = did ? `/profile/${did}` : `/profile/${handle}`; 24 25
+108
src/lib/components/PostList.svelte
··· 1 + <script lang="ts"> 2 + import Avatar from './Avatar.svelte'; 3 + import VoteButton from './VoteButton.svelte'; 4 + 5 + interface Author { 6 + did: string; 7 + handle: string; 8 + avatar?: string; 9 + } 10 + 11 + interface Post { 12 + uri: string; 13 + rkey: string; 14 + url: string; 15 + title: string; 16 + createdAt: string; 17 + author: Author; 18 + commentCount?: number; 19 + voteCount?: number; 20 + userVote?: number; 21 + } 22 + 23 + interface Props { 24 + posts: Post[]; 25 + emptyMessage?: string; 26 + canVote?: boolean; 27 + } 28 + 29 + let { posts, emptyMessage = 'No posts yet.', canVote = false }: Props = $props(); 30 + 31 + function formatTimeAgo(dateString: string): string { 32 + const date = new Date(dateString); 33 + const now = new Date(); 34 + const diffMs = now.getTime() - date.getTime(); 35 + const diffMins = Math.floor(diffMs / 60000); 36 + const diffHours = Math.floor(diffMs / 3600000); 37 + const diffDays = Math.floor(diffMs / 86400000); 38 + 39 + if (diffMins < 1) return 'just now'; 40 + if (diffMins < 60) return `${diffMins} minutes ago`; 41 + if (diffHours < 24) return `${diffHours} hours ago`; 42 + if (diffDays === 1) return 'yesterday'; 43 + if (diffDays < 30) return `${diffDays} days ago`; 44 + return date.toLocaleDateString(); 45 + } 46 + 47 + function getDomain(url: string): string { 48 + try { 49 + return new URL(url).hostname.replace(/^www\./, ''); 50 + } catch { 51 + return url; 52 + } 53 + } 54 + </script> 55 + 56 + {#if posts.length === 0} 57 + <div class="py-12 text-center text-gray-500 dark:text-gray-400"> 58 + <p>{emptyMessage}</p> 59 + <p class="mt-2"> 60 + Be the first to <a href="/submit" class="text-violet-600 hover:underline dark:text-violet-400">submit a link</a>. 61 + </p> 62 + </div> 63 + {:else} 64 + <ol class="space-y-2"> 65 + {#each posts as post, i (post.uri)} 66 + <li class="flex gap-2 text-sm"> 67 + {#if canVote} 68 + <VoteButton 69 + targetUri={post.uri} 70 + targetType="post" 71 + voteCount={post.voteCount ?? 0} 72 + userVote={post.userVote} 73 + /> 74 + {:else} 75 + <span class="w-6 text-right text-gray-400 dark:text-gray-500 select-none">{i + 1}.</span> 76 + {/if} 77 + <div class="flex-1 min-w-0"> 78 + <div> 79 + <a 80 + href={post.url} 81 + target="_blank" 82 + rel="noopener noreferrer" 83 + class="text-gray-900 dark:text-gray-100 visited:text-gray-500 dark:visited:text-gray-400 hover:underline" 84 + > 85 + {post.title} 86 + </a> 87 + <span class="text-xs text-gray-400 dark:text-gray-500 ml-1"> 88 + ({getDomain(post.url)}) 89 + </span> 90 + </div> 91 + <div class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 flex-wrap"> 92 + by 93 + <Avatar 94 + handle={post.author.handle} 95 + avatar={post.author.avatar} 96 + did={post.author.did} 97 + size="xs" 98 + showHandle 99 + link 100 + /> 101 + {formatTimeAgo(post.createdAt)} 102 + | <a href="/post/{post.rkey}" class="hover:underline">{post.commentCount ?? 0} comment{post.commentCount === 1 ? '' : 's'}</a> 103 + </div> 104 + </div> 105 + </li> 106 + {/each} 107 + </ol> 108 + {/if}
+134
src/lib/components/VoteButton.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + targetUri: string; 4 + targetType: 'post' | 'comment'; 5 + voteCount: number; 6 + userVote?: number; // 1 or 0/undefined 7 + disabled?: boolean; 8 + } 9 + 10 + let { targetUri, targetType, voteCount, userVote = 0, disabled = false }: Props = $props(); 11 + 12 + // Track local optimistic adjustments 13 + let pendingDelta = $state(0); 14 + let pendingVote = $state<number | null>(null); 15 + let loading = $state(false); 16 + 17 + // Computed values combining props with pending changes 18 + let displayCount = $derived(voteCount + pendingDelta); 19 + let hasVoted = $derived((pendingVote ?? userVote) === 1); 20 + 21 + async function toggleVote() { 22 + if (disabled || loading) return; 23 + 24 + const currentVote = pendingVote ?? userVote; 25 + const newValue = currentVote === 1 ? 0 : 1; 26 + const delta = newValue - currentVote; 27 + 28 + // Optimistic update 29 + pendingDelta += delta; 30 + pendingVote = newValue; 31 + loading = true; 32 + 33 + try { 34 + const res = await fetch('/api/vote', { 35 + method: 'POST', 36 + headers: { 'Content-Type': 'application/json' }, 37 + body: JSON.stringify({ targetUri, targetType, value: newValue }) 38 + }); 39 + 40 + if (!res.ok) { 41 + // Revert on error 42 + pendingDelta -= delta; 43 + pendingVote = currentVote || null; 44 + } 45 + } catch { 46 + // Revert on error 47 + pendingDelta -= delta; 48 + pendingVote = currentVote || null; 49 + } finally { 50 + loading = false; 51 + } 52 + } 53 + </script> 54 + 55 + <div class="vote-buttons"> 56 + <button 57 + type="button" 58 + class="vote-btn upvote" 59 + class:active={hasVoted} 60 + onclick={toggleVote} 61 + {disabled} 62 + aria-label={hasVoted ? 'Remove upvote' : 'Upvote'} 63 + > 64 + <svg viewBox="0 0 24 24" fill="currentColor" class="vote-icon"> 65 + <path d="M12 4l-8 8h5v8h6v-8h5z" /> 66 + </svg> 67 + </button> 68 + <span class="vote-count" class:positive={displayCount > 0}> 69 + {displayCount} 70 + </span> 71 + </div> 72 + 73 + <style> 74 + .vote-buttons { 75 + display: flex; 76 + flex-direction: column; 77 + align-items: center; 78 + gap: 0; 79 + } 80 + 81 + .vote-btn { 82 + background: none; 83 + border: none; 84 + padding: 0; 85 + cursor: pointer; 86 + color: #9ca3af; 87 + transition: color 0.15s; 88 + line-height: 1; 89 + } 90 + 91 + .vote-btn:hover:not(:disabled) { 92 + color: #6b7280; 93 + } 94 + 95 + .vote-btn:disabled { 96 + cursor: not-allowed; 97 + opacity: 0.5; 98 + } 99 + 100 + .vote-btn.upvote.active { 101 + color: #f97316; 102 + } 103 + 104 + .vote-icon { 105 + width: 1rem; 106 + height: 1rem; 107 + } 108 + 109 + .vote-count { 110 + font-size: 0.75rem; 111 + font-weight: 500; 112 + color: #6b7280; 113 + min-width: 1.5rem; 114 + text-align: center; 115 + } 116 + 117 + .vote-count.positive { 118 + color: #f97316; 119 + } 120 + 121 + @media (prefers-color-scheme: dark) { 122 + .vote-btn { 123 + color: #6b7280; 124 + } 125 + 126 + .vote-btn:hover:not(:disabled) { 127 + color: #9ca3af; 128 + } 129 + 130 + .vote-count { 131 + color: #9ca3af; 132 + } 133 + } 134 + </style>
+38 -4
src/lib/server/db/schema.ts
··· 1 - import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; 1 + import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; 2 2 3 3 // ============================================================================ 4 4 // Auth tables ··· 48 48 title: text('title').notNull(), 49 49 text: text('text'), // Optional description 50 50 createdAt: text('created_at').notNull(), 51 - indexedAt: text('indexed_at').notNull() // When we stored it 51 + indexedAt: text('indexed_at').notNull(), // When we stored it 52 + voteCount: integer('vote_count').notNull().default(0) 52 53 }); 53 54 54 - // Comments will be added in Phase 4 55 - // Votes will be added in Phase 5 55 + // Comments - threaded discussions 56 + export const comments = sqliteTable('comments', { 57 + uri: text('uri').primaryKey(), // at://did/one.papili.comment/rkey 58 + cid: text('cid').notNull(), // Content hash 59 + authorDid: text('author_did').notNull(), 60 + rkey: text('rkey').notNull(), 61 + // StrongRef to the root post 62 + postUri: text('post_uri').notNull(), 63 + postCid: text('post_cid').notNull(), 64 + // StrongRef to parent comment (null for top-level) 65 + parentUri: text('parent_uri'), 66 + parentCid: text('parent_cid'), 67 + text: text('text').notNull(), 68 + createdAt: text('created_at').notNull(), 69 + indexedAt: text('indexed_at').notNull(), 70 + voteCount: integer('vote_count').notNull().default(0) 71 + }); 72 + 73 + // ============================================================================ 74 + // Voting tables 75 + // ============================================================================ 76 + 77 + // Votes - private, stored locally only (not published to ATProto) 78 + export const votes = sqliteTable( 79 + 'votes', 80 + { 81 + id: integer('id').primaryKey({ autoIncrement: true }), 82 + userDid: text('user_did').notNull(), 83 + targetUri: text('target_uri').notNull(), // URI of post or comment 84 + targetType: text('target_type').notNull(), // 'post' or 'comment' 85 + value: integer('value').notNull(), // 1 (upvote) or -1 (downvote) 86 + createdAt: text('created_at').notNull() 87 + }, 88 + (table) => [uniqueIndex('votes_user_target_idx').on(table.userDid, table.targetUri)] 89 + );
+91 -18
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import '../app.css'; 3 3 import favicon from '$lib/assets/favicon.svg'; 4 - import ThemeToggle from '$lib/components/ThemeToggle.svelte'; 5 4 import Logo from '$lib/components/Logo.svelte'; 6 5 7 6 let { children, data } = $props(); 7 + let menuOpen = $state(false); 8 + 9 + function closeMenu() { 10 + menuOpen = false; 11 + } 8 12 </script> 9 13 10 14 <svelte:head> 11 15 <link rel="icon" href={favicon} /> 12 16 </svelte:head> 13 17 18 + <!-- Close menu when clicking outside --> 19 + {#if menuOpen} 20 + <!-- svelte-ignore a11y_click_events_have_key_events --> 21 + <!-- svelte-ignore a11y_no_static_element_interactions --> 22 + <div class="fixed inset-0 z-40" onclick={closeMenu}></div> 23 + {/if} 24 + 14 25 <div class="min-h-screen bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100"> 15 26 <header class="bg-violet-600 dark:bg-violet-700"> 16 - <nav class="mx-auto flex max-w-4xl items-center gap-4 px-4 py-2 text-sm"> 27 + <nav class="mx-auto flex max-w-4xl items-center gap-3 px-2 sm:px-4 py-2 text-sm"> 17 28 <Logo /> 18 29 <a href="/new" class="text-violet-200 hover:text-white">new</a> 30 + 19 31 <div class="flex-1"></div> 20 - {#if data.user} 21 - <a href="/submit" class="text-violet-200 hover:text-white">submit</a> 22 - <span class="text-violet-300">|</span> 23 - <a href="/profile/{data.user.did}" class="text-violet-200 hover:text-white">{data.user.handle}</a> 24 - <span class="text-violet-300">|</span> 25 - <a href="/logout" class="text-violet-200 hover:text-white">logout</a> 26 - {:else if data.did} 27 - <span class="text-violet-300">{data.did.slice(0, 16)}...</span> 28 - <span class="text-violet-300">|</span> 29 - <a href="/logout" class="text-violet-200 hover:text-white">logout</a> 30 - {:else} 31 - <a href="/login" class="text-violet-200 hover:text-white">login</a> 32 - {/if} 33 - <ThemeToggle /> 32 + 33 + <!-- Desktop nav --> 34 + <div class="hidden sm:flex items-center gap-3"> 35 + {#if data.user} 36 + <a href="/submit" class="text-violet-200 hover:text-white">submit</a> 37 + <span class="text-violet-300">|</span> 38 + <a href="/profile/{data.user.did}" class="text-violet-200 hover:text-white">{data.user.handle}</a> 39 + <span class="text-violet-300">|</span> 40 + <a href="/logout" class="text-violet-200 hover:text-white">logout</a> 41 + {:else if data.did} 42 + <span class="text-violet-300 text-xs">{data.did.slice(0, 16)}...</span> 43 + <span class="text-violet-300">|</span> 44 + <a href="/logout" class="text-violet-200 hover:text-white">logout</a> 45 + {:else} 46 + <a href="/login" class="text-violet-200 hover:text-white">login</a> 47 + {/if} 48 + </div> 49 + 50 + <!-- Mobile menu button --> 51 + <div class="relative sm:hidden"> 52 + {#if data.user || data.did} 53 + <button 54 + type="button" 55 + class="flex items-center gap-1 text-violet-200 hover:text-white" 56 + onclick={() => menuOpen = !menuOpen} 57 + aria-expanded={menuOpen} 58 + aria-haspopup="true" 59 + > 60 + {#if data.user?.avatar} 61 + <img src={data.user.avatar} alt="" class="w-6 h-6 rounded-full" /> 62 + {:else} 63 + <div class="w-6 h-6 rounded-full bg-violet-500 flex items-center justify-center text-xs font-medium text-white"> 64 + {(data.user?.handle ?? data.did ?? '?').charAt(0).toUpperCase()} 65 + </div> 66 + {/if} 67 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 68 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={menuOpen ? 'M6 18L18 6M6 6l12 12' : 'M19 9l-7 7-7-7'} /> 69 + </svg> 70 + </button> 71 + 72 + <!-- Dropdown menu --> 73 + {#if menuOpen} 74 + <div class="absolute right-0 top-full mt-2 w-48 rounded-md bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black/5 dark:ring-white/10 z-50"> 75 + <div class="py-1"> 76 + <a 77 + href="/submit" 78 + class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700" 79 + onclick={closeMenu} 80 + > 81 + Submit link 82 + </a> 83 + {#if data.user} 84 + <a 85 + href="/profile/{data.user.did}" 86 + class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700" 87 + onclick={closeMenu} 88 + > 89 + Profile 90 + </a> 91 + {/if} 92 + <hr class="my-1 border-gray-200 dark:border-gray-700" /> 93 + <a 94 + href="/logout" 95 + class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700" 96 + onclick={closeMenu} 97 + > 98 + Log out 99 + </a> 100 + </div> 101 + </div> 102 + {/if} 103 + {:else} 104 + <a href="/login" class="text-violet-200 hover:text-white">login</a> 105 + {/if} 106 + </div> 34 107 </nav> 35 108 </header> 36 109 37 - <main class="mx-auto max-w-4xl px-4 py-4"> 110 + <main class="mx-auto max-w-4xl px-2 sm:px-4 py-4"> 38 111 {@render children()} 39 112 </main> 40 113 41 114 <footer class="border-t border-gray-200 dark:border-gray-800 mt-8"> 42 - <div class="mx-auto max-w-4xl px-4 py-4 text-center text-xs text-gray-500 dark:text-gray-400"> 115 + <div class="mx-auto max-w-4xl px-2 sm:px-4 py-4 text-center text-xs text-gray-500 dark:text-gray-400"> 43 116 <a href="https://atproto.com" class="hover:underline" target="_blank" rel="noopener">Powered by ATProto</a> 44 117 </div> 45 118 </footer>
+38 -9
src/routes/+page.server.ts
··· 1 1 import type { PageServerLoad } from './$types'; 2 2 import { db } from '$lib/server/db'; 3 - import { posts } from '$lib/server/db/schema'; 4 - import { desc } from 'drizzle-orm'; 3 + import { posts, comments, votes } from '$lib/server/db/schema'; 4 + import { desc, eq, count, and, inArray } from 'drizzle-orm'; 5 5 6 6 interface AuthorProfile { 7 7 did: string; ··· 42 42 return profiles; 43 43 } 44 44 45 - export const load: PageServerLoad = async () => { 46 - // Fetch recent posts, ordered by creation time (newest first) 45 + export const load: PageServerLoad = async ({ locals }) => { 46 + // Fetch recent posts with comment counts and vote counts 47 47 const recentPosts = await db 48 - .select() 48 + .select({ 49 + uri: posts.uri, 50 + cid: posts.cid, 51 + authorDid: posts.authorDid, 52 + rkey: posts.rkey, 53 + url: posts.url, 54 + title: posts.title, 55 + text: posts.text, 56 + createdAt: posts.createdAt, 57 + indexedAt: posts.indexedAt, 58 + voteCount: posts.voteCount, 59 + commentCount: count(comments.uri) 60 + }) 49 61 .from(posts) 62 + .leftJoin(comments, eq(comments.postUri, posts.uri)) 63 + .groupBy(posts.uri) 50 64 .orderBy(desc(posts.createdAt)) 51 65 .limit(50); 52 66 ··· 54 68 const authorDids = recentPosts.map((p) => p.authorDid); 55 69 const profiles = await fetchProfiles(authorDids); 56 70 57 - // Combine posts with author profiles 58 - const postsWithAuthors = recentPosts.map((post) => ({ 71 + // Fetch user's votes for these posts (if logged in) 72 + const userVotes = new Map<string, number>(); 73 + if (locals.did && recentPosts.length > 0) { 74 + const postUris = recentPosts.map((p) => p.uri); 75 + const userVoteRows = await db 76 + .select({ targetUri: votes.targetUri, value: votes.value }) 77 + .from(votes) 78 + .where(and(eq(votes.userDid, locals.did), inArray(votes.targetUri, postUris))); 79 + 80 + for (const vote of userVoteRows) { 81 + userVotes.set(vote.targetUri, vote.value); 82 + } 83 + } 84 + 85 + // Combine posts with author profiles and user votes 86 + const postsWithData = recentPosts.map((post) => ({ 59 87 ...post, 60 88 author: profiles.get(post.authorDid) ?? { 61 89 did: post.authorDid, 62 90 handle: post.authorDid.slice(0, 20) + '...' 63 - } 91 + }, 92 + userVote: userVotes.get(post.uri) ?? 0 64 93 })); 65 94 66 95 return { 67 - posts: postsWithAuthors 96 + posts: postsWithData 68 97 }; 69 98 };
+3 -60
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 - let { data } = $props(); 3 - 4 - function formatTimeAgo(dateString: string): string { 5 - const date = new Date(dateString); 6 - const now = new Date(); 7 - const diffMs = now.getTime() - date.getTime(); 8 - const diffMins = Math.floor(diffMs / 60000); 9 - const diffHours = Math.floor(diffMs / 3600000); 10 - const diffDays = Math.floor(diffMs / 86400000); 11 - 12 - if (diffMins < 1) return 'just now'; 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`; 17 - return date.toLocaleDateString(); 18 - } 2 + import PostList from '$lib/components/PostList.svelte'; 19 3 20 - function getDomain(url: string): string { 21 - try { 22 - return new URL(url).hostname.replace(/^www\./, ''); 23 - } catch { 24 - return url; 25 - } 26 - } 4 + let { data } = $props(); 27 5 </script> 28 6 29 7 <svelte:head> 30 8 <title>papili</title> 31 9 </svelte:head> 32 10 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-violet-600 hover:underline dark:text-violet-400">submit a link</a>. 38 - </p> 39 - </div> 40 - {:else} 41 - <ol class="space-y-2"> 42 - {#each data.posts as post, i (post.uri)} 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> 45 - <div class="flex-1 min-w-0"> 46 - <div> 47 - <a 48 - href={post.url} 49 - target="_blank" 50 - rel="noopener noreferrer" 51 - class="text-gray-900 dark:text-gray-100 visited:text-gray-500 dark:visited:text-gray-400 hover:underline" 52 - > 53 - {post.title} 54 - </a> 55 - <span class="text-xs text-gray-400 dark:text-gray-500 ml-1"> 56 - ({getDomain(post.url)}) 57 - </span> 58 - </div> 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> 63 - </div> 64 - </div> 65 - </li> 66 - {/each} 67 - </ol> 68 - {/if} 11 + <PostList posts={data.posts} canVote={!!data.user} />
+82
src/routes/api/vote/+server.ts
··· 1 + import { json } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + import { db } from '$lib/server/db'; 4 + import { votes, posts, comments } from '$lib/server/db/schema'; 5 + import { eq, and, sql } from 'drizzle-orm'; 6 + import { getCurrentDid } from '$lib/server/auth/session'; 7 + 8 + export const POST: RequestHandler = async ({ request, cookies }) => { 9 + const userDid = await getCurrentDid(cookies); 10 + if (!userDid) { 11 + return json({ error: 'Not authenticated' }, { status: 401 }); 12 + } 13 + 14 + const body = await request.json(); 15 + const { targetUri, targetType, value } = body; 16 + 17 + // Validate input 18 + if (!targetUri || typeof targetUri !== 'string') { 19 + return json({ error: 'targetUri is required' }, { status: 400 }); 20 + } 21 + 22 + if (targetType !== 'post' && targetType !== 'comment') { 23 + return json({ error: 'targetType must be "post" or "comment"' }, { status: 400 }); 24 + } 25 + 26 + if (value !== 1 && value !== 0) { 27 + return json({ error: 'value must be 1 or 0' }, { status: 400 }); 28 + } 29 + 30 + const now = new Date().toISOString(); 31 + 32 + // Get existing vote 33 + const [existingVote] = await db 34 + .select() 35 + .from(votes) 36 + .where(and(eq(votes.userDid, userDid), eq(votes.targetUri, targetUri))) 37 + .limit(1); 38 + 39 + const oldValue = existingVote?.value ?? 0; 40 + const delta = value - oldValue; 41 + 42 + if (delta === 0) { 43 + // No change needed 44 + return json({ success: true, newValue: value }); 45 + } 46 + 47 + // Update or insert vote 48 + if (value === 0) { 49 + // Remove vote 50 + await db.delete(votes).where(and(eq(votes.userDid, userDid), eq(votes.targetUri, targetUri))); 51 + } else if (existingVote) { 52 + // Update existing vote 53 + await db 54 + .update(votes) 55 + .set({ value, createdAt: now }) 56 + .where(and(eq(votes.userDid, userDid), eq(votes.targetUri, targetUri))); 57 + } else { 58 + // Insert new vote 59 + await db.insert(votes).values({ 60 + userDid, 61 + targetUri, 62 + targetType, 63 + value, 64 + createdAt: now 65 + }); 66 + } 67 + 68 + // Update vote count on target 69 + if (targetType === 'post') { 70 + await db 71 + .update(posts) 72 + .set({ voteCount: sql`${posts.voteCount} + ${delta}` }) 73 + .where(eq(posts.uri, targetUri)); 74 + } else { 75 + await db 76 + .update(comments) 77 + .set({ voteCount: sql`${comments.voteCount} + ${delta}` }) 78 + .where(eq(comments.uri, targetUri)); 79 + } 80 + 81 + return json({ success: true, newValue: value, delta }); 82 + };
+95
src/routes/new/+page.server.ts
··· 1 + import type { PageServerLoad } from './$types'; 2 + import { db } from '$lib/server/db'; 3 + import { posts, comments, votes } from '$lib/server/db/schema'; 4 + import { desc, eq, count, and, inArray } 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 + const uniqueDids = [...new Set(dids)]; 15 + 16 + const results = await Promise.all( 17 + uniqueDids.map(async (did) => { 18 + try { 19 + const res = await fetch( 20 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}` 21 + ); 22 + if (!res.ok) return null; 23 + const data = await res.json(); 24 + return { 25 + did: data.did, 26 + handle: data.handle, 27 + avatar: data.avatar 28 + } as AuthorProfile; 29 + } catch { 30 + return null; 31 + } 32 + }) 33 + ); 34 + 35 + for (const profile of results) { 36 + if (profile) { 37 + profiles.set(profile.did, profile); 38 + } 39 + } 40 + 41 + return profiles; 42 + } 43 + 44 + export const load: PageServerLoad = async ({ locals }) => { 45 + // Fetch posts ordered by creation time (newest first) with comment counts and vote counts 46 + const recentPosts = await db 47 + .select({ 48 + uri: posts.uri, 49 + cid: posts.cid, 50 + authorDid: posts.authorDid, 51 + rkey: posts.rkey, 52 + url: posts.url, 53 + title: posts.title, 54 + text: posts.text, 55 + createdAt: posts.createdAt, 56 + indexedAt: posts.indexedAt, 57 + voteCount: posts.voteCount, 58 + commentCount: count(comments.uri) 59 + }) 60 + .from(posts) 61 + .leftJoin(comments, eq(comments.postUri, posts.uri)) 62 + .groupBy(posts.uri) 63 + .orderBy(desc(posts.createdAt)) 64 + .limit(50); 65 + 66 + const authorDids = recentPosts.map((p) => p.authorDid); 67 + const profiles = await fetchProfiles(authorDids); 68 + 69 + // Fetch user's votes for these posts (if logged in) 70 + const userVotes = new Map<string, number>(); 71 + if (locals.did && recentPosts.length > 0) { 72 + const postUris = recentPosts.map((p) => p.uri); 73 + const userVoteRows = await db 74 + .select({ targetUri: votes.targetUri, value: votes.value }) 75 + .from(votes) 76 + .where(and(eq(votes.userDid, locals.did), inArray(votes.targetUri, postUris))); 77 + 78 + for (const vote of userVoteRows) { 79 + userVotes.set(vote.targetUri, vote.value); 80 + } 81 + } 82 + 83 + const postsWithData = recentPosts.map((post) => ({ 84 + ...post, 85 + author: profiles.get(post.authorDid) ?? { 86 + did: post.authorDid, 87 + handle: post.authorDid.slice(0, 20) + '...' 88 + }, 89 + userVote: userVotes.get(post.uri) ?? 0 90 + })); 91 + 92 + return { 93 + posts: postsWithData 94 + }; 95 + };
+11
src/routes/new/+page.svelte
··· 1 + <script lang="ts"> 2 + import PostList from '$lib/components/PostList.svelte'; 3 + 4 + let { data } = $props(); 5 + </script> 6 + 7 + <svelte:head> 8 + <title>New - papili</title> 9 + </svelte:head> 10 + 11 + <PostList posts={data.posts} canVote={!!data.user} />
+2 -1
src/routes/page.svelte.spec.ts
··· 21 21 }); 22 22 23 23 it('should render posts list', async () => { 24 - // @ts-expect-error - vitest-browser-svelte types don't match runtime API 24 + // @ts-expect-error - vitest-browser-svelte types issue 25 25 render(Page, { 26 26 props: { 27 27 data: { ··· 38 38 text: null, 39 39 createdAt: new Date().toISOString(), 40 40 indexedAt: new Date().toISOString(), 41 + commentCount: 0, 41 42 author: { 42 43 did: 'did:plc:test', 43 44 handle: 'test.bsky.social'
+213
src/routes/post/[rkey]/+page.server.ts
··· 1 + import { error, fail, redirect } from '@sveltejs/kit'; 2 + import type { Actions, PageServerLoad } from './$types'; 3 + import { db } from '$lib/server/db'; 4 + import { posts, comments, votes } from '$lib/server/db/schema'; 5 + import { eq, asc, and, inArray } from 'drizzle-orm'; 6 + import { getLexClient, AuthRequiredError } from '$lib/server/lex-client'; 7 + import { generateTid } from '$lib/server/tid'; 8 + import { cidForLex } from '@atproto/lex-cbor'; 9 + import * as comment from '$lib/lexicons/one/papili/comment.defs'; 10 + import type { l } from '@atproto/lex'; 11 + 12 + interface AuthorProfile { 13 + did: string; 14 + handle: string; 15 + avatar?: string; 16 + } 17 + 18 + async function fetchProfiles(dids: string[]): Promise<Map<string, AuthorProfile>> { 19 + const profiles = new Map<string, AuthorProfile>(); 20 + const uniqueDids = [...new Set(dids)]; 21 + 22 + const results = await Promise.all( 23 + uniqueDids.map(async (did) => { 24 + try { 25 + const res = await fetch( 26 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}` 27 + ); 28 + if (!res.ok) return null; 29 + const data = await res.json(); 30 + return { 31 + did: data.did, 32 + handle: data.handle, 33 + avatar: data.avatar 34 + } as AuthorProfile; 35 + } catch { 36 + return null; 37 + } 38 + }) 39 + ); 40 + 41 + for (const profile of results) { 42 + if (profile) { 43 + profiles.set(profile.did, profile); 44 + } 45 + } 46 + 47 + return profiles; 48 + } 49 + 50 + export const load: PageServerLoad = async ({ params, locals }) => { 51 + const { rkey } = params; 52 + 53 + // Find post by rkey 54 + const [post] = await db 55 + .select() 56 + .from(posts) 57 + .where(eq(posts.rkey, rkey)) 58 + .limit(1); 59 + 60 + if (!post) { 61 + error(404, 'Post not found'); 62 + } 63 + 64 + // Load comments for this post 65 + const postComments = await db 66 + .select() 67 + .from(comments) 68 + .where(eq(comments.postUri, post.uri)) 69 + .orderBy(asc(comments.createdAt)); 70 + 71 + // Collect all DIDs for profile fetching 72 + const allDids = [post.authorDid, ...postComments.map((c) => c.authorDid)]; 73 + const profiles = await fetchProfiles(allDids); 74 + 75 + // Fetch user's votes (if logged in) 76 + const userVotes = new Map<string, number>(); 77 + if (locals.did) { 78 + const allUris = [post.uri, ...postComments.map((c) => c.uri)]; 79 + const userVoteRows = await db 80 + .select({ targetUri: votes.targetUri, value: votes.value }) 81 + .from(votes) 82 + .where(and(eq(votes.userDid, locals.did), inArray(votes.targetUri, allUris))); 83 + 84 + for (const vote of userVoteRows) { 85 + userVotes.set(vote.targetUri, vote.value); 86 + } 87 + } 88 + 89 + // Get author for post 90 + const postAuthor = profiles.get(post.authorDid) ?? { 91 + did: post.authorDid, 92 + handle: post.authorDid.slice(0, 20) + '...' 93 + }; 94 + 95 + // Build comments with authors and votes 96 + const commentsWithAuthors = postComments.map((c) => ({ 97 + ...c, 98 + author: profiles.get(c.authorDid) ?? { 99 + did: c.authorDid, 100 + handle: c.authorDid.slice(0, 20) + '...' 101 + }, 102 + userVote: userVotes.get(c.uri) ?? 0 103 + })); 104 + 105 + return { 106 + post: { 107 + ...post, 108 + author: postAuthor, 109 + userVote: userVotes.get(post.uri) ?? 0 110 + }, 111 + comments: commentsWithAuthors 112 + }; 113 + }; 114 + 115 + export const actions: Actions = { 116 + comment: async ({ request, cookies, params }) => { 117 + const { rkey } = params; 118 + 119 + // Get authenticated client 120 + let client; 121 + try { 122 + client = await getLexClient(cookies); 123 + } catch (err) { 124 + if (err instanceof AuthRequiredError) { 125 + redirect(302, '/login'); 126 + } 127 + throw err; 128 + } 129 + 130 + const authorDid = client.assertDid; 131 + 132 + // Get the post 133 + const [post] = await db 134 + .select() 135 + .from(posts) 136 + .where(eq(posts.rkey, rkey)) 137 + .limit(1); 138 + 139 + if (!post) { 140 + return fail(404, { error: 'Post not found' }); 141 + } 142 + 143 + // Parse form data 144 + const formData = await request.formData(); 145 + const text = formData.get('text')?.toString()?.trim(); 146 + const parentUri = formData.get('parentUri')?.toString() || undefined; 147 + const parentCid = formData.get('parentCid')?.toString() || undefined; 148 + 149 + if (!text) { 150 + return fail(400, { error: 'Comment text is required' }); 151 + } 152 + 153 + if (text.length > 10000) { 154 + return fail(400, { error: 'Comment must be 10,000 characters or less' }); 155 + } 156 + 157 + const now = new Date().toISOString(); 158 + const commentRkey = generateTid(); 159 + const uri = `at://${authorDid}/one.papili.comment/${commentRkey}`; 160 + 161 + // Build the record 162 + const commentRecord: comment.Main = { 163 + $type: 'one.papili.comment', 164 + post: { 165 + uri: post.uri as l.AtUriString, 166 + cid: post.cid as l.CidString 167 + }, 168 + text, 169 + createdAt: now as l.DatetimeString, 170 + ...(parentUri && parentCid 171 + ? { parent: { uri: parentUri as l.AtUriString, cid: parentCid as l.CidString } } 172 + : {}) 173 + }; 174 + 175 + // Calculate CID 176 + const cid = (await cidForLex(commentRecord)).toString(); 177 + 178 + // Write to local DB (optimistic) 179 + await db.insert(comments).values({ 180 + uri, 181 + cid, 182 + authorDid, 183 + rkey: commentRkey, 184 + postUri: post.uri, 185 + postCid: post.cid, 186 + parentUri: parentUri || null, 187 + parentCid: parentCid || null, 188 + text, 189 + createdAt: now, 190 + indexedAt: now 191 + }); 192 + 193 + // Fire-and-forget PDS write 194 + client 195 + .create( 196 + comment.main, 197 + { 198 + post: { uri: post.uri as l.AtUriString, cid: post.cid as l.CidString }, 199 + text, 200 + createdAt: now as l.DatetimeString, 201 + ...(parentUri && parentCid 202 + ? { parent: { uri: parentUri as l.AtUriString, cid: parentCid as l.CidString } } 203 + : {}) 204 + }, 205 + { rkey: commentRkey } 206 + ) 207 + .catch((err) => { 208 + console.error(`[pds] Failed to write comment ${uri} to PDS:`, err); 209 + }); 210 + 211 + return { success: true }; 212 + } 213 + };
+536
src/routes/post/[rkey]/+page.svelte
··· 1 + <script lang="ts"> 2 + import { enhance } from '$app/forms'; 3 + import { SvelteMap, SvelteSet } from 'svelte/reactivity'; 4 + import Avatar from '$lib/components/Avatar.svelte'; 5 + import VoteButton from '$lib/components/VoteButton.svelte'; 6 + 7 + let { data, form } = $props(); 8 + let canVote = $derived(!!data.user); 9 + let submitting = $state(false); 10 + let commentText = $state(''); 11 + let replyingTo = $state<{ uri: string; cid: string; handle: string } | null>(null); 12 + let replyText = $state(''); 13 + let collapsed = new SvelteSet<string>(); 14 + 15 + interface Comment { 16 + uri: string; 17 + cid: string; 18 + rkey: string; 19 + text: string; 20 + createdAt: string; 21 + parentUri: string | null; 22 + voteCount: number; 23 + userVote: number; 24 + author: { 25 + did: string; 26 + handle: string; 27 + avatar?: string; 28 + }; 29 + } 30 + 31 + function startReply(comment: Comment) { 32 + replyingTo = { uri: comment.uri, cid: comment.cid, handle: comment.author.handle }; 33 + replyText = ''; 34 + } 35 + 36 + function cancelReply() { 37 + replyingTo = null; 38 + replyText = ''; 39 + } 40 + 41 + function toggleCollapse(uri: string) { 42 + if (collapsed.has(uri)) { 43 + collapsed.delete(uri); 44 + } else { 45 + collapsed.add(uri); 46 + } 47 + } 48 + 49 + function countDescendants(uri: string): number { 50 + const children = commentTree.get(uri) ?? []; 51 + let count = children.length; 52 + for (const child of children) { 53 + count += countDescendants(child.uri); 54 + } 55 + return count; 56 + } 57 + 58 + // Build threaded comment structure 59 + function buildCommentTree(comments: Comment[]): SvelteMap<string | null, Comment[]> { 60 + const tree = new SvelteMap<string | null, Comment[]>(); 61 + for (const comment of comments) { 62 + const parentKey = comment.parentUri; 63 + if (!tree.has(parentKey)) { 64 + tree.set(parentKey, []); 65 + } 66 + tree.get(parentKey)!.push(comment); 67 + } 68 + return tree; 69 + } 70 + 71 + let commentTree = $derived(buildCommentTree(data.comments)); 72 + 73 + function formatTimeAgo(dateString: string): string { 74 + const date = new Date(dateString); 75 + const now = new Date(); 76 + const diffMs = now.getTime() - date.getTime(); 77 + const diffMins = Math.floor(diffMs / 60000); 78 + const diffHours = Math.floor(diffMs / 3600000); 79 + const diffDays = Math.floor(diffMs / 86400000); 80 + 81 + if (diffMins < 1) return 'now'; 82 + if (diffMins < 60) return `${diffMins}m`; 83 + if (diffHours < 24) return `${diffHours}h`; 84 + if (diffDays < 7) return `${diffDays}d`; 85 + if (diffDays < 365) return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); 86 + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); 87 + } 88 + 89 + function getDomain(url: string): string { 90 + try { 91 + return new URL(url).hostname.replace(/^www\./, ''); 92 + } catch { 93 + return url; 94 + } 95 + } 96 + </script> 97 + 98 + <svelte:head> 99 + <title>{data.post.title} - papili</title> 100 + </svelte:head> 101 + 102 + <article class="space-y-4"> 103 + <header class="flex gap-3"> 104 + {#if canVote} 105 + <VoteButton 106 + targetUri={data.post.uri} 107 + targetType="post" 108 + voteCount={data.post.voteCount ?? 0} 109 + userVote={data.post.userVote} 110 + /> 111 + {/if} 112 + <div> 113 + <h1 class="text-lg font-medium"> 114 + <a 115 + href={data.post.url} 116 + target="_blank" 117 + rel="noopener noreferrer" 118 + class="text-gray-900 dark:text-gray-100 hover:underline" 119 + > 120 + {data.post.title} 121 + </a> 122 + <span class="text-sm text-gray-400 dark:text-gray-500 font-normal ml-1"> 123 + ({getDomain(data.post.url)}) 124 + </span> 125 + </h1> 126 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1 flex items-center gap-1"> 127 + {#if !canVote} 128 + {data.post.voteCount ?? 0} point{data.post.voteCount === 1 ? '' : 's'} · 129 + {/if} 130 + by 131 + <Avatar 132 + handle={data.post.author.handle} 133 + avatar={data.post.author.avatar} 134 + did={data.post.author.did} 135 + size="xs" 136 + showHandle 137 + link 138 + /> 139 + · {formatTimeAgo(data.post.createdAt)} 140 + </p> 141 + </div> 142 + </header> 143 + 144 + {#if data.post.text} 145 + <div class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap"> 146 + {data.post.text} 147 + </div> 148 + {/if} 149 + 150 + <hr class="border-gray-200 dark:border-gray-800" /> 151 + 152 + <section class="space-y-4"> 153 + <h2 class="text-sm font-medium text-gray-700 dark:text-gray-300"> 154 + {data.comments.length === 0 ? 'No comments yet' : `${data.comments.length} comment${data.comments.length === 1 ? '' : 's'}`} 155 + </h2> 156 + 157 + <!-- Comment form --> 158 + {#if data.user} 159 + {#if form?.error} 160 + <div class="p-3 text-sm bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400 rounded"> 161 + {form.error} 162 + </div> 163 + {/if} 164 + 165 + <form 166 + method="POST" 167 + action="?/comment" 168 + use:enhance={() => { 169 + submitting = true; 170 + return async ({ update, result }) => { 171 + await update(); 172 + submitting = false; 173 + if (result.type === 'success') { 174 + commentText = ''; 175 + } 176 + }; 177 + }} 178 + class="space-y-2" 179 + > 180 + <textarea 181 + name="text" 182 + bind:value={commentText} 183 + placeholder="Add a comment..." 184 + rows="3" 185 + maxlength="10000" 186 + 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-violet-500 focus:border-transparent resize-y" 187 + ></textarea> 188 + <button 189 + type="submit" 190 + disabled={submitting || !commentText.trim()} 191 + class="px-4 py-1.5 text-sm font-medium text-white bg-violet-600 hover:bg-violet-700 rounded disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" 192 + > 193 + {#if submitting} 194 + Posting... 195 + {:else} 196 + Post Comment 197 + {/if} 198 + </button> 199 + </form> 200 + {:else} 201 + <p class="text-sm text-gray-500 dark:text-gray-400"> 202 + <a href="/login" class="text-violet-600 dark:text-violet-400 hover:underline">Sign in</a> to comment 203 + </p> 204 + {/if} 205 + 206 + <!-- Comments list --> 207 + {#if data.comments.length > 0} 208 + <div class="comments-tree"> 209 + {#each commentTree.get(null) ?? [] as comment (comment.uri)} 210 + {@render commentNode(comment, 0)} 211 + {/each} 212 + </div> 213 + {/if} 214 + </section> 215 + </article> 216 + 217 + {#snippet commentNode(comment: Comment, depth: number)} 218 + {@const hasChildren = (commentTree.get(comment.uri)?.length ?? 0) > 0} 219 + {@const isCollapsed = collapsed.has(comment.uri)} 220 + {@const descendantCount = isCollapsed ? countDescendants(comment.uri) : 0} 221 + 222 + <div class="comment-wrapper" class:is-reply={depth > 0}> 223 + <!-- Thread line (clickable to collapse) --> 224 + {#if depth > 0} 225 + <button 226 + type="button" 227 + class="thread-line" 228 + onclick={() => toggleCollapse(comment.uri)} 229 + title={isCollapsed ? 'Expand thread' : 'Collapse thread'} 230 + aria-label={isCollapsed ? 'Expand thread' : 'Collapse thread'} 231 + ></button> 232 + {/if} 233 + 234 + <div class="comment-content"> 235 + <!-- Comment header --> 236 + <div class="comment-meta"> 237 + {#if hasChildren} 238 + <button 239 + type="button" 240 + class="collapse-toggle" 241 + onclick={() => toggleCollapse(comment.uri)} 242 + title={isCollapsed ? 'Expand' : 'Collapse'} 243 + > 244 + {isCollapsed ? '[+]' : '[−]'} 245 + </button> 246 + {/if} 247 + <Avatar 248 + handle={comment.author.handle} 249 + avatar={comment.author.avatar} 250 + did={comment.author.did} 251 + size="xs" 252 + showHandle 253 + link 254 + /> 255 + <span class="comment-time" title={new Date(comment.createdAt).toLocaleString()}> 256 + {formatTimeAgo(comment.createdAt)} 257 + </span> 258 + {#if canVote && !isCollapsed} 259 + <div class="comment-vote"> 260 + <VoteButton 261 + targetUri={comment.uri} 262 + targetType="comment" 263 + voteCount={comment.voteCount} 264 + userVote={comment.userVote} 265 + /> 266 + </div> 267 + {:else if !isCollapsed} 268 + <span class="comment-points">{comment.voteCount} pt{comment.voteCount === 1 ? '' : 's'}</span> 269 + {/if} 270 + {#if data.user && !isCollapsed} 271 + <button 272 + type="button" 273 + onclick={() => startReply(comment)} 274 + class="comment-action" 275 + > 276 + reply 277 + </button> 278 + {/if} 279 + </div> 280 + 281 + <!-- Collapsed indicator --> 282 + {#if isCollapsed} 283 + <div class="collapsed-info"> 284 + {descendantCount} {descendantCount === 1 ? 'reply' : 'replies'} hidden 285 + </div> 286 + {:else} 287 + <!-- Comment body --> 288 + <div class="comment-body"> 289 + {comment.text} 290 + </div> 291 + 292 + <!-- Inline reply form --> 293 + {#if replyingTo?.uri === comment.uri} 294 + <form 295 + method="POST" 296 + action="?/comment" 297 + use:enhance={() => { 298 + submitting = true; 299 + return async ({ update, result }) => { 300 + await update(); 301 + submitting = false; 302 + if (result.type === 'success') { 303 + cancelReply(); 304 + } 305 + }; 306 + }} 307 + class="reply-form" 308 + > 309 + <input type="hidden" name="parentUri" value={replyingTo.uri} /> 310 + <input type="hidden" name="parentCid" value={replyingTo.cid} /> 311 + <textarea 312 + name="text" 313 + bind:value={replyText} 314 + placeholder="Reply to {replyingTo.handle}..." 315 + rows="2" 316 + maxlength="10000" 317 + 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-violet-500 focus:border-transparent resize-y" 318 + ></textarea> 319 + <div class="reply-actions"> 320 + <button 321 + type="submit" 322 + disabled={submitting || !replyText.trim()} 323 + class="px-3 py-1 text-sm font-medium text-white bg-violet-600 hover:bg-violet-700 rounded disabled:opacity-50 disabled:cursor-not-allowed" 324 + > 325 + {submitting ? 'Posting...' : 'Reply'} 326 + </button> 327 + <button 328 + type="button" 329 + onclick={cancelReply} 330 + class="px-3 py-1 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200" 331 + > 332 + Cancel 333 + </button> 334 + </div> 335 + </form> 336 + {/if} 337 + 338 + <!-- Nested replies --> 339 + {#if hasChildren} 340 + <div class="comment-children"> 341 + {#each commentTree.get(comment.uri) ?? [] as reply (reply.uri)} 342 + {@render commentNode(reply, depth + 1)} 343 + {/each} 344 + </div> 345 + {/if} 346 + {/if} 347 + </div> 348 + </div> 349 + {/snippet} 350 + 351 + <style> 352 + .comments-tree { 353 + font-size: 0.875rem; 354 + } 355 + 356 + .comment-wrapper { 357 + position: relative; 358 + display: flex; 359 + margin-top: 0.75rem; 360 + } 361 + 362 + .comment-wrapper:first-child { 363 + margin-top: 0; 364 + } 365 + 366 + .comment-wrapper.is-reply { 367 + padding-left: 1rem; 368 + } 369 + 370 + .thread-line { 371 + position: absolute; 372 + left: 0; 373 + top: 0; 374 + bottom: 0; 375 + width: 1rem; 376 + background: transparent; 377 + border: none; 378 + cursor: pointer; 379 + padding: 0; 380 + } 381 + 382 + .thread-line::before { 383 + content: ''; 384 + position: absolute; 385 + left: 0.375rem; 386 + top: 0; 387 + bottom: 0; 388 + width: 2px; 389 + background: #e5e7eb; 390 + border-radius: 1px; 391 + transition: background-color 0.15s; 392 + } 393 + 394 + @media (prefers-color-scheme: dark) { 395 + .thread-line::before { 396 + background: #374151; 397 + } 398 + } 399 + 400 + .thread-line:hover::before { 401 + background: #a78bfa; 402 + } 403 + 404 + .comment-content { 405 + flex: 1; 406 + min-width: 0; 407 + } 408 + 409 + .comment-meta { 410 + display: flex; 411 + flex-wrap: wrap; 412 + align-items: center; 413 + gap: 0.375rem; 414 + font-size: 0.75rem; 415 + color: #6b7280; 416 + margin-bottom: 0.25rem; 417 + } 418 + 419 + @media (prefers-color-scheme: dark) { 420 + .comment-meta { 421 + color: #9ca3af; 422 + } 423 + } 424 + 425 + .collapse-toggle { 426 + font-family: ui-monospace, monospace; 427 + font-size: 0.625rem; 428 + color: #6b7280; 429 + background: none; 430 + border: none; 431 + padding: 0; 432 + cursor: pointer; 433 + line-height: 1; 434 + } 435 + 436 + .collapse-toggle:hover { 437 + color: #a78bfa; 438 + } 439 + 440 + .comment-time { 441 + color: #6b7280; 442 + } 443 + 444 + .comment-action { 445 + color: #8b5cf6; 446 + background: none; 447 + border: none; 448 + padding: 0; 449 + cursor: pointer; 450 + font-size: inherit; 451 + } 452 + 453 + .comment-action:hover { 454 + text-decoration: underline; 455 + } 456 + 457 + .collapsed-info { 458 + font-size: 0.75rem; 459 + font-style: italic; 460 + color: #6b7280; 461 + margin-top: 0.125rem; 462 + } 463 + 464 + @media (prefers-color-scheme: dark) { 465 + .collapse-toggle { 466 + color: #9ca3af; 467 + } 468 + 469 + .comment-time { 470 + color: #9ca3af; 471 + } 472 + 473 + .collapsed-info { 474 + color: #9ca3af; 475 + } 476 + } 477 + 478 + .comment-body { 479 + color: #1f2937; 480 + white-space: pre-wrap; 481 + word-break: break-word; 482 + } 483 + 484 + @media (prefers-color-scheme: dark) { 485 + .comment-body { 486 + color: #e5e7eb; 487 + } 488 + } 489 + 490 + .reply-form { 491 + margin-top: 0.5rem; 492 + display: flex; 493 + flex-direction: column; 494 + gap: 0.5rem; 495 + } 496 + 497 + .reply-actions { 498 + display: flex; 499 + gap: 0.5rem; 500 + } 501 + 502 + .comment-children { 503 + margin-top: 0.5rem; 504 + } 505 + 506 + .comment-vote { 507 + display: inline-flex; 508 + align-items: center; 509 + } 510 + 511 + .comment-vote :global(.vote-buttons) { 512 + flex-direction: row; 513 + gap: 0.125rem; 514 + } 515 + 516 + .comment-vote :global(.vote-icon) { 517 + width: 0.75rem; 518 + height: 0.75rem; 519 + } 520 + 521 + .comment-vote :global(.vote-count) { 522 + font-size: 0.625rem; 523 + min-width: 1rem; 524 + } 525 + 526 + .comment-points { 527 + color: #6b7280; 528 + font-size: 0.75rem; 529 + } 530 + 531 + @media (prefers-color-scheme: dark) { 532 + .comment-points { 533 + color: #9ca3af; 534 + } 535 + } 536 + </style>