appview-less bluesky client
1<script lang="ts">
2 import { needsReload, settings } from '$lib/settings';
3 import { get } from 'svelte/store';
4 import Tabs from './Tabs.svelte';
5 import { portal } from 'svelte-portal';
6 import {
7 router,
8 clients,
9 accountPreferences,
10 setAccountPreferences,
11 syncAccountPreferences,
12 loadAccountPreferences
13 } from '$lib/state.svelte';
14 import { accounts as accountsStore, generateColorForDid } from '$lib/accounts';
15 import AccountSelector from './AccountSelector.svelte';
16 import Dropdown from './Dropdown.svelte';
17 import SettingsAdvancedTab from './SettingsAdvancedTab.svelte';
18 import SettingsStyleTab from './SettingsStyleTab.svelte';
19 import SettingsModerationTab from './SettingsModerationTab.svelte';
20 import SettingsFeedsTab from './SettingsFeedsTab.svelte';
21 import type { Did } from '@atcute/lexicons';
22 import type { AtprotoDid } from '@atcute/lexicons/syntax';
23 import Icon from '@iconify/svelte';
24 import type { FeedGenerator } from '$lib/at/feeds';
25
26 interface Props {
27 tab: string;
28 }
29
30 let { tab }: Props = $props();
31
32 let localSettings = $state(get(settings));
33 let hasReloadChanges = $derived(needsReload($settings, localSettings));
34
35 $effect(() => {
36 $settings.theme = localSettings.theme;
37 });
38
39 const handleSave = () => {
40 settings.set(localSettings);
41 window.location.reload();
42 };
43
44 const handleReset = () => {
45 const confirmed = confirm('reset all settings to defaults?');
46 if (!confirmed) return;
47 settings.reset();
48 window.location.reload();
49 };
50
51 const onTabChange = (tab: string) => router.replace(`/settings/${tab}`);
52
53 let selectedAccount: AtprotoDid | null = $state(null);
54 let syncStatus = $state<'syncing' | 'synced' | null>(null);
55 let isAccountDropdownOpen = $state(false);
56
57 const accounts = $derived($accountsStore.filter((a) => clients.has(a.did)));
58 const selectedAccountData = $derived(accounts.find((a) => a.did === selectedAccount));
59 const currentPrefs = $derived(
60 selectedAccount ? (accountPreferences.get(selectedAccount) ?? null) : null
61 );
62 const mutes = $derived(currentPrefs?.mutes ?? []);
63
64 $effect(() => {
65 if (accounts.length > 0 && !selectedAccount) {
66 selectedAccount = accounts[0].did;
67 }
68 });
69
70 let syncDebounceTimer: ReturnType<typeof setTimeout> | null = null;
71 const SYNC_DEBOUNCE_MS = 1000;
72
73 const scheduleSyncFor = (did: AtprotoDid) => {
74 if (syncDebounceTimer) clearTimeout(syncDebounceTimer);
75 syncDebounceTimer = setTimeout(async () => {
76 syncStatus = 'syncing';
77 await syncAccountPreferences(did);
78 syncStatus = 'synced';
79 setTimeout(() => (syncStatus = null), 2000);
80 }, SYNC_DEBOUNCE_MS);
81 };
82
83 const handleAddMute = (did: Did) => {
84 if (!selectedAccount) return;
85 setAccountPreferences(selectedAccount, { mutes: [...mutes, did] });
86 scheduleSyncFor(selectedAccount);
87 };
88
89 const handleRemoveMute = (did: Did) => {
90 if (!selectedAccount) return;
91 setAccountPreferences(selectedAccount, { mutes: mutes.filter((m) => m !== did) });
92 scheduleSyncFor(selectedAccount);
93 };
94
95 const handleReload = async () => {
96 if (!selectedAccount) return;
97 syncStatus = 'syncing';
98 await loadAccountPreferences({ did: selectedAccount, handle: null });
99 syncStatus = 'synced';
100 setTimeout(() => (syncStatus = null), 2000);
101 };
102
103 const feeds = $derived(localSettings.feeds);
104
105 const handleAddFeed = (feed: FeedGenerator) => {
106 localSettings.feeds = [...localSettings.feeds, { feed, pinned: false }];
107 settings.set(localSettings);
108 };
109
110 const handleRemoveFeed = (uri: string) => {
111 localSettings.feeds = localSettings.feeds.filter((f) => f.feed.uri !== uri);
112 settings.set(localSettings);
113 };
114
115 const handleTogglePin = (uri: string) => {
116 localSettings.feeds = localSettings.feeds.map((f) =>
117 f.feed.uri === uri ? { ...f, pinned: !f.pinned } : f
118 );
119 settings.set(localSettings);
120 };
121</script>
122
123<div class="flex flex-col">
124 <div class="mb-6 flex items-center justify-between p-4 pb-0">
125 <div>
126 <h2 class="text-3xl font-bold">settings</h2>
127 <div class="mt-2 flex gap-2">
128 <div class="h-1 w-8 rounded-full bg-(--nucleus-accent)"></div>
129 <div class="h-1 w-9.5 rounded-full bg-(--nucleus-accent2)"></div>
130 </div>
131 </div>
132 <div class="flex items-center gap-2">
133 {#if tab === 'moderation'}
134 {#if syncStatus}
135 <span class="text-xs opacity-70">{syncStatus}</span>
136 {/if}
137 <Dropdown
138 class="min-w-48 rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl"
139 bind:isOpen={isAccountDropdownOpen}
140 placement="bottom-end"
141 >
142 {#snippet trigger()}
143 <button
144 onclick={() => (isAccountDropdownOpen = !isAccountDropdownOpen)}
145 class="flex action-button items-center gap-1.5 text-sm"
146 style="color: {selectedAccountData
147 ? generateColorForDid(selectedAccountData.did)
148 : 'inherit'}"
149 >
150 <span>@{selectedAccountData?.handle ?? selectedAccount?.slice(0, 12)}</span>
151 <span class="opacity-50">▾</span>
152 </button>
153 {/snippet}
154 <AccountSelector
155 {accounts}
156 selectedDid={selectedAccount}
157 onSelect={(did) => {
158 selectedAccount = did;
159 isAccountDropdownOpen = false;
160 }}
161 />
162 </Dropdown>
163 <button onclick={handleReload} class="action-button p-2" title="reload from pocket">
164 <Icon
165 class={syncStatus === 'syncing' ? 'animate-spin' : ''}
166 icon="heroicons:arrow-path-16-solid"
167 width="20"
168 />
169 </button>
170 {:else if hasReloadChanges}
171 <button onclick={handleSave} class="action-button animate-pulse shadow-lg">
172 save & reload
173 </button>
174 {/if}
175 </div>
176 </div>
177
178 <div class="flex-1">
179 {#if tab === 'advanced'}
180 <SettingsAdvancedTab bind:localSettings onReset={handleReset} />
181 {:else if tab === 'moderation'}
182 <SettingsModerationTab
183 {mutes}
184 onAddMute={handleAddMute}
185 onRemoveMute={handleRemoveMute}
186 {selectedAccount}
187 />
188 {:else if tab === 'style'}
189 <SettingsStyleTab bind:localSettings />
190 {:else if tab === 'feeds'}
191 <SettingsFeedsTab
192 {feeds}
193 onAddFeed={handleAddFeed}
194 onRemoveFeed={handleRemoveFeed}
195 onTogglePin={handleTogglePin}
196 />
197 {/if}
198 </div>
199
200 <div
201 use:portal={'#footer-portal'}
202 class="
203 z-20 w-full max-w-2xl bg-(--nucleus-bg) p-4 pt-2 pb-1 shadow-[0_-10px_20px_-5px_rgba(0,0,0,0.1)]
204 "
205 >
206 <Tabs tabs={['feeds', 'moderation', 'style', 'advanced']} activeTab={tab} {onTabChange} />
207 </div>
208</div>