1<script lang="ts">
2 import { generateColorForDid, loggingIn, type Account } from '$lib/accounts';
3 import { AtpClient } from '$lib/at/client';
4 import type { Handle } from '@atcute/lexicons';
5 import ProfilePicture from './ProfilePicture.svelte';
6 import PfpPlaceholder from './PfpPlaceholder.svelte';
7 import { flow } from '$lib/at/oauth';
8 import { isHandle, type AtprotoDid } from '@atcute/lexicons/syntax';
9 import Icon from '@iconify/svelte';
10
11 interface Props {
12 client: AtpClient;
13 accounts: Array<Account>;
14 selectedDid?: AtprotoDid | null;
15 onAccountSelected: (did: AtprotoDid) => void;
16 onLogout: (did: AtprotoDid) => void;
17 }
18
19 let {
20 client,
21 accounts = [],
22 selectedDid = $bindable(null),
23 onAccountSelected,
24 onLogout
25 }: Props = $props();
26
27 let isDropdownOpen = $state(false);
28 let isLoginModalOpen = $state(false);
29 let loginHandle = $state('');
30 let loginError = $state('');
31 let isLoggingIn = $state(false);
32
33 const toggleDropdown = (e: MouseEvent) => {
34 e.stopPropagation();
35 isDropdownOpen = !isDropdownOpen;
36 };
37
38 const selectAccount = (did: AtprotoDid) => {
39 onAccountSelected(did);
40 isDropdownOpen = false;
41 };
42
43 const openLoginModal = () => {
44 isLoginModalOpen = true;
45 isDropdownOpen = false;
46 loginHandle = '';
47 loginError = '';
48 };
49
50 const closeLoginModal = () => {
51 isLoginModalOpen = false;
52 loginHandle = '';
53 loginError = '';
54 };
55
56 const handleLogin = async () => {
57 try {
58 if (!loginHandle) throw 'please enter handle';
59
60 isLoggingIn = true;
61 loginError = '';
62
63 let handle: Handle;
64 if (isHandle(loginHandle)) handle = loginHandle;
65 else throw 'handle is invalid';
66
67 let did = await client.resolveHandle(handle);
68 if (!did.ok) throw did.error;
69
70 loggingIn.set({ did: did.value, handle });
71 const result = await flow.start(handle);
72 if (!result.ok) throw result.error;
73 } catch (error) {
74 loginError = `login failed: ${error}`;
75 loggingIn.set(null);
76 } finally {
77 isLoggingIn = false;
78 }
79 };
80
81 const handleKeydown = (event: KeyboardEvent) => {
82 if (event.key === 'Escape') {
83 closeLoginModal();
84 } else if (event.key === 'Enter' && !isLoggingIn) {
85 handleLogin();
86 }
87 };
88
89 const closeDropdown = () => {
90 isDropdownOpen = false;
91 };
92</script>
93
94<svelte:window onclick={closeDropdown} />
95
96<div class="relative">
97 <button
98 onclick={toggleDropdown}
99 class="flex h-16 w-16 items-center justify-center rounded-sm shadow-lg transition-all hover:scale-105 hover:shadow-xl"
100 >
101 {#if selectedDid}
102 <ProfilePicture {client} did={selectedDid} size={15} />
103 {:else}
104 <PfpPlaceholder color="var(--nucleus-accent)" size={15} />
105 {/if}
106 </button>
107
108 {#if isDropdownOpen}
109 <!-- svelte-ignore a11y_click_events_have_key_events -->
110 <!-- svelte-ignore a11y_no_static_element_interactions -->
111 <div
112 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"
113 onclick={(e) => e.stopPropagation()}
114 >
115 {#if accounts.length > 0}
116 <div class="p-2">
117 {#each accounts as account (account.did)}
118 {@const color = generateColorForDid(account.did)}
119 <button
120 onclick={() => selectAccount(account.did)}
121 class="
122 group flex w-full items-center gap-3 rounded-sm p-2 text-left text-sm font-medium transition-all
123 {account.did === selectedDid ? 'shadow-lg' : ''}
124 "
125 style="color: {color}; background: {account.did === selectedDid
126 ? `linear-gradient(135deg, color-mix(in srgb, var(--nucleus-accent) 20%, transparent), color-mix(in srgb, var(--nucleus-accent2) 20%, transparent))`
127 : 'transparent'};"
128 >
129 <span>@{account.handle}</span>
130 <svg
131 xmlns="http://www.w3.org/2000/svg"
132 onclick={() => onLogout(account.did)}
133 class="ml-auto hidden h-5 w-5 text-(--nucleus-accent) transition-all group-hover:block hover:scale-[1.2] hover:shadow-md"
134 width="24"
135 height="24"
136 viewBox="0 0 20 20"
137 ><path
138 fill="currentColor"
139 fill-rule="evenodd"
140 d="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443q-1.193.115-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022l.841 10.518A2.75 2.75 0 0 0 7.596 19h4.807a2.75 2.75 0 0 0 2.742-2.53l.841-10.52l.149.023a.75.75 0 0 0 .23-1.482A41 41 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1zM10 4q1.26 0 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325Q8.74 4 10 4M8.58 7.72a.75.75 0 0 0-1.5.06l.3 7.5a.75.75 0 1 0 1.5-.06zm4.34.06a.75.75 0 1 0-1.5-.06l-.3 7.5a.75.75 0 1 0 1.5.06z"
141 clip-rule="evenodd"
142 /></svg
143 >
144
145 {#if account.did === selectedDid}
146 <svg
147 xmlns="http://www.w3.org/2000/svg"
148 class="ml-auto h-5 w-5 text-(--nucleus-accent) group-hover:hidden"
149 width="24"
150 height="24"
151 viewBox="0 0 24 24"
152 ><path
153 fill="currentColor"
154 fill-rule="evenodd"
155 d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353l8.493-12.74a.75.75 0 0 1 1.04-.207"
156 clip-rule="evenodd"
157 stroke-width="1.5"
158 stroke="currentColor"
159 /></svg
160 >
161 {/if}
162 </button>
163 {/each}
164 </div>
165 <div class="mx-2 h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div>
166 {/if}
167 <button
168 onclick={openLoginModal}
169 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]"
170 >
171 <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
172 <path
173 stroke-linecap="round"
174 stroke-linejoin="round"
175 stroke-width="2.5"
176 d="M12 4v16m8-8H4"
177 />
178 </svg>
179 <span>add account</span>
180 </button>
181 </div>
182 {/if}
183</div>
184
185{#if isLoginModalOpen}
186 <div
187 class="fixed inset-0 z-50 flex items-center justify-center bg-(--nucleus-bg)/80 backdrop-blur-sm"
188 onclick={closeLoginModal}
189 onkeydown={handleKeydown}
190 role="button"
191 tabindex="-1"
192 >
193 <!-- svelte-ignore a11y_interactive_supports_focus -->
194 <!-- svelte-ignore a11y_click_events_have_key_events -->
195 <div
196 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"
197 onclick={(e) => e.stopPropagation()}
198 role="dialog"
199 >
200 <div class="mb-6 flex items-center justify-between">
201 <div>
202 <h2 class="text-2xl font-bold">add account</h2>
203 <div class="mt-2 flex gap-2">
204 <div class="h-1 w-10 rounded-full bg-(--nucleus-accent)"></div>
205 <div class="h-1 w-9 rounded-full bg-(--nucleus-accent2)"></div>
206 </div>
207 </div>
208 <!-- svelte-ignore a11y_consider_explicit_label -->
209 <button
210 onclick={closeLoginModal}
211 class="rounded-xl p-2 text-(--nucleus-fg)/40 transition-all hover:scale-110 hover:text-(--nucleus-fg)"
212 >
213 <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
214 <path
215 stroke-linecap="round"
216 stroke-linejoin="round"
217 stroke-width="2.5"
218 d="M6 18L18 6M6 6l12 12"
219 />
220 </svg>
221 </button>
222 </div>
223
224 <div class="space-y-5">
225 <div>
226 <label for="handle" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80">
227 handle
228 </label>
229 <input
230 id="handle"
231 type="text"
232 bind:value={loginHandle}
233 placeholder="example.bsky.social"
234 class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3"
235 disabled={isLoggingIn}
236 />
237 </div>
238
239 {#if loginError}
240 <div class="error-disclaimer">
241 <p>
242 <Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" />
243 {loginError}
244 </p>
245 </div>
246 {/if}
247
248 <div class="flex gap-3 pt-3">
249 <button onclick={closeLoginModal} class="flex-1 action-button" disabled={isLoggingIn}>
250 cancel
251 </button>
252 <button
253 onclick={handleLogin}
254 class="flex-1 action-button border-transparent text-(--nucleus-fg)"
255 style="background: linear-gradient(135deg, var(--nucleus-accent), var(--nucleus-accent2));"
256 disabled={isLoggingIn}
257 >
258 {isLoggingIn ? 'logging in...' : 'login'}
259 </button>
260 </div>
261 </div>
262 </div>
263 </div>
264{/if}