+8
deno.lock
+8
deno.lock
···
22
"npm:@sveltejs/vite-plugin-svelte@^6.2.1": "6.2.1_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_@types+node@25.0.3",
23
"npm:@tailwindcss/forms@~0.5.11": "0.5.11_tailwindcss@4.1.18",
24
"npm:@tailwindcss/vite@^4.1.18": "4.1.18_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_@types+node@25.0.3",
25
"npm:@types/node@^25.0.3": "25.0.3",
26
"npm:@wora/cache-persist@^2.2.1": "2.2.1",
27
"npm:async-cache-dedupe@^3.4.0": "3.4.0",
···
714
"@tailwindcss/oxide",
715
"tailwindcss",
716
"vite"
717
]
718
},
719
"@types/cookie@0.6.0": {
···
1814
"npm:@sveltejs/vite-plugin-svelte@^6.2.1",
1815
"npm:@tailwindcss/forms@~0.5.11",
1816
"npm:@tailwindcss/vite@^4.1.18",
1817
"npm:@types/node@^25.0.3",
1818
"npm:@wora/cache-persist@^2.2.1",
1819
"npm:async-cache-dedupe@^3.4.0",
···
22
"npm:@sveltejs/vite-plugin-svelte@^6.2.1": "6.2.1_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_@types+node@25.0.3",
23
"npm:@tailwindcss/forms@~0.5.11": "0.5.11_tailwindcss@4.1.18",
24
"npm:@tailwindcss/vite@^4.1.18": "4.1.18_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_@types+node@25.0.3",
25
+
"npm:@tutorlatin/svelte-tiny-virtual-list@^3.0.17": "3.0.17_svelte@5.46.1__acorn@8.15.0",
26
"npm:@types/node@^25.0.3": "25.0.3",
27
"npm:@wora/cache-persist@^2.2.1": "2.2.1",
28
"npm:async-cache-dedupe@^3.4.0": "3.4.0",
···
715
"@tailwindcss/oxide",
716
"tailwindcss",
717
"vite"
718
+
]
719
+
},
720
+
"@tutorlatin/svelte-tiny-virtual-list@3.0.17_svelte@5.46.1__acorn@8.15.0": {
721
+
"integrity": "sha512-OvFRITfbWdsFk7VR2FKVJiBMPlgbyc81hqbFORXdEcBXcT91XRdLXfhSbS8o14ntUBMFPWVv19fti+Ez50q45g==",
722
+
"dependencies": [
723
+
"svelte"
724
]
725
},
726
"@types/cookie@0.6.0": {
···
1821
"npm:@sveltejs/vite-plugin-svelte@^6.2.1",
1822
"npm:@tailwindcss/forms@~0.5.11",
1823
"npm:@tailwindcss/vite@^4.1.18",
1824
+
"npm:@tutorlatin/svelte-tiny-virtual-list@^3.0.17",
1825
"npm:@types/node@^25.0.3",
1826
"npm:@wora/cache-persist@^2.2.1",
1827
"npm:async-cache-dedupe@^3.4.0",
+1
package.json
+1
package.json
+2
-1
src/app.html
+2
-1
src/app.html
+55
-56
src/components/FollowingView.svelte
+55
-56
src/components/FollowingView.svelte
···
6
import { getRelativeTime } from '$lib/date';
7
import { generateColorForDid } from '$lib/accounts';
8
import { type AtprotoDid } from '@atcute/lexicons/syntax';
9
-
import { flip } from 'svelte/animate';
10
-
import { cubicOut } from 'svelte/easing';
11
12
interface Props {
13
selectedDid: Did;
···
194
);
195
</script>
196
197
-
{#snippet followingItems()}
198
-
{#each sortedFollowing as user (user.did)}
199
-
{@const stats = user.data!}
200
-
{@const lastPostAt = stats.lastPostAt}
201
-
{@const relTime = getRelativeTime(lastPostAt, currentTime)}
202
-
{@const color = generateColorForDid(user.did)}
203
-
<div animate:flip={{ duration: 350, easing: cubicOut }}>
204
-
<div
205
-
class="group flex items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20"
206
-
style={`--post-color: ${color};`}
207
-
>
208
-
<ProfilePicture client={selectedClient} did={user.did} size={10} />
209
-
<div class="min-w-0 flex-1 space-y-1">
210
-
<div
211
-
class="flex items-baseline gap-2 font-bold transition-colors group-hover:text-(--post-color)"
212
-
style={`--post-color: ${color};`}
213
-
>
214
-
{#await Promise.all([user.profile, user.handle]) then [displayName, handle]}
215
-
<span class="truncate">{displayName || handle}</span>
216
-
<span class="truncate text-sm opacity-60">@{handle}</span>
217
-
{/await}
218
-
</div>
219
-
<div class="flex gap-2 text-xs opacity-70">
220
-
<span
221
-
class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2
222
-
? 'text-(--nucleus-accent)'
223
-
: ''}
224
-
>
225
-
posted {relTime}
226
-
{relTime !== 'now' ? 'ago' : ''}
227
-
</span>
228
-
{#if stats.recentPostCount > 0}
229
-
<span class="text-(--nucleus-accent2)">
230
-
{stats.recentPostCount} posts / 6h
231
-
</span>
232
-
{/if}
233
-
{#if followingSort === 'conversational' && stats.conversationalScore > 0}
234
-
<span class="ml-auto font-bold text-(--nucleus-accent)">
235
-
★ {stats.conversationalScore.toFixed(1)}
236
-
</span>
237
-
{/if}
238
-
</div>
239
-
</div>
240
-
</div>
241
-
</div>
242
-
{/each}
243
-
{/snippet}
244
-
245
-
<div class="p-2">
246
-
<div class="mb-4 flex flex-col justify-between gap-4 px-2 sm:flex-row sm:items-center">
247
<div>
248
-
<h2 class="text-3xl font-bold">following</h2>
249
<div class="mt-2 flex gap-2">
250
<div class="h-1 w-8 rounded-full bg-(--nucleus-accent)"></div>
251
<div class="h-1 w-11 rounded-full bg-(--nucleus-accent2)"></div>
252
</div>
253
</div>
254
-
<div class="flex flex-wrap gap-2 text-sm">
255
{#each ['recent', 'active', 'conversational'] as type (type)}
256
<button
257
class="rounded-sm px-2 py-1 transition-colors {followingSort === type
···
265
</div>
266
</div>
267
268
-
<div class="flex flex-col gap-2">
269
{#if sortedFollowing.length === 0}
270
<div class="flex justify-center py-8">
271
<div
···
274
></div>
275
</div>
276
{:else}
277
-
{@render followingItems()}
278
{/if}
279
</div>
280
</div>
···
6
import { getRelativeTime } from '$lib/date';
7
import { generateColorForDid } from '$lib/accounts';
8
import { type AtprotoDid } from '@atcute/lexicons/syntax';
9
+
import VirtualList from '@tutorlatin/svelte-tiny-virtual-list';
10
11
interface Props {
12
selectedDid: Did;
···
193
);
194
</script>
195
196
+
<div class="flex h-full flex-col p-2">
197
+
<div class="mb-4 flex items-center justify-between gap-2 p-2 px-2 md:gap-4">
198
<div>
199
+
<h2 class="text-2xl font-bold md:text-3xl">following</h2>
200
<div class="mt-2 flex gap-2">
201
<div class="h-1 w-8 rounded-full bg-(--nucleus-accent)"></div>
202
<div class="h-1 w-11 rounded-full bg-(--nucleus-accent2)"></div>
203
</div>
204
</div>
205
+
<div class="flex gap-1 text-sm sm:gap-2">
206
{#each ['recent', 'active', 'conversational'] as type (type)}
207
<button
208
class="rounded-sm px-2 py-1 transition-colors {followingSort === type
···
216
</div>
217
</div>
218
219
+
<div class="min-h-0 flex-1">
220
{#if sortedFollowing.length === 0}
221
<div class="flex justify-center py-8">
222
<div
···
225
></div>
226
</div>
227
{:else}
228
+
<VirtualList height="70vh" itemCount={sortedFollowing.length} itemSize={76}>
229
+
{#snippet item({ index, style }: { index: number; style: string })}
230
+
{@const user = sortedFollowing[index]}
231
+
{@const stats = user.data!}
232
+
{@const lastPostAt = stats.lastPostAt}
233
+
{@const relTime = getRelativeTime(lastPostAt, currentTime)}
234
+
{@const color = generateColorForDid(user.did)}
235
+
<!-- box-border and pb-2 (0.5rem) simulates the gap-2 -->
236
+
<div {style} class="box-border w-full pb-2">
237
+
<div
238
+
class="group flex items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20"
239
+
style={`--post-color: ${color};`}
240
+
>
241
+
<ProfilePicture client={selectedClient} did={user.did} size={10} />
242
+
<div class="min-w-0 flex-1 space-y-1">
243
+
<div
244
+
class="flex items-baseline gap-2 font-bold transition-colors group-hover:text-(--post-color)"
245
+
style={`--post-color: ${color};`}
246
+
>
247
+
{#await Promise.all([user.profile, user.handle]) then [displayName, handle]}
248
+
<span class="truncate">{displayName || handle}</span>
249
+
<span class="truncate text-sm opacity-60">@{handle}</span>
250
+
{/await}
251
+
</div>
252
+
<div class="flex gap-2 text-xs opacity-70">
253
+
<span
254
+
class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2
255
+
? 'text-(--nucleus-accent)'
256
+
: ''}
257
+
>
258
+
posted {relTime}
259
+
{relTime !== 'now' ? 'ago' : ''}
260
+
</span>
261
+
{#if stats.recentPostCount > 0}
262
+
<span class="text-(--nucleus-accent2)">
263
+
{stats.recentPostCount} posts / 6h
264
+
</span>
265
+
{/if}
266
+
{#if followingSort === 'conversational' && stats.conversationalScore > 0}
267
+
<span class="ml-auto font-bold text-(--nucleus-accent)">
268
+
★ {stats.conversationalScore.toFixed(1)}
269
+
</span>
270
+
{/if}
271
+
</div>
272
+
</div>
273
+
</div>
274
+
</div>
275
+
{/snippet}
276
+
</VirtualList>
277
{/if}
278
</div>
279
</div>
+2
-2
src/components/SettingsView.svelte
+2
-2
src/components/SettingsView.svelte
+17
-4
src/lib/settings.ts
+17
-4
src/lib/settings.ts
···
25
};
26
27
const createSettingsStore = () => {
28
-
const stored = localStorage.getItem('settings');
29
30
const initial: Partial<Settings> = stored ? JSON.parse(stored) : defaultSettings;
31
initial.endpoints = { ...defaultSettings.endpoints, ...initial.endpoints };
···
35
const { subscribe, set, update } = writable<Settings>(initial as Settings);
36
37
subscribe((settings) => {
38
const theme = settings.theme;
39
document.documentElement.style.setProperty('--nucleus-bg', theme.bg);
40
document.documentElement.style.setProperty('--nucleus-fg', theme.fg);
41
document.documentElement.style.setProperty('--nucleus-accent', theme.accent);
42
document.documentElement.style.setProperty('--nucleus-accent2', theme.accent2);
43
});
44
45
return {
46
subscribe,
47
set: (value: Settings) => {
48
-
localStorage.setItem('settings', JSON.stringify(value));
49
set(value);
50
},
51
update: (fn: (value: Settings) => Settings) => {
52
update((value) => {
53
const newValue = fn(value);
54
-
localStorage.setItem('settings', JSON.stringify(newValue));
55
return newValue;
56
});
57
},
58
reset: () => {
59
-
localStorage.setItem('settings', JSON.stringify(defaultSettings));
60
set(defaultSettings);
61
}
62
};
···
25
};
26
27
const createSettingsStore = () => {
28
+
// Prevent SSR crash if localStorage is missing
29
+
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem('settings') : null;
30
31
const initial: Partial<Settings> = stored ? JSON.parse(stored) : defaultSettings;
32
initial.endpoints = { ...defaultSettings.endpoints, ...initial.endpoints };
···
36
const { subscribe, set, update } = writable<Settings>(initial as Settings);
37
38
subscribe((settings) => {
39
+
if (typeof document === 'undefined') return;
40
const theme = settings.theme;
41
document.documentElement.style.setProperty('--nucleus-bg', theme.bg);
42
document.documentElement.style.setProperty('--nucleus-fg', theme.fg);
43
document.documentElement.style.setProperty('--nucleus-accent', theme.accent);
44
document.documentElement.style.setProperty('--nucleus-accent2', theme.accent2);
45
+
46
+
const oldMeta = document.querySelector('meta[name="theme-color"]');
47
+
if (oldMeta) oldMeta.remove();
48
+
49
+
const metaThemeColor = document.createElement('meta');
50
+
metaThemeColor.setAttribute('name', 'theme-color');
51
+
metaThemeColor.setAttribute('content', theme.bg);
52
+
document.head.appendChild(metaThemeColor);
53
});
54
55
return {
56
subscribe,
57
set: (value: Settings) => {
58
+
if (typeof localStorage !== 'undefined')
59
+
localStorage.setItem('settings', JSON.stringify(value));
60
set(value);
61
},
62
update: (fn: (value: Settings) => Settings) => {
63
update((value) => {
64
const newValue = fn(value);
65
+
if (typeof localStorage !== 'undefined')
66
+
localStorage.setItem('settings', JSON.stringify(newValue));
67
return newValue;
68
});
69
},
70
reset: () => {
71
+
if (typeof localStorage !== 'undefined')
72
+
localStorage.setItem('settings', JSON.stringify(defaultSettings));
73
set(defaultSettings);
74
}
75
};
+4
-2
src/routes/+page.svelte
+4
-2
src/routes/+page.svelte
···
352
353
<div
354
class="
355
-
{currentView === 'timeline' ? '' : 'hidden'}
356
-
fixed bottom-[5.5dvh] z-20 w-full max-w-2xl p-2.5 px-4 transition-all
357
"
358
>
359
<!-- composer and error disclaimer (above thread list, not scrollable) -->
···
389
</div>
390
</div>
391
</div>
392
393
<div class="footer-border-bg rounded-t-sm px-0.5 pt-0.5">
394
<div class="footer-bg rounded-t-sm">
···
352
353
<div
354
class="
355
+
{currentView === 'timeline' || currentView === 'following' ? '' : 'hidden'}
356
+
z-20 w-full max-w-2xl p-2.5 px-4 pb-1 transition-all
357
"
358
>
359
<!-- composer and error disclaimer (above thread list, not scrollable) -->
···
389
</div>
390
</div>
391
</div>
392
+
393
+
<div id="footer-portal" class="contents"></div>
394
395
<div class="footer-border-bg rounded-t-sm px-0.5 pt-0.5">
396
<div class="footer-bg rounded-t-sm">