an atproto based link aggregator
at main 170 lines 5.2 kB view raw
1<script lang="ts"> 2 import { invalidateAll } from '$app/navigation'; 3 import Avatar from './Avatar.svelte'; 4 import VoteButton from './VoteButton.svelte'; 5 import PostTitle from './PostTitle.svelte'; 6 import { formatTimeAgo, getDomain } from '$lib/utils/formatting'; 7 import { pendingPosts } from '$lib/stores/pending'; 8 import type { AuthorProfile } from '$lib/types'; 9 10 interface Post { 11 uri: string; 12 rkey: string; 13 url?: string | null; 14 title: string; 15 text?: string | null; 16 createdAt: string; 17 author: AuthorProfile; 18 commentCount?: number; 19 voteCount?: number; 20 userVote?: number; 21 } 22 23 interface Props { 24 posts: Post[]; 25 emptyMessage?: string; 26 canVote?: boolean; 27 currentUserDid?: string; 28 currentUserHandle?: string; 29 } 30 31 let { 32 posts, 33 emptyMessage = 'No posts yet.', 34 canVote = false, 35 currentUserDid: _currentUserDid, 36 currentUserHandle 37 }: Props = $props(); 38 39 // Get pending posts using $ auto-subscription, filtering out duplicates 40 let realRkeys = $derived(new Set(posts.map((p) => p.rkey))); 41 let pending = $derived($pendingPosts.filter((p) => !realRkeys.has(p.rkey))); 42 43 // Reconcile pending posts when real posts change - remove any that now exist in DB 44 $effect(() => { 45 pendingPosts.reconcile([...realRkeys]); 46 }); 47 48 // Poll for updates while there are pending posts 49 $effect(() => { 50 if (pending.length === 0) return; 51 52 const interval = setInterval(() => { 53 invalidateAll(); 54 }, 2000); 55 56 return () => clearInterval(interval); 57 }); 58</script> 59 60{#if posts.length === 0 && pending.length === 0} 61 <div class="py-12 text-center text-gray-500 dark:text-gray-400"> 62 <p>{emptyMessage}</p> 63 <p class="mt-2"> 64 Be the first to <a href="/submit" class="text-violet-600 hover:underline dark:text-violet-400" 65 >submit a link</a 66 >. 67 </p> 68 </div> 69{:else} 70 <ol class="space-y-2"> 71 <!-- Pending posts (optimistic UI) --> 72 {#each pending as post (post.rkey)} 73 <li class="flex gap-2 text-sm opacity-70"> 74 <span class="w-6 text-right text-gray-400 dark:text-gray-500 select-none"> 75 <svg class="w-4 h-4 animate-spin inline" fill="none" viewBox="0 0 24 24"> 76 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" 77 ></circle> 78 <path 79 class="opacity-75" 80 fill="currentColor" 81 d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" 82 ></path> 83 </svg> 84 </span> 85 <div class="flex-1 min-w-0"> 86 <div> 87 {#if post.url} 88 <span class="text-gray-900 dark:text-gray-100"> 89 {post.title} 90 </span> 91 <span class="text-xs text-gray-400 dark:text-gray-500 ml-1"> 92 ({getDomain(post.url)}) 93 </span> 94 {:else} 95 <span class="text-gray-900 dark:text-gray-100"> 96 {post.title} 97 </span> 98 {/if} 99 </div> 100 {#if post.text} 101 <p class="text-xs text-gray-600 dark:text-gray-400 mt-0.5 line-clamp-2">{post.text}</p> 102 {/if} 103 <div class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1"> 104 by 105 {#if currentUserHandle} 106 <span class="text-violet-600 dark:text-violet-400">@{currentUserHandle}</span> 107 {:else} 108 <span class="text-violet-600 dark:text-violet-400">you</span> 109 {/if} 110 <span class="italic">posting...</span> 111 </div> 112 </div> 113 <span class="flex-shrink-0 text-xs text-gray-400 dark:text-gray-500 self-start mt-0.5" 114 >0</span 115 > 116 </li> 117 {/each} 118 <!-- Real posts --> 119 {#each posts as post, i (post.uri)} 120 <li class="flex gap-2 text-sm"> 121 {#if canVote} 122 <VoteButton 123 targetUri={post.uri} 124 targetType="post" 125 voteCount={post.voteCount ?? 0} 126 userVote={post.userVote} 127 /> 128 {:else} 129 <span class="w-6 text-right text-gray-400 dark:text-gray-500 select-none">{i + 1}.</span> 130 {/if} 131 <div class="flex-1 min-w-0"> 132 <div> 133 <PostTitle title={post.title} rkey={post.rkey} url={post.url} /> 134 </div> 135 {#if post.text} 136 <p class="text-xs text-gray-600 dark:text-gray-400 mt-0.5 line-clamp-2">{post.text}</p> 137 {/if} 138 <div class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1"> 139 by 140 <Avatar 141 handle={post.author.handle} 142 avatar={post.author.avatar} 143 did={post.author.did} 144 size="xs" 145 showHandle 146 link 147 /> 148 {formatTimeAgo(post.createdAt)} 149 </div> 150 </div> 151 <!-- Comment count with icon, right-aligned --> 152 <a 153 href="/post/{post.rkey}" 154 class="flex-shrink-0 flex items-center gap-0.5 text-xs text-gray-400 dark:text-gray-500 hover:text-violet-600 dark:hover:text-violet-400 self-start mt-0.5" 155 title="{post.commentCount ?? 0} comment{post.commentCount === 1 ? '' : 's'}" 156 > 157 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 158 <path 159 stroke-linecap="round" 160 stroke-linejoin="round" 161 stroke-width="1.5" 162 d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" 163 /> 164 </svg> 165 <span>{post.commentCount ?? 0}</span> 166 </a> 167 </li> 168 {/each} 169 </ol> 170{/if}