an atproto based link aggregator
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}