appview-less bluesky client
23
fork

Configure Feed

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

feat: settings, refactor a bunch of stuff

dusk 916c6c27 44c66612

+480 -115
+19
deno.lock
··· 25 25 "npm:prettier-plugin-svelte@^3.4.0": "3.4.0_prettier@3.6.2_svelte@5.40.1__acorn@8.15.0", 26 26 "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", 27 27 "npm:prettier@^3.6.2": "3.6.2", 28 + "npm:svelte-awesome-color-picker@^4.0.2": "4.0.2_svelte@5.40.1__acorn@8.15.0", 28 29 "npm:svelte-check@^4.3.2": "4.3.3_svelte@5.40.1__acorn@8.15.0_typescript@5.9.3", 29 30 "npm:svelte-infinite@0.5": "0.5.0_svelte@5.40.1__acorn@8.15.0", 30 31 "npm:svelte@^5.39.5": "5.40.1_acorn@8.15.0", ··· 835 836 "color-name@1.1.4": { 836 837 "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" 837 838 }, 839 + "colord@2.9.3": { 840 + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==" 841 + }, 838 842 "concat-map@0.0.1": { 839 843 "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" 840 844 }, ··· 1551 1555 "has-flag" 1552 1556 ] 1553 1557 }, 1558 + "svelte-awesome-color-picker@4.0.2_svelte@5.40.1__acorn@8.15.0": { 1559 + "integrity": "sha512-Ez72goMMNmw6sZhB1/BXEA8984lEkudPrdlNS+y3nHm2Lnk1w4nwy5NFyWPxTP7nFnLxhIqyV3VuJVG4PokKwg==", 1560 + "dependencies": [ 1561 + "colord", 1562 + "svelte", 1563 + "svelte-awesome-slider" 1564 + ] 1565 + }, 1566 + "svelte-awesome-slider@2.0.0_svelte@5.40.1__acorn@8.15.0": { 1567 + "integrity": "sha512-YBkOdYm1Feaqsn2JkJBRs+Kc/X3Qy/3GuVmI7GmoYDjBaHkjx9uH4khTuED22z57Hg3gGWeDhp/clIjWDdLNaw==", 1568 + "dependencies": [ 1569 + "svelte" 1570 + ] 1571 + }, 1554 1572 "svelte-check@4.3.3_svelte@5.40.1__acorn@8.15.0_typescript@5.9.3": { 1555 1573 "integrity": "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg==", 1556 1574 "dependencies": [ ··· 1753 1771 "npm:prettier-plugin-svelte@^3.4.0", 1754 1772 "npm:prettier-plugin-tailwindcss@~0.6.14", 1755 1773 "npm:prettier@^3.6.2", 1774 + "npm:svelte-awesome-color-picker@^4.0.2", 1756 1775 "npm:svelte-check@^4.3.2", 1757 1776 "npm:svelte-infinite@0.5", 1758 1777 "npm:svelte@^5.39.5",
+1
package.json
··· 42 42 "prettier-plugin-svelte": "^3.4.0", 43 43 "prettier-plugin-tailwindcss": "^0.6.14", 44 44 "svelte": "^5.39.5", 45 + "svelte-awesome-color-picker": "^4.0.2", 45 46 "svelte-check": "^4.3.2", 46 47 "tailwindcss": "^4.1.13", 47 48 "typescript": "^5.9.2",
+49 -1
src/app.css
··· 1 1 @import 'tailwindcss'; 2 2 @plugin '@tailwindcss/forms'; 3 3 4 + @theme { 5 + @keyframes fade-in-scale { 6 + 0% { 7 + opacity: 0; 8 + transform: scale(0.95); 9 + } 10 + 100% { 11 + opacity: 1; 12 + transform: scale(1); 13 + } 14 + } 15 + } 16 + 17 + @utility animate-fade-in-scale { 18 + animation: fade-in-scale 0.2s ease-out forwards; 19 + } 20 + 21 + @utility animate-fade-in-scale-fast { 22 + animation: fade-in-scale 0.1s ease-out forwards; 23 + } 24 + 25 + @utility single-line-input { 26 + @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; 27 + } 28 + 29 + @utility action-button { 30 + @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; 31 + } 32 + 33 + :root { 34 + scrollbar-width: thin; 35 + scrollbar-color: var(--nucleus-accent) var(--nucleus-bg); 36 + } 37 + 38 + button { 39 + @apply hover:cursor-pointer; 40 + } 41 + 4 42 .grain:before { 5 43 content: ''; 6 44 background-color: transparent; 7 45 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"); 8 46 background-repeat: repeat; 9 47 background-size: 40vmax; 10 - opacity: 0.06; 48 + opacity: 0.08; 11 49 top: 0; 12 50 left: 0; 13 51 position: fixed; ··· 16 54 pointer-events: none; 17 55 z-index: 1; 18 56 } 57 + 58 + .color-picker { 59 + --cp-bg-color: var(--nucleus-bg); 60 + --cp-border-color: var(--nucleus-accent); 61 + --cp-text-color: var(--nucleus-fg); 62 + --cp-input-color: color-mix(in srgb, var(--nucleus-accent) 10%, transparent); 63 + --cp-button-hover-color: color-mix(in srgb, var(--nucleus-accent) 30%, transparent); 64 + --picker-height: 8rem; 65 + --picker-width: 8rem; 66 + }
+20 -40
src/components/AccountSelector.svelte
··· 2 2 import { generateColorForDid, type Account } from '$lib/accounts'; 3 3 import { AtpClient } from '$lib/at/client'; 4 4 import type { Did, Handle } from '@atcute/lexicons'; 5 - import { theme } from '$lib/theme.svelte'; 6 5 import ProfilePicture from './ProfilePicture.svelte'; 7 6 import PfpPlaceholder from './PfpPlaceholder.svelte'; 8 7 ··· 113 112 {#if selectedDid} 114 113 <ProfilePicture {client} did={selectedDid} size={15} /> 115 114 {:else} 116 - <PfpPlaceholder color={theme.accent} size={15} /> 115 + <PfpPlaceholder color="var(--nucleus-accent)" size={15} /> 117 116 {/if} 118 117 </button> 119 118 ··· 121 120 <!-- svelte-ignore a11y_click_events_have_key_events --> 122 121 <!-- svelte-ignore a11y_no_static_element_interactions --> 123 122 <div 124 - class="absolute left-0 z-10 mt-3 min-w-52 overflow-hidden rounded-sm border-2 shadow-2xl backdrop-blur-lg" 125 - style="border-color: {theme.accent}; background: {theme.bg}f0;" 123 + 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" 126 124 onclick={(e) => e.stopPropagation()} 127 125 > 128 126 {#if accounts.length > 0} ··· 136 134 {account.did === selectedDid ? 'shadow-lg' : ''} 137 135 " 138 136 style="color: {color}; background: {account.did === selectedDid 139 - ? `linear-gradient(135deg, ${theme.accent}33, ${theme.accent2}33)` 137 + ? `linear-gradient(135deg, color-mix(in srgb, var(--nucleus-accent) 20%, transparent), color-mix(in srgb, var(--nucleus-accent2) 20%, transparent))` 140 138 : 'transparent'};" 141 139 > 142 140 <span>@{account.handle}</span> 143 141 <svg 144 142 xmlns="http://www.w3.org/2000/svg" 145 143 onclick={() => onLogout(account.did)} 146 - class="ml-auto hidden h-5 w-5 transition-all group-hover:[display:block] hover:scale-[1.2] hover:shadow-md" 147 - style="color: {theme.accent};" 144 + class="ml-auto hidden h-5 w-5 text-(--nucleus-accent) transition-all group-hover:[display:block] hover:scale-[1.2] hover:shadow-md" 148 145 width="24" 149 146 height="24" 150 147 viewBox="0 0 20 20" ··· 159 156 {#if account.did === selectedDid} 160 157 <svg 161 158 xmlns="http://www.w3.org/2000/svg" 162 - class="ml-auto h-5 w-5 group-hover:hidden" 163 - style="color: {theme.accent};" 159 + class="ml-auto h-5 w-5 text-(--nucleus-accent) group-hover:hidden" 164 160 width="24" 165 161 height="24" 166 162 viewBox="0 0 24 24" ··· 178 174 {/each} 179 175 </div> 180 176 <div 181 - class="mx-2 h-px" 182 - style="background: linear-gradient(to right, {theme.accent}, {theme.accent2});" 177 + class="mx-2 h-px bg-gradient-to-r from-(--nucleus-accent) to-(--nucleus-accent2)" 183 178 ></div> 184 179 {/if} 185 180 <button 186 181 onclick={openLoginModal} 187 - 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]" 188 - style="color: {theme.accent};" 182 + 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]" 189 183 > 190 184 <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 191 185 <path ··· 203 197 204 198 {#if isLoginModalOpen} 205 199 <div 206 - class="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm" 207 - style="background: {theme.bg}cc;" 200 + class="fixed inset-0 z-50 flex items-center justify-center bg-(--nucleus-bg)/80 backdrop-blur-sm" 208 201 onclick={closeLoginModal} 209 202 onkeydown={handleKeydown} 210 203 role="button" ··· 213 206 <!-- svelte-ignore a11y_interactive_supports_focus --> 214 207 <!-- svelte-ignore a11y_click_events_have_key_events --> 215 208 <div 216 - class="w-full max-w-md rounded-sm border-2 p-5 shadow-2xl" 217 - style="background: {theme.bg}; border-color: {theme.accent};" 209 + 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" 218 210 onclick={(e) => e.stopPropagation()} 219 211 role="dialog" 220 212 > 221 213 <div class="mb-6 flex items-center justify-between"> 222 214 <div> 223 - <h2 class="text-2xl font-bold" style="color: {theme.fg};">add account</h2> 215 + <h2 class="text-2xl font-bold">add account</h2> 224 216 <div class="mt-2 flex gap-2"> 225 - <div class="h-1 w-10 rounded-full" style="background: {theme.accent};"></div> 226 - <div class="h-1 w-9 rounded-full" style="background: {theme.accent2};"></div> 217 + <div class="h-1 w-10 rounded-full bg-(--nucleus-accent)"></div> 218 + <div class="h-1 w-9 rounded-full bg-(--nucleus-accent2)"></div> 227 219 </div> 228 220 </div> 229 221 <!-- svelte-ignore a11y_consider_explicit_label --> 230 222 <button 231 223 onclick={closeLoginModal} 232 - class="rounded-xl p-2 transition-all hover:scale-110" 233 - style="color: {theme.fg}66; hover:color: {theme.fg};" 224 + class="rounded-xl p-2 text-(--nucleus-fg)/40 transition-all hover:scale-110 hover:text-(--nucleus-fg)" 234 225 > 235 226 <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 236 227 <path ··· 245 236 246 237 <div class="space-y-5"> 247 238 <div> 248 - <label for="handle" class="mb-2 block text-sm font-semibold" style="color: {theme.fg}cc;"> 239 + <label for="handle" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> 249 240 handle 250 241 </label> 251 242 <input ··· 253 244 type="text" 254 245 bind:value={loginHandle} 255 246 placeholder="example.bsky.social" 256 - 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" 257 - style="background: {theme.accent}08; border-color: {theme.accent}66; color: {theme.fg};" 247 + class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3" 258 248 disabled={isLoggingIn} 259 249 /> 260 250 </div> 261 251 262 252 <div> 263 - <label 264 - for="password" 265 - class="mb-2 block text-sm font-semibold" 266 - style="color: {theme.fg}cc;" 267 - > 253 + <label for="password" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> 268 254 app password 269 255 </label> 270 256 <input ··· 272 258 type="password" 273 259 bind:value={loginPassword} 274 260 placeholder="xxxx-xxxx-xxxx-xxxx" 275 - 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" 276 - style="background: {theme.accent}08; border-color: {theme.accent}66; color: {theme.fg};" 261 + class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3" 277 262 disabled={isLoggingIn} 278 263 /> 279 264 </div> ··· 288 273 {/if} 289 274 290 275 <div class="flex gap-3 pt-3"> 291 - <button 292 - onclick={closeLoginModal} 293 - class="flex-1 rounded-sm border-2 px-5 py-3 font-semibold transition-all hover:scale-105" 294 - style="background: {theme.bg}; border-color: {theme.fg}33; color: {theme.fg};" 295 - disabled={isLoggingIn} 296 - > 276 + <button onclick={closeLoginModal} class="flex-1 action-button" disabled={isLoggingIn}> 297 277 cancel 298 278 </button> 299 279 <button 300 280 onclick={handleLogin} 301 - 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" 302 - style="background: linear-gradient(135deg, {theme.accent}, {theme.accent2}); border-color: transparent; color: {theme.fg};" 281 + class="flex-1 action-button border-transparent text-(--nucleus-fg)" 282 + style="background: linear-gradient(135deg, var(--nucleus-accent), var(--nucleus-accent2));" 303 283 disabled={isLoggingIn} 304 284 > 305 285 {isLoggingIn ? 'logging in...' : 'login'}
+9 -12
src/components/BskyPost.svelte
··· 2 2 import type { AtpClient } from '$lib/at/client'; 3 3 import { AppBskyFeedPost } from '@atcute/bluesky'; 4 4 import type { ActorIdentifier, Did, RecordKey } from '@atcute/lexicons'; 5 - import { theme } from '$lib/theme.svelte'; 6 5 import { map, ok } from '$lib/result'; 7 6 import { generateColorForDid } from '$lib/accounts'; 8 7 import ProfilePicture from './ProfilePicture.svelte'; ··· 18 17 19 18 const { client, did, rkey, record, mini /* replyBacklinks */ }: Props = $props(); 20 19 21 - const color = generateColorForDid(did) ?? theme.accent2; 20 + const color = generateColorForDid(did); 22 21 23 22 let handle: ActorIdentifier = $state(did); 24 23 client ··· 80 79 {#if record.embed} 81 80 <span 82 81 class="rounded-full px-2.5 py-0.5 text-xs font-medium" 83 - style="background: {mini ? theme.fg : color}22; color: {mini ? theme.fg : color};" 82 + style="background: color-mix(in srgb, {mini 83 + ? 'var(--nucleus-fg)' 84 + : color} 13%, transparent); color: {mini ? 'var(--nucleus-fg)' : color};" 84 85 > 85 86 {getEmbedText(record.embed.$type)} 86 87 </span> ··· 88 89 {/snippet} 89 90 90 91 {#if mini} 91 - <div 92 - class="overflow-hidden text-sm text-nowrap overflow-ellipsis opacity-60" 93 - style="color: {theme.fg};" 94 - > 92 + <div class="overflow-hidden text-sm text-nowrap overflow-ellipsis opacity-60"> 95 93 {#await post} 96 94 loading... 97 95 {:then post} ··· 111 109 style="background: {color}18; border-color: {color}66;" 112 110 > 113 111 <div 114 - class="inline-block h-6 w-6 animate-spin rounded-full border-3" 115 - style="border-color: {theme.accent}; border-left-color: transparent;" 112 + class="inline-block h-6 w-6 animate-spin rounded-full border-3 border-(--nucleus-accent) [border-left-color:transparent]" 116 113 ></div> 117 - <p class="mt-3 text-sm font-medium opacity-60" style="color: {theme.fg};">loading post...</p> 114 + <p class="mt-3 text-sm font-medium opacity-60">loading post...</p> 118 115 </div> 119 116 {:then post} 120 117 {#if post.ok} ··· 167 164 {/if} 168 165 {/await} --> 169 166 <span>·</span> 170 - <span class="text-nowrap" style="color: {theme.fg}aa;" 167 + <span class="text-nowrap text-(--nucleus-fg)/67" 171 168 >{getRelativeTime(new Date(record.createdAt))}</span 172 169 > 173 170 </div> 174 - <p class="leading-relaxed text-wrap" style="color: {theme.fg};"> 171 + <p class="leading-relaxed text-wrap"> 175 172 {record.text} 176 173 {@render embedBadge(record)} 177 174 </p>
+1 -1
src/components/PfpPlaceholder.svelte
··· 9 9 10 10 <svg 11 11 class="rounded-sm" 12 - style="background: {color}44; color: {color}; width: calc(var(--spacing) * {size}); height: calc(var(--spacing) * {size});" 12 + style="background: color-mix(in srgb, {color} 27%, transparent); color: {color}; width: calc(var(--spacing) * {size}); height: calc(var(--spacing) * {size});" 13 13 xmlns="http://www.w3.org/2000/svg" 14 14 width="24px" 15 15 height="24px"
+14 -12
src/components/PostComposer.svelte
··· 3 3 import { ok, err, type Result } from '$lib/result'; 4 4 import type { AppBskyFeedPost } from '@atcute/bluesky'; 5 5 import type { ResourceUri } from '@atcute/lexicons'; 6 - import { theme } from '$lib/theme.svelte'; 7 6 import { generateColorForDid } from '$lib/accounts'; 8 7 9 8 interface Props { ··· 14 13 const { client, onPostSent }: Props = $props(); 15 14 16 15 let color = $derived( 17 - client.didDoc?.did ? (generateColorForDid(client.didDoc?.did) ?? theme.accent) : theme.accent 16 + client.didDoc?.did ? generateColorForDid(client.didDoc?.did) : 'var(--nucleus-accent)' 18 17 ); 19 18 20 19 const post = async ( ··· 90 89 class:right-0={isFocused} 91 90 class:z-50={isFocused} 92 91 style="background: {isFocused 93 - ? `color-mix(in srgb, ${theme.bg} 80%, ${color} 20%)` 94 - : `${color}18`}; border-color: {color}{isFocused ? '' : '66'};" 92 + ? `color-mix(in srgb, var(--nucleus-bg) 80%, ${color} 20%)` 93 + : `color-mix(in srgb, ${color} 9%, transparent)`}; 94 + border-color: color-mix(in srgb, {color} {isFocused ? '100' : '40'}%, transparent);" 95 95 > 96 96 <div class="w-full p-2" class:py-3={isFocused}> 97 97 {#if info.length > 0} 98 98 <div 99 99 class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis" 100 - style="background: {color}22; color: {color};" 100 + style="background: color-mix(in srgb, {color} 13%, transparent); color: {color};" 101 101 > 102 102 {info} 103 103 </div> ··· 114 114 }} 115 115 placeholder="what's on your mind?" 116 116 rows="4" 117 - 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" 118 - style="background: {theme.bg}66; border-color: {color}44; color: {theme.fg};" 117 + class="[field-sizing:content] single-line-input resize-none bg-(--nucleus-bg)/40 focus:scale-100" 118 + style="border-color: color-mix(in srgb, {color} 27%, transparent);" 119 119 ></textarea> 120 120 <div class="flex items-center gap-2"> 121 121 <div class="grow"></div> 122 122 <span 123 123 class="text-sm font-medium" 124 - style="color: {postText.length > 300 ? '#ef4444' : theme.fg}88;" 124 + style="color: color-mix(in srgb, {postText.length > 300 125 + ? '#ef4444' 126 + : 'var(--nucleus-fg)'} 53%, transparent);" 125 127 > 126 128 {postText.length} / 300 127 129 </span> 128 130 <button 129 131 onclick={doPost} 130 132 disabled={postText.length === 0 || postText.length > 300} 131 - 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" 132 - style="background: {color}dd; color: {theme.fg}f0;" 133 + class="action-button border-none px-5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100" 134 + style="background: color-mix(in srgb, {color} 87%, transparent);" 133 135 > 134 136 post 135 137 </button> ··· 143 145 }} 144 146 type="text" 145 147 placeholder="what's on your mind?" 146 - class="placeholder-opacity-50 flex-1 rounded-sm border-2 px-3 py-2 text-sm font-medium transition-all focus:outline-none" 147 - style="background: {theme.bg}66; border-color: {color}44; color: {theme.fg};" 148 + class="single-line-input flex-1 bg-(--nucleus-bg)/40" 149 + style="border-color: color-mix(in srgb, {color} 27%, transparent);" 148 150 /> 149 151 {/if} 150 152 </div>
+224
src/components/SettingsPopup.svelte
··· 1 + <script lang="ts"> 2 + import { defaultSettings, needsReload, settings } from '$lib/settings'; 3 + import { handleCache, didDocCache, recordCache } from '$lib/at/client'; 4 + import { get } from 'svelte/store'; 5 + import ColorPicker from 'svelte-awesome-color-picker'; 6 + 7 + interface Props { 8 + isOpen: boolean; 9 + onClose: () => void; 10 + } 11 + 12 + let { isOpen = $bindable(false), onClose }: Props = $props(); 13 + 14 + type Tab = 'advanced' | 'moderation' | 'style'; 15 + let activeTab = $state<Tab>('advanced'); 16 + 17 + let localSettings = $state(get(settings)); 18 + let hasReloadChanges = $derived(needsReload($settings, localSettings)); 19 + 20 + $effect(() => { 21 + $settings.theme = localSettings.theme; 22 + }); 23 + 24 + const resetSettingsToSaved = () => { 25 + localSettings = $settings; 26 + }; 27 + 28 + const handleClose = () => { 29 + resetSettingsToSaved(); 30 + onClose(); 31 + }; 32 + 33 + const handleSave = () => { 34 + settings.set(localSettings); 35 + // reload to update api endpoints 36 + window.location.reload(); 37 + }; 38 + 39 + const handleReset = () => { 40 + const confirmed = confirm('reset all settings to defaults?'); 41 + if (!confirmed) return; 42 + settings.reset(); 43 + window.location.reload(); 44 + }; 45 + 46 + const handleClearCache = () => { 47 + handleCache.clear(); 48 + didDocCache.clear(); 49 + recordCache.clear(); 50 + alert('cache cleared!'); 51 + }; 52 + 53 + const handleKeydown = (event: KeyboardEvent) => { 54 + if (event.key === 'Escape') handleClose(); 55 + }; 56 + </script> 57 + 58 + {#snippet divider()} 59 + <div class="h-px bg-gradient-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div> 60 + {/snippet} 61 + 62 + {#snippet settingHeader(name: string, desc: string)} 63 + <h3 class="mb-3 text-lg font-bold">{name}</h3> 64 + <p class="mb-4 text-sm opacity-80">{desc}</p> 65 + {/snippet} 66 + 67 + {#snippet advancedTab()} 68 + <div class="space-y-5"> 69 + <div> 70 + <h3 class="mb-3 text-lg font-bold">api endpoints</h3> 71 + <div class="space-y-4"> 72 + {#snippet _input(name: string, desc: string)} 73 + <div> 74 + <label for={name} class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> 75 + {desc} 76 + </label> 77 + <!-- todo: add validation for url --> 78 + <input 79 + id={name} 80 + type="url" 81 + bind:value={localSettings.endpoints[name]} 82 + placeholder={defaultSettings.endpoints[name]} 83 + class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3" 84 + /> 85 + </div> 86 + {/snippet} 87 + {@render _input('slingshot', 'slingshot url (for fetching records & resolving identity)')} 88 + {@render _input('spacedust', 'spacedust url (for notifications)')} 89 + {@render _input('constellation', 'constellation url (for backlinks)')} 90 + </div> 91 + </div> 92 + 93 + {@render divider()} 94 + 95 + <div> 96 + {@render settingHeader( 97 + 'cache management', 98 + 'clears cached data (records, DID documents, handles, etc.)' 99 + )} 100 + <button onclick={handleClearCache} class="action-button"> clear cache </button> 101 + </div> 102 + 103 + {@render divider()} 104 + 105 + <div> 106 + {@render settingHeader('reset settings', 'resets all settings to their default values')} 107 + <button 108 + onclick={handleReset} 109 + class="action-button border-red-600 text-red-600 hover:bg-red-600/20" 110 + > 111 + reset to defaults 112 + </button> 113 + </div> 114 + </div> 115 + {/snippet} 116 + 117 + {#snippet styleTab()} 118 + <div class="space-y-5"> 119 + <div> 120 + <h3 class="mb-3 text-lg font-bold">colors</h3> 121 + <div class="space-y-4"> 122 + {#snippet color(name: string, desc: string)} 123 + <div> 124 + <label for={name} class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> 125 + {desc} 126 + </label> 127 + <div class="color-picker"> 128 + <ColorPicker 129 + bind:hex={localSettings.theme[name]} 130 + isAlpha={false} 131 + position="responsive" 132 + label={localSettings.theme[name]} 133 + /> 134 + </div> 135 + </div> 136 + {/snippet} 137 + {@render color('fg', 'foreground color')} 138 + {@render color('bg', 'background color')} 139 + {@render color('accent', 'accent color')} 140 + {@render color('accent2', 'secondary accent color')} 141 + </div> 142 + </div> 143 + </div> 144 + {/snippet} 145 + 146 + {#if isOpen} 147 + <div 148 + class="fixed inset-0 z-50 flex items-center justify-center bg-(--nucleus-bg)/80 p-8 backdrop-blur-sm" 149 + onclick={handleClose} 150 + onkeydown={handleKeydown} 151 + role="button" 152 + tabindex="-1" 153 + > 154 + <!-- svelte-ignore a11y_interactive_supports_focus --> 155 + <!-- svelte-ignore a11y_click_events_have_key_events --> 156 + <div 157 + 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" 158 + onclick={(e) => e.stopPropagation()} 159 + role="dialog" 160 + > 161 + <div class="flex items-center gap-4 border-b-2 border-(--nucleus-accent)/20 p-4"> 162 + <div> 163 + <h2 class="text-2xl font-bold">settings</h2> 164 + <div class="mt-2 flex gap-2"> 165 + <div class="h-1 w-8 rounded-full bg-(--nucleus-accent)"></div> 166 + <div class="h-1 w-9.5 rounded-full bg-(--nucleus-accent2)"></div> 167 + </div> 168 + </div> 169 + {#if hasReloadChanges} 170 + <button onclick={handleSave} class="shrink-0 action-button px-6"> save & reload </button> 171 + {/if} 172 + <div class="grow"></div> 173 + <!-- svelte-ignore a11y_consider_explicit_label --> 174 + <button 175 + onclick={handleClose} 176 + class="rounded-xl p-2 text-(--nucleus-fg)/40 transition-all hover:scale-110" 177 + > 178 + <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 179 + <path 180 + stroke-linecap="round" 181 + stroke-linejoin="round" 182 + stroke-width="2.5" 183 + d="M6 18L18 6M6 6l12 12" 184 + /> 185 + </svg> 186 + </button> 187 + </div> 188 + 189 + <div class="flex-1 overflow-y-auto p-4"> 190 + {#if activeTab === 'advanced'} 191 + {@render advancedTab()} 192 + {:else if activeTab === 'moderation'} 193 + <div class="flex h-full items-center justify-center"> 194 + <div class="text-center"> 195 + <div class="mb-4 text-6xl opacity-50">🚧</div> 196 + <h3 class="text-xl font-bold opacity-80">todo</h3> 197 + </div> 198 + </div> 199 + {:else if activeTab === 'style'} 200 + {@render styleTab()} 201 + {/if} 202 + </div> 203 + 204 + <div> 205 + <div class="flex"> 206 + {#snippet tabButton(name: Tab)} 207 + {@const isActive = activeTab === name} 208 + <button 209 + onclick={() => (activeTab = name)} 210 + class="flex-1 border-t-3 px-4 py-3 font-semibold transition-colors hover:cursor-pointer {isActive 211 + ? 'border-(--nucleus-accent) bg-(--nucleus-accent)/20 text-(--nucleus-accent)' 212 + : 'border-(--nucleus-accent)/20 bg-transparent text-(--nucleus-fg)/60 hover:bg-(--nucleus-accent)/10'}" 213 + > 214 + {name} 215 + </button> 216 + {/snippet} 217 + {#each ['style', 'moderation', 'advanced'] as Tab[] as tabName (tabName)} 218 + {@render tabButton(tabName)} 219 + {/each} 220 + </div> 221 + </div> 222 + </div> 223 + </div> 224 + {/if}
+8 -12
src/lib/at/client.ts
··· 34 34 import { AppBskyActorProfile } from '@atcute/bluesky'; 35 35 import { WebSocket } from '@soffinal/websocket'; 36 36 import type { Notification } from './stardust'; 37 + import { get } from 'svelte/store'; 38 + import { settings } from '$lib/settings'; 37 39 // import { JetstreamSubscription } from '@atcute/jetstream'; 38 40 39 41 const cacheTtl = 1000 * 60 * 60 * 24; 40 - const handleCache = new PersistedLRU<Handle, AtprotoDid>({ 42 + export const handleCache = new PersistedLRU<Handle, AtprotoDid>({ 41 43 max: 1000, 42 44 ttl: cacheTtl, 43 45 prefix: 'handle' 44 46 }); 45 - const didDocCache = new PersistedLRU<ActorIdentifier, MiniDoc>({ 47 + export const didDocCache = new PersistedLRU<ActorIdentifier, MiniDoc>({ 46 48 max: 1000, 47 49 ttl: cacheTtl, 48 50 prefix: 'didDoc' 49 51 }); 50 - const recordCache = new PersistedLRU< 52 + export const recordCache = new PersistedLRU< 51 53 string, 52 54 InferOutput<typeof ComAtprotoRepoGetRecord.mainSchema.output.schema> 53 55 >({ ··· 56 58 prefix: 'record' 57 59 }); 58 60 59 - export let slingshotUrl: URL = new URL( 60 - localStorage.getItem('slingshotUrl') ?? 'https://slingshot.microcosm.blue' 61 - ); 62 - export let spacedustUrl: URL = new URL( 63 - localStorage.getItem('spacedustUrl') ?? 'https://spacedust.microcosm.blue' 64 - ); 65 - export let constellationUrl: URL = new URL( 66 - localStorage.getItem('constellationUrl') ?? 'https://constellation.microcosm.blue' 67 - ); 61 + export const slingshotUrl: URL = new URL(get(settings).endpoints.slingshot); 62 + export const spacedustUrl: URL = new URL(get(settings).endpoints.spacedust); 63 + export const constellationUrl: URL = new URL(get(settings).endpoints.constellation); 68 64 69 65 type NotificationsStreamEncoder = WebSocket.Encoder<undefined, Notification>; 70 66 export type NotificationsStream = WebSocket<NotificationsStreamEncoder>;
+11 -3
src/lib/cache.ts
··· 12 12 export class PersistedLRU<K extends string, V extends {}> { 13 13 private memory: LRUCache<K, V>; 14 14 private storage: Cache; 15 - private signals: Map<K, (data: V) => void>; 15 + private signals: Map<K, ((data: V) => void)[]>; 16 16 17 17 private prefix = ''; 18 18 ··· 49 49 } 50 50 getSignal(key: K): Promise<V> { 51 51 return new Promise<V>((resolve) => { 52 - this.signals.set(key, resolve); 52 + if (!this.signals.has(key)) { 53 + this.signals.set(key, [resolve]); 54 + return; 55 + } 56 + const signals = this.signals.get(key)!; 57 + signals.push(resolve); 58 + this.signals.set(key, signals); 53 59 }); 54 60 } 55 61 set(key: K, value: V): void { 56 62 this.memory.set(key, value); 57 63 this.storage.set(this.prefixed(key), value); 58 - this.signals.get(key)?.(value); 64 + for (const signal of this.signals.get(key) ?? []) { 65 + signal(value); 66 + } 59 67 this.storage.flush(); // TODO: uh evil and fucked up (this whole file is evil honestly) 60 68 } 61 69 has(key: K): boolean {
+68
src/lib/settings.ts
··· 1 + import { writable } from 'svelte/store'; 2 + import { defaultTheme, type Theme } from './theme.svelte'; 3 + 4 + export type ApiEndpoints = Record<string, string> & { 5 + slingshot: string; 6 + spacedust: string; 7 + constellation: string; 8 + }; 9 + export type Settings = { 10 + endpoints: ApiEndpoints; 11 + theme: Theme; 12 + }; 13 + 14 + export const defaultSettings: Settings = { 15 + endpoints: { 16 + slingshot: 'https://slingshot.microcosm.blue', 17 + spacedust: 'https://spacedust.microcosm.blue', 18 + constellation: 'https://constellation.microcosm.blue' 19 + }, 20 + theme: defaultTheme 21 + }; 22 + 23 + const createSettingsStore = () => { 24 + const stored = localStorage.getItem('settings'); 25 + 26 + const initial: Partial<Settings> = stored ? JSON.parse(stored) : defaultSettings; 27 + initial.endpoints = initial.endpoints ?? defaultSettings.endpoints; 28 + initial.theme = initial.theme ?? defaultSettings.theme; 29 + 30 + const { subscribe, set, update } = writable<Settings>(initial as Settings); 31 + 32 + subscribe((settings) => { 33 + const theme = settings.theme; 34 + document.documentElement.style.setProperty('--nucleus-bg', theme.bg); 35 + document.documentElement.style.setProperty('--nucleus-fg', theme.fg); 36 + document.documentElement.style.setProperty('--nucleus-accent', theme.accent); 37 + document.documentElement.style.setProperty('--nucleus-accent2', theme.accent2); 38 + }); 39 + 40 + return { 41 + subscribe, 42 + set: (value: Settings) => { 43 + localStorage.setItem('settings', JSON.stringify(value)); 44 + set(value); 45 + }, 46 + update: (fn: (value: Settings) => Settings) => { 47 + update((value) => { 48 + const newValue = fn(value); 49 + localStorage.setItem('settings', JSON.stringify(newValue)); 50 + return newValue; 51 + }); 52 + }, 53 + reset: () => { 54 + localStorage.setItem('settings', JSON.stringify(defaultSettings)); 55 + set(defaultSettings); 56 + } 57 + }; 58 + }; 59 + 60 + export const settings = createSettingsStore(); 61 + 62 + export const needsReload = (current: Settings, other: Settings): boolean => { 63 + return ( 64 + current.endpoints.slingshot !== other.endpoints.slingshot || 65 + current.endpoints.spacedust !== other.endpoints.spacedust || 66 + current.endpoints.constellation !== other.endpoints.constellation 67 + ); 68 + };
+11 -11
src/lib/theme.svelte.ts
··· 1 - export const theme = $state({ 2 - bg: '#11001c', // slate-900 - deep blue-grey background 3 - fg: '#f8fafc', // slate-50 - crisp white foreground 4 - accent: '#ec4899', // pink-500 - vibrant pink accent 5 - accent2: '#8b5cf6' // violet-500 - purple secondary accent 6 - }); 1 + export type Theme = Record<string, string> & { 2 + bg: string; 3 + fg: string; 4 + accent: string; 5 + accent2: string; 6 + }; 7 7 8 - export const setTheme = (bg: string, fg: string, accent: string, accent2: string) => { 9 - theme.bg = bg; 10 - theme.fg = fg; 11 - theme.accent = accent; 12 - theme.accent2 = accent2; 8 + export const defaultTheme: Theme = { 9 + bg: '#11001c', 10 + fg: '#f8fafc', 11 + accent: '#ec4899', 12 + accent2: '#8b5cf6' 13 13 };
+1 -3
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import '../app.css'; 3 - import { theme } from '$lib/theme.svelte'; 4 3 import favicon from '$lib/assets/favicon.svg'; 5 4 6 5 let { children } = $props(); ··· 11 10 </svelte:head> 12 11 13 12 <div 14 - class="grain min-h-screen transition-colors duration-300" 15 - style="background: {theme.bg}; color: {theme.fg};" 13 + class="grain min-h-screen bg-(--nucleus-bg) text-(--nucleus-fg) transition-colors duration-300" 16 14 > 17 15 {@render children?.()} 18 16 </div>
+44 -20
src/routes/+page.svelte
··· 2 2 import BskyPost from '$components/BskyPost.svelte'; 3 3 import PostComposer from '$components/PostComposer.svelte'; 4 4 import AccountSelector from '$components/AccountSelector.svelte'; 5 + import SettingsPopup from '$components/SettingsPopup.svelte'; 5 6 import { AtpClient, type NotificationsStreamEvent } from '$lib/at/client'; 6 7 import { accounts, addAccount, type Account } from '$lib/accounts'; 7 8 import { ··· 11 12 type ResourceUri 12 13 } from '@atcute/lexicons'; 13 14 import { onMount } from 'svelte'; 14 - import { theme } from '$lib/theme.svelte'; 15 15 import { fetchPostsWithBacklinks, hydratePosts } from '$lib/at/fetch'; 16 16 import { expect, ok } from '$lib/result'; 17 17 import { AppBskyFeedPost } from '@atcute/bluesky'; ··· 31 31 32 32 let posts = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyFeedPost.Main>>(); 33 33 let cursors = new SvelteMap<Did, { value?: string; end: boolean }>(); 34 + 35 + let isSettingsOpen = $state(false); 34 36 35 37 const addPosts = (did: Did, accTimeline: Map<ResourceUri, AppBskyFeedPost.Main>) => { 36 38 if (!posts.has(did)) { ··· 372 374 </script> 373 375 374 376 <div class="mx-auto flex h-screen max-w-2xl flex-col p-4"> 375 - <div class="mb-6 flex-shrink-0"> 376 - <h1 class="text-3xl font-bold tracking-tight" style="color: {theme.fg};">nucleus</h1> 377 - <div class="mt-1 flex gap-2"> 378 - <div class="h-1 w-11 rounded-full" style="background: {theme.accent};"></div> 379 - <div class="h-1 w-8 rounded-full" style="background: {theme.accent2};"></div> 377 + <div class="mb-6 flex flex-shrink-0 items-center justify-between"> 378 + <div> 379 + <h1 class="text-3xl font-bold tracking-tight">nucleus</h1> 380 + <div class="mt-1 flex gap-2"> 381 + <div class="h-1 w-11 rounded-full bg-(--nucleus-accent)"></div> 382 + <div class="h-1 w-8 rounded-full bg-(--nucleus-accent2)"></div> 383 + </div> 380 384 </div> 385 + <button 386 + onclick={() => (isSettingsOpen = true)} 387 + class="rounded-sm bg-(--nucleus-accent)/7 p-2.5 text-(--nucleus-accent) transition-all hover:scale-110 hover:shadow-lg" 388 + aria-label="Settings" 389 + > 390 + <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 391 + <path 392 + stroke-linecap="round" 393 + stroke-linejoin="round" 394 + stroke-width="2" 395 + 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" 396 + /> 397 + <path 398 + stroke-linecap="round" 399 + stroke-linejoin="round" 400 + stroke-width="2" 401 + d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" 402 + /> 403 + </svg> 404 + </button> 381 405 </div> 382 406 383 407 <div class="flex-shrink-0 space-y-4"> ··· 400 424 </div> 401 425 {:else} 402 426 <div 403 - class="flex flex-1 items-center justify-center rounded-sm border-2 px-4 py-2.5 backdrop-blur-sm" 404 - style="border-color: {theme.accent}33; background: {theme.accent}0a;" 427 + 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" 405 428 > 406 - <p class="text-sm opacity-80" style="color: {theme.fg};"> 407 - select or add an account to post 408 - </p> 429 + <p class="text-sm opacity-80">select or add an account to post</p> 409 430 </div> 410 431 {/if} 411 432 </div> 412 433 413 434 <hr 414 435 class="h-[4px] w-full rounded-full border-0" 415 - style="background: linear-gradient(to right, {theme.accent}, {theme.accent2});" 436 + style="background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));" 416 437 /> 417 438 </div> 418 439 419 - <div class="mt-4 overflow-y-scroll [scrollbar-width:none]" bind:this={scrollContainer}> 440 + <div 441 + class="mt-4 overflow-y-scroll [scrollbar-color:var(--nucleus-accent)_transparent]" 442 + bind:this={scrollContainer} 443 + > 420 444 {#if $accounts.length > 0} 421 445 {@render renderThreads()} 422 446 {:else} 423 447 <div class="flex justify-center py-4"> 424 - <p class="text-xl opacity-80" style="color: {theme.fg};"> 448 + <p class="text-xl opacity-80"> 425 449 <span class="text-4xl">x_x</span> <br /> no accounts are logged in! 426 450 </p> 427 451 </div> ··· 429 453 </div> 430 454 </div> 431 455 456 + <SettingsPopup bind:isOpen={isSettingsOpen} onClose={() => (isSettingsOpen = false)} /> 457 + 432 458 {#snippet renderThreads()} 433 459 <InfiniteLoader 434 460 {loaderState} ··· 439 465 {@render threadsView()} 440 466 {#snippet noData()} 441 467 <div class="flex justify-center py-4"> 442 - <p class="text-xl opacity-80" style="color: {theme.fg};"> 468 + <p class="text-xl opacity-80"> 443 469 all posts seen! <span class="text-2xl">:o</span> 444 470 </p> 445 471 </div> ··· 448 474 <div class="flex justify-center"> 449 475 <div 450 476 class="h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" 451 - style="border-color: {theme.accent} {theme.accent} {theme.accent} transparent;" 477 + style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;" 452 478 ></div> 453 479 </div> 454 480 {/snippet} 455 481 {#snippet error()} 456 482 <div class="flex justify-center py-4"> 457 - <p class="text-xl opacity-80" style="color: {theme.fg};"> 483 + <p class="text-xl opacity-80"> 458 484 <span class="text-4xl">:(</span> <br /> an error occurred while loading posts: {loadError} 459 485 </p> 460 486 </div> ··· 468 494 {#if thread.branchParentPost} 469 495 {@const post = thread.branchParentPost} 470 496 <div class="mb-1.5 flex items-center gap-1.5"> 471 - <span class="text-sm text-nowrap opacity-60" style="color: {theme.fg};" 472 - >{reverseChronological ? '↱' : '↳'}</span 473 - > 497 + <span class="text-sm text-nowrap opacity-60">{reverseChronological ? '↱' : '↳'}</span> 474 498 <BskyPost mini client={viewClient} {...post} /> 475 499 </div> 476 500 {/if}