replies timeline only, appview-less bluesky client

feat: settings, refactor a bunch of stuff

+19
deno.lock
···
"npm:prettier-plugin-svelte@^3.4.0": "3.4.0_prettier@3.6.2_svelte@5.40.1__acorn@8.15.0",
"npm:prettier-plugin-tailwindcss@~0.6.14": "0.6.14_prettier@3.6.2_prettier-plugin-svelte@3.4.0__prettier@3.6.2__svelte@5.40.1___acorn@8.15.0_svelte@5.40.1__acorn@8.15.0",
"npm:prettier@^3.6.2": "3.6.2",
+
"npm:svelte-awesome-color-picker@^4.0.2": "4.0.2_svelte@5.40.1__acorn@8.15.0",
"npm:svelte-check@^4.3.2": "4.3.3_svelte@5.40.1__acorn@8.15.0_typescript@5.9.3",
"npm:svelte-infinite@0.5": "0.5.0_svelte@5.40.1__acorn@8.15.0",
"npm:svelte@^5.39.5": "5.40.1_acorn@8.15.0",
···
"color-name@1.1.4": {
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
+
"colord@2.9.3": {
+
"integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="
+
},
"concat-map@0.0.1": {
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
},
···
"has-flag"
},
+
"svelte-awesome-color-picker@4.0.2_svelte@5.40.1__acorn@8.15.0": {
+
"integrity": "sha512-Ez72goMMNmw6sZhB1/BXEA8984lEkudPrdlNS+y3nHm2Lnk1w4nwy5NFyWPxTP7nFnLxhIqyV3VuJVG4PokKwg==",
+
"dependencies": [
+
"colord",
+
"svelte",
+
"svelte-awesome-slider"
+
]
+
},
+
"svelte-awesome-slider@2.0.0_svelte@5.40.1__acorn@8.15.0": {
+
"integrity": "sha512-YBkOdYm1Feaqsn2JkJBRs+Kc/X3Qy/3GuVmI7GmoYDjBaHkjx9uH4khTuED22z57Hg3gGWeDhp/clIjWDdLNaw==",
+
"dependencies": [
+
"svelte"
+
]
+
},
"svelte-check@4.3.3_svelte@5.40.1__acorn@8.15.0_typescript@5.9.3": {
"integrity": "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg==",
"dependencies": [
···
"npm:prettier-plugin-svelte@^3.4.0",
"npm:prettier-plugin-tailwindcss@~0.6.14",
"npm:prettier@^3.6.2",
+
"npm:svelte-awesome-color-picker@^4.0.2",
"npm:svelte-check@^4.3.2",
"npm:svelte-infinite@0.5",
"npm:svelte@^5.39.5",
+1
package.json
···
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.6.14",
"svelte": "^5.39.5",
+
"svelte-awesome-color-picker": "^4.0.2",
"svelte-check": "^4.3.2",
"tailwindcss": "^4.1.13",
"typescript": "^5.9.2",
+49 -1
src/app.css
···
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
+
@theme {
+
@keyframes fade-in-scale {
+
0% {
+
opacity: 0;
+
transform: scale(0.95);
+
}
+
100% {
+
opacity: 1;
+
transform: scale(1);
+
}
+
}
+
}
+
+
@utility animate-fade-in-scale {
+
animation: fade-in-scale 0.2s ease-out forwards;
+
}
+
+
@utility animate-fade-in-scale-fast {
+
animation: fade-in-scale 0.1s ease-out forwards;
+
}
+
+
@utility single-line-input {
+
@apply w-full rounded-sm border-2 px-3 py-2 font-medium transition-all focus:scale-[1.02] focus:shadow-lg focus:outline-none;
+
}
+
+
@utility action-button {
+
@apply rounded-sm border-2 border-(--nucleus-accent) px-3 py-2 font-semibold text-(--nucleus-accent) transition-all hover:scale-105 hover:bg-(--nucleus-accent)/20;
+
}
+
+
:root {
+
scrollbar-width: thin;
+
scrollbar-color: var(--nucleus-accent) var(--nucleus-bg);
+
}
+
+
button {
+
@apply hover:cursor-pointer;
+
}
+
.grain:before {
content: '';
background-color: transparent;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 600'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='4' stitchTiles='stitch' /%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='2' intercept='-0.5' /%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23a)' /%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 40vmax;
-
opacity: 0.06;
+
opacity: 0.08;
top: 0;
left: 0;
position: fixed;
···
pointer-events: none;
z-index: 1;
}
+
+
.color-picker {
+
--cp-bg-color: var(--nucleus-bg);
+
--cp-border-color: var(--nucleus-accent);
+
--cp-text-color: var(--nucleus-fg);
+
--cp-input-color: color-mix(in srgb, var(--nucleus-accent) 10%, transparent);
+
--cp-button-hover-color: color-mix(in srgb, var(--nucleus-accent) 30%, transparent);
+
--picker-height: 8rem;
+
--picker-width: 8rem;
+
}
+20 -40
src/components/AccountSelector.svelte
···
import { generateColorForDid, type Account } from '$lib/accounts';
import { AtpClient } from '$lib/at/client';
import type { Did, Handle } from '@atcute/lexicons';
-
import { theme } from '$lib/theme.svelte';
import ProfilePicture from './ProfilePicture.svelte';
import PfpPlaceholder from './PfpPlaceholder.svelte';
···
{#if selectedDid}
<ProfilePicture {client} did={selectedDid} size={15} />
{:else}
-
<PfpPlaceholder color={theme.accent} size={15} />
+
<PfpPlaceholder color="var(--nucleus-accent)" size={15} />
{/if}
</button>
···
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
-
class="absolute left-0 z-10 mt-3 min-w-52 overflow-hidden rounded-sm border-2 shadow-2xl backdrop-blur-lg"
-
style="border-color: {theme.accent}; background: {theme.bg}f0;"
+
class="absolute left-0 z-10 mt-3 min-w-52 animate-fade-in-scale-fast overflow-hidden rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg)/94 shadow-2xl backdrop-blur-lg transition-all"
onclick={(e) => e.stopPropagation()}
>
{#if accounts.length > 0}
···
{account.did === selectedDid ? 'shadow-lg' : ''}
"
style="color: {color}; background: {account.did === selectedDid
-
? `linear-gradient(135deg, ${theme.accent}33, ${theme.accent2}33)`
+
? `linear-gradient(135deg, color-mix(in srgb, var(--nucleus-accent) 20%, transparent), color-mix(in srgb, var(--nucleus-accent2) 20%, transparent))`
: 'transparent'};"
>
<span>@{account.handle}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
onclick={() => onLogout(account.did)}
-
class="ml-auto hidden h-5 w-5 transition-all group-hover:[display:block] hover:scale-[1.2] hover:shadow-md"
-
style="color: {theme.accent};"
+
class="ml-auto hidden h-5 w-5 text-(--nucleus-accent) transition-all group-hover:[display:block] hover:scale-[1.2] hover:shadow-md"
width="24"
height="24"
viewBox="0 0 20 20"
···
{#if account.did === selectedDid}
<svg
xmlns="http://www.w3.org/2000/svg"
-
class="ml-auto h-5 w-5 group-hover:hidden"
-
style="color: {theme.accent};"
+
class="ml-auto h-5 w-5 text-(--nucleus-accent) group-hover:hidden"
width="24"
height="24"
viewBox="0 0 24 24"
···
{/each}
</div>
<div
-
class="mx-2 h-px"
-
style="background: linear-gradient(to right, {theme.accent}, {theme.accent2});"
+
class="mx-2 h-px bg-gradient-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"
></div>
{/if}
<button
onclick={openLoginModal}
-
class="group flex w-full origin-left items-center gap-3 p-3 text-left text-sm font-semibold transition-all hover:scale-[1.1]"
-
style="color: {theme.accent};"
+
class="group flex w-full origin-left items-center gap-3 p-3 text-left text-sm font-semibold text-(--nucleus-accent) transition-all hover:scale-[1.1]"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
···
{#if isLoginModalOpen}
<div
-
class="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm"
-
style="background: {theme.bg}cc;"
+
class="fixed inset-0 z-50 flex items-center justify-center bg-(--nucleus-bg)/80 backdrop-blur-sm"
onclick={closeLoginModal}
onkeydown={handleKeydown}
role="button"
···
<!-- svelte-ignore a11y_interactive_supports_focus -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
-
class="w-full max-w-md rounded-sm border-2 p-5 shadow-2xl"
-
style="background: {theme.bg}; border-color: {theme.accent};"
+
class="w-full max-w-md animate-fade-in-scale rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) p-4 shadow-2xl transition-all"
onclick={(e) => e.stopPropagation()}
role="dialog"
>
<div class="mb-6 flex items-center justify-between">
<div>
-
<h2 class="text-2xl font-bold" style="color: {theme.fg};">add account</h2>
+
<h2 class="text-2xl font-bold">add account</h2>
<div class="mt-2 flex gap-2">
-
<div class="h-1 w-10 rounded-full" style="background: {theme.accent};"></div>
-
<div class="h-1 w-9 rounded-full" style="background: {theme.accent2};"></div>
+
<div class="h-1 w-10 rounded-full bg-(--nucleus-accent)"></div>
+
<div class="h-1 w-9 rounded-full bg-(--nucleus-accent2)"></div>
</div>
</div>
<!-- svelte-ignore a11y_consider_explicit_label -->
<button
onclick={closeLoginModal}
-
class="rounded-xl p-2 transition-all hover:scale-110"
-
style="color: {theme.fg}66; hover:color: {theme.fg};"
+
class="rounded-xl p-2 text-(--nucleus-fg)/40 transition-all hover:scale-110 hover:text-(--nucleus-fg)"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
···
<div class="space-y-5">
<div>
-
<label for="handle" class="mb-2 block text-sm font-semibold" style="color: {theme.fg}cc;">
+
<label for="handle" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80">
handle
</label>
<input
···
type="text"
bind:value={loginHandle}
placeholder="example.bsky.social"
-
class="placeholder-opacity-40 w-full rounded-sm border-2 px-4 py-3 font-medium transition-all focus:scale-[1.02] focus:shadow-lg focus:outline-none"
-
style="background: {theme.accent}08; border-color: {theme.accent}66; color: {theme.fg};"
+
class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3"
disabled={isLoggingIn}
/>
</div>
<div>
-
<label
-
for="password"
-
class="mb-2 block text-sm font-semibold"
-
style="color: {theme.fg}cc;"
-
>
+
<label for="password" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80">
app password
</label>
<input
···
type="password"
bind:value={loginPassword}
placeholder="xxxx-xxxx-xxxx-xxxx"
-
class="placeholder-opacity-40 w-full rounded-sm border-2 px-4 py-3 font-medium transition-all focus:scale-[1.02] focus:shadow-lg focus:outline-none"
-
style="background: {theme.accent}08; border-color: {theme.accent}66; color: {theme.fg};"
+
class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3"
disabled={isLoggingIn}
/>
</div>
···
{/if}
<div class="flex gap-3 pt-3">
-
<button
-
onclick={closeLoginModal}
-
class="flex-1 rounded-sm border-2 px-5 py-3 font-semibold transition-all hover:scale-105"
-
style="background: {theme.bg}; border-color: {theme.fg}33; color: {theme.fg};"
-
disabled={isLoggingIn}
-
>
+
<button onclick={closeLoginModal} class="flex-1 action-button" disabled={isLoggingIn}>
cancel
</button>
<button
onclick={handleLogin}
-
class="flex-1 rounded-sm border-2 px-5 py-3 font-semibold transition-all hover:scale-105 hover:shadow-xl disabled:cursor-not-allowed disabled:opacity-50"
-
style="background: linear-gradient(135deg, {theme.accent}, {theme.accent2}); border-color: transparent; color: {theme.fg};"
+
class="flex-1 action-button border-transparent text-(--nucleus-fg)"
+
style="background: linear-gradient(135deg, var(--nucleus-accent), var(--nucleus-accent2));"
disabled={isLoggingIn}
>
{isLoggingIn ? 'logging in...' : 'login'}
+9 -12
src/components/BskyPost.svelte
···
import type { AtpClient } from '$lib/at/client';
import { AppBskyFeedPost } from '@atcute/bluesky';
import type { ActorIdentifier, Did, RecordKey } from '@atcute/lexicons';
-
import { theme } from '$lib/theme.svelte';
import { map, ok } from '$lib/result';
import { generateColorForDid } from '$lib/accounts';
import ProfilePicture from './ProfilePicture.svelte';
···
const { client, did, rkey, record, mini /* replyBacklinks */ }: Props = $props();
-
const color = generateColorForDid(did) ?? theme.accent2;
+
const color = generateColorForDid(did);
let handle: ActorIdentifier = $state(did);
client
···
{#if record.embed}
<span
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
-
style="background: {mini ? theme.fg : color}22; color: {mini ? theme.fg : color};"
+
style="background: color-mix(in srgb, {mini
+
? 'var(--nucleus-fg)'
+
: color} 13%, transparent); color: {mini ? 'var(--nucleus-fg)' : color};"
>
{getEmbedText(record.embed.$type)}
</span>
···
{/snippet}
{#if mini}
-
<div
-
class="overflow-hidden text-sm text-nowrap overflow-ellipsis opacity-60"
-
style="color: {theme.fg};"
-
>
+
<div class="overflow-hidden text-sm text-nowrap overflow-ellipsis opacity-60">
{#await post}
loading...
{:then post}
···
style="background: {color}18; border-color: {color}66;"
>
<div
-
class="inline-block h-6 w-6 animate-spin rounded-full border-3"
-
style="border-color: {theme.accent}; border-left-color: transparent;"
+
class="inline-block h-6 w-6 animate-spin rounded-full border-3 border-(--nucleus-accent) [border-left-color:transparent]"
></div>
-
<p class="mt-3 text-sm font-medium opacity-60" style="color: {theme.fg};">loading post...</p>
+
<p class="mt-3 text-sm font-medium opacity-60">loading post...</p>
</div>
{:then post}
{#if post.ok}
···
{/if}
{/await} -->
<span>·</span>
-
<span class="text-nowrap" style="color: {theme.fg}aa;"
+
<span class="text-nowrap text-(--nucleus-fg)/67"
>{getRelativeTime(new Date(record.createdAt))}</span
>
</div>
-
<p class="leading-relaxed text-wrap" style="color: {theme.fg};">
+
<p class="leading-relaxed text-wrap">
{record.text}
{@render embedBadge(record)}
</p>
+1 -1
src/components/PfpPlaceholder.svelte
···
<svg
class="rounded-sm"
-
style="background: {color}44; color: {color}; width: calc(var(--spacing) * {size}); height: calc(var(--spacing) * {size});"
+
style="background: color-mix(in srgb, {color} 27%, transparent); color: {color}; width: calc(var(--spacing) * {size}); height: calc(var(--spacing) * {size});"
xmlns="http://www.w3.org/2000/svg"
width="24px"
height="24px"
+14 -12
src/components/PostComposer.svelte
···
import { ok, err, type Result } from '$lib/result';
import type { AppBskyFeedPost } from '@atcute/bluesky';
import type { ResourceUri } from '@atcute/lexicons';
-
import { theme } from '$lib/theme.svelte';
import { generateColorForDid } from '$lib/accounts';
interface Props {
···
const { client, onPostSent }: Props = $props();
let color = $derived(
-
client.didDoc?.did ? (generateColorForDid(client.didDoc?.did) ?? theme.accent) : theme.accent
+
client.didDoc?.did ? generateColorForDid(client.didDoc?.did) : 'var(--nucleus-accent)'
);
const post = async (
···
class:right-0={isFocused}
class:z-50={isFocused}
style="background: {isFocused
-
? `color-mix(in srgb, ${theme.bg} 80%, ${color} 20%)`
-
: `${color}18`}; border-color: {color}{isFocused ? '' : '66'};"
+
? `color-mix(in srgb, var(--nucleus-bg) 80%, ${color} 20%)`
+
: `color-mix(in srgb, ${color} 9%, transparent)`};
+
border-color: color-mix(in srgb, {color} {isFocused ? '100' : '40'}%, transparent);"
>
<div class="w-full p-2" class:py-3={isFocused}>
{#if info.length > 0}
<div
class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis"
-
style="background: {color}22; color: {color};"
+
style="background: color-mix(in srgb, {color} 13%, transparent); color: {color};"
>
{info}
</div>
···
}}
placeholder="what's on your mind?"
rows="4"
-
class="placeholder-opacity-50 [field-sizing:content] w-full resize-none rounded-sm border-2 px-3 py-2 text-sm font-medium transition-all focus:outline-none"
-
style="background: {theme.bg}66; border-color: {color}44; color: {theme.fg};"
+
class="[field-sizing:content] single-line-input resize-none bg-(--nucleus-bg)/40 focus:scale-100"
+
style="border-color: color-mix(in srgb, {color} 27%, transparent);"
></textarea>
<div class="flex items-center gap-2">
<div class="grow"></div>
<span
class="text-sm font-medium"
-
style="color: {postText.length > 300 ? '#ef4444' : theme.fg}88;"
+
style="color: color-mix(in srgb, {postText.length > 300
+
? '#ef4444'
+
: 'var(--nucleus-fg)'} 53%, transparent);"
>
{postText.length} / 300
</span>
<button
onclick={doPost}
disabled={postText.length === 0 || postText.length > 300}
-
class="rounded-sm border-none px-5 py-2 text-sm font-bold transition-all hover:scale-105 hover:shadow-xl disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100"
-
style="background: {color}dd; color: {theme.fg}f0;"
+
class="action-button border-none px-5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100"
+
style="background: color-mix(in srgb, {color} 87%, transparent);"
>
post
</button>
···
}}
type="text"
placeholder="what's on your mind?"
-
class="placeholder-opacity-50 flex-1 rounded-sm border-2 px-3 py-2 text-sm font-medium transition-all focus:outline-none"
-
style="background: {theme.bg}66; border-color: {color}44; color: {theme.fg};"
+
class="single-line-input flex-1 bg-(--nucleus-bg)/40"
+
style="border-color: color-mix(in srgb, {color} 27%, transparent);"
/>
{/if}
</div>
+224
src/components/SettingsPopup.svelte
···
+
<script lang="ts">
+
import { defaultSettings, needsReload, settings } from '$lib/settings';
+
import { handleCache, didDocCache, recordCache } from '$lib/at/client';
+
import { get } from 'svelte/store';
+
import ColorPicker from 'svelte-awesome-color-picker';
+
+
interface Props {
+
isOpen: boolean;
+
onClose: () => void;
+
}
+
+
let { isOpen = $bindable(false), onClose }: Props = $props();
+
+
type Tab = 'advanced' | 'moderation' | 'style';
+
let activeTab = $state<Tab>('advanced');
+
+
let localSettings = $state(get(settings));
+
let hasReloadChanges = $derived(needsReload($settings, localSettings));
+
+
$effect(() => {
+
$settings.theme = localSettings.theme;
+
});
+
+
const resetSettingsToSaved = () => {
+
localSettings = $settings;
+
};
+
+
const handleClose = () => {
+
resetSettingsToSaved();
+
onClose();
+
};
+
+
const handleSave = () => {
+
settings.set(localSettings);
+
// reload to update api endpoints
+
window.location.reload();
+
};
+
+
const handleReset = () => {
+
const confirmed = confirm('reset all settings to defaults?');
+
if (!confirmed) return;
+
settings.reset();
+
window.location.reload();
+
};
+
+
const handleClearCache = () => {
+
handleCache.clear();
+
didDocCache.clear();
+
recordCache.clear();
+
alert('cache cleared!');
+
};
+
+
const handleKeydown = (event: KeyboardEvent) => {
+
if (event.key === 'Escape') handleClose();
+
};
+
</script>
+
+
{#snippet divider()}
+
<div class="h-px bg-gradient-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div>
+
{/snippet}
+
+
{#snippet settingHeader(name: string, desc: string)}
+
<h3 class="mb-3 text-lg font-bold">{name}</h3>
+
<p class="mb-4 text-sm opacity-80">{desc}</p>
+
{/snippet}
+
+
{#snippet advancedTab()}
+
<div class="space-y-5">
+
<div>
+
<h3 class="mb-3 text-lg font-bold">api endpoints</h3>
+
<div class="space-y-4">
+
{#snippet _input(name: string, desc: string)}
+
<div>
+
<label for={name} class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80">
+
{desc}
+
</label>
+
<!-- todo: add validation for url -->
+
<input
+
id={name}
+
type="url"
+
bind:value={localSettings.endpoints[name]}
+
placeholder={defaultSettings.endpoints[name]}
+
class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3"
+
/>
+
</div>
+
{/snippet}
+
{@render _input('slingshot', 'slingshot url (for fetching records & resolving identity)')}
+
{@render _input('spacedust', 'spacedust url (for notifications)')}
+
{@render _input('constellation', 'constellation url (for backlinks)')}
+
</div>
+
</div>
+
+
{@render divider()}
+
+
<div>
+
{@render settingHeader(
+
'cache management',
+
'clears cached data (records, DID documents, handles, etc.)'
+
)}
+
<button onclick={handleClearCache} class="action-button"> clear cache </button>
+
</div>
+
+
{@render divider()}
+
+
<div>
+
{@render settingHeader('reset settings', 'resets all settings to their default values')}
+
<button
+
onclick={handleReset}
+
class="action-button border-red-600 text-red-600 hover:bg-red-600/20"
+
>
+
reset to defaults
+
</button>
+
</div>
+
</div>
+
{/snippet}
+
+
{#snippet styleTab()}
+
<div class="space-y-5">
+
<div>
+
<h3 class="mb-3 text-lg font-bold">colors</h3>
+
<div class="space-y-4">
+
{#snippet color(name: string, desc: string)}
+
<div>
+
<label for={name} class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80">
+
{desc}
+
</label>
+
<div class="color-picker">
+
<ColorPicker
+
bind:hex={localSettings.theme[name]}
+
isAlpha={false}
+
position="responsive"
+
label={localSettings.theme[name]}
+
/>
+
</div>
+
</div>
+
{/snippet}
+
{@render color('fg', 'foreground color')}
+
{@render color('bg', 'background color')}
+
{@render color('accent', 'accent color')}
+
{@render color('accent2', 'secondary accent color')}
+
</div>
+
</div>
+
</div>
+
{/snippet}
+
+
{#if isOpen}
+
<div
+
class="fixed inset-0 z-50 flex items-center justify-center bg-(--nucleus-bg)/80 p-8 backdrop-blur-sm"
+
onclick={handleClose}
+
onkeydown={handleKeydown}
+
role="button"
+
tabindex="-1"
+
>
+
<!-- svelte-ignore a11y_interactive_supports_focus -->
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
+
<div
+
class="flex h-[600px] w-full max-w-2xl animate-fade-in-scale flex-col rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl transition-all"
+
onclick={(e) => e.stopPropagation()}
+
role="dialog"
+
>
+
<div class="flex items-center gap-4 border-b-2 border-(--nucleus-accent)/20 p-4">
+
<div>
+
<h2 class="text-2xl font-bold">settings</h2>
+
<div class="mt-2 flex gap-2">
+
<div class="h-1 w-8 rounded-full bg-(--nucleus-accent)"></div>
+
<div class="h-1 w-9.5 rounded-full bg-(--nucleus-accent2)"></div>
+
</div>
+
</div>
+
{#if hasReloadChanges}
+
<button onclick={handleSave} class="shrink-0 action-button px-6"> save & reload </button>
+
{/if}
+
<div class="grow"></div>
+
<!-- svelte-ignore a11y_consider_explicit_label -->
+
<button
+
onclick={handleClose}
+
class="rounded-xl p-2 text-(--nucleus-fg)/40 transition-all hover:scale-110"
+
>
+
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2.5"
+
d="M6 18L18 6M6 6l12 12"
+
/>
+
</svg>
+
</button>
+
</div>
+
+
<div class="flex-1 overflow-y-auto p-4">
+
{#if activeTab === 'advanced'}
+
{@render advancedTab()}
+
{:else if activeTab === 'moderation'}
+
<div class="flex h-full items-center justify-center">
+
<div class="text-center">
+
<div class="mb-4 text-6xl opacity-50">🚧</div>
+
<h3 class="text-xl font-bold opacity-80">todo</h3>
+
</div>
+
</div>
+
{:else if activeTab === 'style'}
+
{@render styleTab()}
+
{/if}
+
</div>
+
+
<div>
+
<div class="flex">
+
{#snippet tabButton(name: Tab)}
+
{@const isActive = activeTab === name}
+
<button
+
onclick={() => (activeTab = name)}
+
class="flex-1 border-t-3 px-4 py-3 font-semibold transition-colors hover:cursor-pointer {isActive
+
? 'border-(--nucleus-accent) bg-(--nucleus-accent)/20 text-(--nucleus-accent)'
+
: 'border-(--nucleus-accent)/20 bg-transparent text-(--nucleus-fg)/60 hover:bg-(--nucleus-accent)/10'}"
+
>
+
{name}
+
</button>
+
{/snippet}
+
{#each ['style', 'moderation', 'advanced'] as Tab[] as tabName (tabName)}
+
{@render tabButton(tabName)}
+
{/each}
+
</div>
+
</div>
+
</div>
+
</div>
+
{/if}
+8 -12
src/lib/at/client.ts
···
import { AppBskyActorProfile } from '@atcute/bluesky';
import { WebSocket } from '@soffinal/websocket';
import type { Notification } from './stardust';
+
import { get } from 'svelte/store';
+
import { settings } from '$lib/settings';
// import { JetstreamSubscription } from '@atcute/jetstream';
const cacheTtl = 1000 * 60 * 60 * 24;
-
const handleCache = new PersistedLRU<Handle, AtprotoDid>({
+
export const handleCache = new PersistedLRU<Handle, AtprotoDid>({
max: 1000,
ttl: cacheTtl,
prefix: 'handle'
});
-
const didDocCache = new PersistedLRU<ActorIdentifier, MiniDoc>({
+
export const didDocCache = new PersistedLRU<ActorIdentifier, MiniDoc>({
max: 1000,
ttl: cacheTtl,
prefix: 'didDoc'
});
-
const recordCache = new PersistedLRU<
+
export const recordCache = new PersistedLRU<
string,
InferOutput<typeof ComAtprotoRepoGetRecord.mainSchema.output.schema>
>({
···
prefix: 'record'
});
-
export let slingshotUrl: URL = new URL(
-
localStorage.getItem('slingshotUrl') ?? 'https://slingshot.microcosm.blue'
-
);
-
export let spacedustUrl: URL = new URL(
-
localStorage.getItem('spacedustUrl') ?? 'https://spacedust.microcosm.blue'
-
);
-
export let constellationUrl: URL = new URL(
-
localStorage.getItem('constellationUrl') ?? 'https://constellation.microcosm.blue'
-
);
+
export const slingshotUrl: URL = new URL(get(settings).endpoints.slingshot);
+
export const spacedustUrl: URL = new URL(get(settings).endpoints.spacedust);
+
export const constellationUrl: URL = new URL(get(settings).endpoints.constellation);
type NotificationsStreamEncoder = WebSocket.Encoder<undefined, Notification>;
export type NotificationsStream = WebSocket<NotificationsStreamEncoder>;
+11 -3
src/lib/cache.ts
···
export class PersistedLRU<K extends string, V extends {}> {
private memory: LRUCache<K, V>;
private storage: Cache;
-
private signals: Map<K, (data: V) => void>;
+
private signals: Map<K, ((data: V) => void)[]>;
private prefix = '';
···
}
getSignal(key: K): Promise<V> {
return new Promise<V>((resolve) => {
-
this.signals.set(key, resolve);
+
if (!this.signals.has(key)) {
+
this.signals.set(key, [resolve]);
+
return;
+
}
+
const signals = this.signals.get(key)!;
+
signals.push(resolve);
+
this.signals.set(key, signals);
});
}
set(key: K, value: V): void {
this.memory.set(key, value);
this.storage.set(this.prefixed(key), value);
-
this.signals.get(key)?.(value);
+
for (const signal of this.signals.get(key) ?? []) {
+
signal(value);
+
}
this.storage.flush(); // TODO: uh evil and fucked up (this whole file is evil honestly)
}
has(key: K): boolean {
+68
src/lib/settings.ts
···
+
import { writable } from 'svelte/store';
+
import { defaultTheme, type Theme } from './theme.svelte';
+
+
export type ApiEndpoints = Record<string, string> & {
+
slingshot: string;
+
spacedust: string;
+
constellation: string;
+
};
+
export type Settings = {
+
endpoints: ApiEndpoints;
+
theme: Theme;
+
};
+
+
export const defaultSettings: Settings = {
+
endpoints: {
+
slingshot: 'https://slingshot.microcosm.blue',
+
spacedust: 'https://spacedust.microcosm.blue',
+
constellation: 'https://constellation.microcosm.blue'
+
},
+
theme: defaultTheme
+
};
+
+
const createSettingsStore = () => {
+
const stored = localStorage.getItem('settings');
+
+
const initial: Partial<Settings> = stored ? JSON.parse(stored) : defaultSettings;
+
initial.endpoints = initial.endpoints ?? defaultSettings.endpoints;
+
initial.theme = initial.theme ?? defaultSettings.theme;
+
+
const { subscribe, set, update } = writable<Settings>(initial as Settings);
+
+
subscribe((settings) => {
+
const theme = settings.theme;
+
document.documentElement.style.setProperty('--nucleus-bg', theme.bg);
+
document.documentElement.style.setProperty('--nucleus-fg', theme.fg);
+
document.documentElement.style.setProperty('--nucleus-accent', theme.accent);
+
document.documentElement.style.setProperty('--nucleus-accent2', theme.accent2);
+
});
+
+
return {
+
subscribe,
+
set: (value: Settings) => {
+
localStorage.setItem('settings', JSON.stringify(value));
+
set(value);
+
},
+
update: (fn: (value: Settings) => Settings) => {
+
update((value) => {
+
const newValue = fn(value);
+
localStorage.setItem('settings', JSON.stringify(newValue));
+
return newValue;
+
});
+
},
+
reset: () => {
+
localStorage.setItem('settings', JSON.stringify(defaultSettings));
+
set(defaultSettings);
+
}
+
};
+
};
+
+
export const settings = createSettingsStore();
+
+
export const needsReload = (current: Settings, other: Settings): boolean => {
+
return (
+
current.endpoints.slingshot !== other.endpoints.slingshot ||
+
current.endpoints.spacedust !== other.endpoints.spacedust ||
+
current.endpoints.constellation !== other.endpoints.constellation
+
);
+
};
+11 -11
src/lib/theme.svelte.ts
···
-
export const theme = $state({
-
bg: '#11001c', // slate-900 - deep blue-grey background
-
fg: '#f8fafc', // slate-50 - crisp white foreground
-
accent: '#ec4899', // pink-500 - vibrant pink accent
-
accent2: '#8b5cf6' // violet-500 - purple secondary accent
-
});
+
export type Theme = Record<string, string> & {
+
bg: string;
+
fg: string;
+
accent: string;
+
accent2: string;
+
};
-
export const setTheme = (bg: string, fg: string, accent: string, accent2: string) => {
-
theme.bg = bg;
-
theme.fg = fg;
-
theme.accent = accent;
-
theme.accent2 = accent2;
+
export const defaultTheme: Theme = {
+
bg: '#11001c',
+
fg: '#f8fafc',
+
accent: '#ec4899',
+
accent2: '#8b5cf6'
};
+1 -3
src/routes/+layout.svelte
···
<script lang="ts">
import '../app.css';
-
import { theme } from '$lib/theme.svelte';
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
···
</svelte:head>
<div
-
class="grain min-h-screen transition-colors duration-300"
-
style="background: {theme.bg}; color: {theme.fg};"
+
class="grain min-h-screen bg-(--nucleus-bg) text-(--nucleus-fg) transition-colors duration-300"
>
{@render children?.()}
</div>
+44 -20
src/routes/+page.svelte
···
import BskyPost from '$components/BskyPost.svelte';
import PostComposer from '$components/PostComposer.svelte';
import AccountSelector from '$components/AccountSelector.svelte';
+
import SettingsPopup from '$components/SettingsPopup.svelte';
import { AtpClient, type NotificationsStreamEvent } from '$lib/at/client';
import { accounts, addAccount, type Account } from '$lib/accounts';
import {
···
type ResourceUri
} from '@atcute/lexicons';
import { onMount } from 'svelte';
-
import { theme } from '$lib/theme.svelte';
import { fetchPostsWithBacklinks, hydratePosts } from '$lib/at/fetch';
import { expect, ok } from '$lib/result';
import { AppBskyFeedPost } from '@atcute/bluesky';
···
let posts = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyFeedPost.Main>>();
let cursors = new SvelteMap<Did, { value?: string; end: boolean }>();
+
+
let isSettingsOpen = $state(false);
const addPosts = (did: Did, accTimeline: Map<ResourceUri, AppBskyFeedPost.Main>) => {
if (!posts.has(did)) {
···
</script>
<div class="mx-auto flex h-screen max-w-2xl flex-col p-4">
-
<div class="mb-6 flex-shrink-0">
-
<h1 class="text-3xl font-bold tracking-tight" style="color: {theme.fg};">nucleus</h1>
-
<div class="mt-1 flex gap-2">
-
<div class="h-1 w-11 rounded-full" style="background: {theme.accent};"></div>
-
<div class="h-1 w-8 rounded-full" style="background: {theme.accent2};"></div>
+
<div class="mb-6 flex flex-shrink-0 items-center justify-between">
+
<div>
+
<h1 class="text-3xl font-bold tracking-tight">nucleus</h1>
+
<div class="mt-1 flex gap-2">
+
<div class="h-1 w-11 rounded-full bg-(--nucleus-accent)"></div>
+
<div class="h-1 w-8 rounded-full bg-(--nucleus-accent2)"></div>
+
</div>
</div>
+
<button
+
onclick={() => (isSettingsOpen = true)}
+
class="rounded-sm bg-(--nucleus-accent)/7 p-2.5 text-(--nucleus-accent) transition-all hover:scale-110 hover:shadow-lg"
+
aria-label="Settings"
+
>
+
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
+
/>
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2"
+
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
+
/>
+
</svg>
+
</button>
</div>
<div class="flex-shrink-0 space-y-4">
···
</div>
{:else}
<div
-
class="flex flex-1 items-center justify-center rounded-sm border-2 px-4 py-2.5 backdrop-blur-sm"
-
style="border-color: {theme.accent}33; background: {theme.accent}0a;"
+
class="flex flex-1 items-center justify-center rounded-sm border-2 border-(--nucleus-accent)/20 bg-(--nucleus-accent)/4 px-4 py-2.5 backdrop-blur-sm"
>
-
<p class="text-sm opacity-80" style="color: {theme.fg};">
-
select or add an account to post
-
</p>
+
<p class="text-sm opacity-80">select or add an account to post</p>
</div>
{/if}
</div>
<hr
class="h-[4px] w-full rounded-full border-0"
-
style="background: linear-gradient(to right, {theme.accent}, {theme.accent2});"
+
style="background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));"
/>
</div>
-
<div class="mt-4 overflow-y-scroll [scrollbar-width:none]" bind:this={scrollContainer}>
+
<div
+
class="mt-4 overflow-y-scroll [scrollbar-color:var(--nucleus-accent)_transparent]"
+
bind:this={scrollContainer}
+
>
{#if $accounts.length > 0}
{@render renderThreads()}
{:else}
<div class="flex justify-center py-4">
-
<p class="text-xl opacity-80" style="color: {theme.fg};">
+
<p class="text-xl opacity-80">
<span class="text-4xl">x_x</span> <br /> no accounts are logged in!
</p>
</div>
···
</div>
</div>
+
<SettingsPopup bind:isOpen={isSettingsOpen} onClose={() => (isSettingsOpen = false)} />
+
{#snippet renderThreads()}
<InfiniteLoader
{loaderState}
···
{@render threadsView()}
{#snippet noData()}
<div class="flex justify-center py-4">
-
<p class="text-xl opacity-80" style="color: {theme.fg};">
+
<p class="text-xl opacity-80">
all posts seen! <span class="text-2xl">:o</span>
</p>
</div>
···
<div class="flex justify-center">
<div
class="h-12 w-12 animate-spin rounded-full border-4 border-t-transparent"
-
style="border-color: {theme.accent} {theme.accent} {theme.accent} transparent;"
+
style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;"
></div>
</div>
{/snippet}
{#snippet error()}
<div class="flex justify-center py-4">
-
<p class="text-xl opacity-80" style="color: {theme.fg};">
+
<p class="text-xl opacity-80">
<span class="text-4xl">:(</span> <br /> an error occurred while loading posts: {loadError}
</p>
</div>
···
{#if thread.branchParentPost}
{@const post = thread.branchParentPost}
<div class="mb-1.5 flex items-center gap-1.5">
-
<span class="text-sm text-nowrap opacity-60" style="color: {theme.fg};"
-
>{reverseChronological ? '↱' : '↳'}</span
-
>
+
<span class="text-sm text-nowrap opacity-60">{reverseChronological ? '↱' : '↳'}</span>
<BskyPost mini client={viewClient} {...post} />
</div>
{/if}