1<script lang="ts">
2 import { generateColorForDid, type Account } from '$lib/accounts';
3 import { AtpClient } from '$lib/at/client';
4 import type { Did, Handle } from '@atcute/lexicons';
5 import { theme } from '$lib/theme.svelte';
6
7 interface Props {
8 accounts: Array<Account>;
9 selectedDid?: Did | null;
10 onAccountSelected: (did: Did) => void;
11 onLoginSucceed: (did: Did, handle: Handle, password: string) => void;
12 onLogout: (did: Did) => void;
13 }
14
15 let {
16 accounts = [],
17 selectedDid = $bindable(null),
18 onAccountSelected,
19 onLoginSucceed,
20 onLogout
21 }: Props = $props();
22
23 let color = $derived(selectedDid ? (generateColorForDid(selectedDid) ?? theme.fg) : theme.fg);
24
25 let isDropdownOpen = $state(false);
26 let isLoginModalOpen = $state(false);
27 let loginHandle = $state('');
28 let loginPassword = $state('');
29 let loginError = $state('');
30 let isLoggingIn = $state(false);
31
32 const toggleDropdown = (e: MouseEvent) => {
33 e.stopPropagation();
34 isDropdownOpen = !isDropdownOpen;
35 };
36
37 const selectAccount = (did: Did) => {
38 onAccountSelected(did);
39 isDropdownOpen = false;
40 };
41
42 const openLoginModal = () => {
43 isLoginModalOpen = true;
44 isDropdownOpen = false;
45 loginHandle = '';
46 loginPassword = '';
47 loginError = '';
48 };
49
50 const closeLoginModal = () => {
51 isLoginModalOpen = false;
52 loginHandle = '';
53 loginPassword = '';
54 loginError = '';
55 };
56
57 const handleLogin = async () => {
58 if (!loginHandle || !loginPassword) {
59 loginError = 'please enter both handle and password';
60 return;
61 }
62
63 isLoggingIn = true;
64 loginError = '';
65
66 try {
67 const client = new AtpClient();
68 const result = await client.login(loginHandle as Handle, loginPassword);
69
70 if (!result.ok) {
71 loginError = result.error;
72 isLoggingIn = false;
73 return;
74 }
75
76 if (!client.didDoc) {
77 loginError = 'failed to get did document';
78 isLoggingIn = false;
79 return;
80 }
81
82 onLoginSucceed(client.didDoc.did, loginHandle as Handle, loginPassword);
83 closeLoginModal();
84 } catch (error) {
85 loginError = `login failed: ${error}`;
86 } finally {
87 isLoggingIn = false;
88 }
89 };
90
91 const handleKeydown = (event: KeyboardEvent) => {
92 if (event.key === 'Escape') {
93 closeLoginModal();
94 } else if (event.key === 'Enter' && !isLoggingIn) {
95 handleLogin();
96 }
97 };
98
99 const closeDropdown = () => {
100 isDropdownOpen = false;
101 };
102
103 let selectedAccount = $derived(accounts.find((acc) => acc.did === selectedDid));
104</script>
105
106<svelte:window onclick={closeDropdown} />
107
108<div class="relative">
109 <button
110 onclick={toggleDropdown}
111 class="group flex h-full items-center gap-2 rounded-sm border-2 px-2 font-medium shadow-lg transition-all hover:scale-105 hover:shadow-xl"
112 style="border-color: {theme.accent}66; background: {theme.accent}18; color: {color}; backdrop-filter: blur(8px);"
113 >
114 <span class="font-bold">
115 {selectedAccount ? `@${selectedAccount.handle}` : 'select account'}
116 </span>
117 <svg
118 class="h-4 w-4 transition-transform {isDropdownOpen ? 'rotate-180' : ''}"
119 style="color: {theme.accent};"
120 fill="none"
121 stroke="currentColor"
122 viewBox="0 0 24 24"
123 >
124 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
125 </svg>
126 </button>
127
128 {#if isDropdownOpen}
129 <!-- svelte-ignore a11y_click_events_have_key_events -->
130 <!-- svelte-ignore a11y_no_static_element_interactions -->
131 <div
132 class="absolute left-0 z-10 mt-3 min-w-52 overflow-hidden rounded-sm border-2 shadow-2xl backdrop-blur-lg"
133 style="border-color: {theme.accent}; background: {theme.bg}f0;"
134 onclick={(e) => e.stopPropagation()}
135 >
136 {#if accounts.length > 0}
137 <div class="p-2">
138 {#each accounts as account (account.did)}
139 {@const color = generateColorForDid(account.did)}
140 <button
141 onclick={() => selectAccount(account.did)}
142 class="
143 group flex w-full items-center gap-3 rounded-sm p-2 text-left text-sm font-medium transition-all
144 {account.did === selectedDid ? 'shadow-lg' : ''}
145 "
146 style="color: {color}; background: {account.did === selectedDid
147 ? `linear-gradient(135deg, ${theme.accent}33, ${theme.accent2}33)`
148 : 'transparent'};"
149 >
150 <span>@{account.handle}</span>
151 <svg
152 xmlns="http://www.w3.org/2000/svg"
153 onclick={() => onLogout(account.did)}
154 class="ml-auto hidden h-5 w-5 transition-all group-hover:[display:block] hover:scale-[1.2] hover:shadow-md"
155 style="color: {theme.accent};"
156 width="24"
157 height="24"
158 viewBox="0 0 20 20"
159 ><path
160 fill="currentColor"
161 fill-rule="evenodd"
162 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"
163 clip-rule="evenodd"
164 /></svg
165 >
166
167 {#if account.did === selectedDid}
168 <svg
169 xmlns="http://www.w3.org/2000/svg"
170 class="ml-auto h-5 w-5 group-hover:hidden"
171 style="color: {theme.accent};"
172 width="24"
173 height="24"
174 viewBox="0 0 24 24"
175 ><path
176 fill="currentColor"
177 fill-rule="evenodd"
178 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"
179 clip-rule="evenodd"
180 stroke-width="1.5"
181 stroke="currentColor"
182 /></svg
183 >
184 {/if}
185 </button>
186 {/each}
187 </div>
188 <div
189 class="mx-2 h-px"
190 style="background: linear-gradient(to right, {theme.accent}, {theme.accent2});"
191 ></div>
192 {/if}
193 <button
194 onclick={openLoginModal}
195 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]"
196 style="color: {theme.accent};"
197 >
198 <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
199 <path
200 stroke-linecap="round"
201 stroke-linejoin="round"
202 stroke-width="2.5"
203 d="M12 4v16m8-8H4"
204 />
205 </svg>
206 <span>add account</span>
207 </button>
208 </div>
209 {/if}
210</div>
211
212{#if isLoginModalOpen}
213 <div
214 class="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm"
215 style="background: {theme.bg}cc;"
216 onclick={closeLoginModal}
217 onkeydown={handleKeydown}
218 role="button"
219 tabindex="-1"
220 >
221 <!-- svelte-ignore a11y_interactive_supports_focus -->
222 <!-- svelte-ignore a11y_click_events_have_key_events -->
223 <div
224 class="w-full max-w-md rounded-sm border-2 p-5 shadow-2xl"
225 style="background: {theme.bg}; border-color: {theme.accent};"
226 onclick={(e) => e.stopPropagation()}
227 role="dialog"
228 >
229 <div class="mb-6 flex items-center justify-between">
230 <div>
231 <h2 class="text-2xl font-bold" style="color: {theme.fg};">add account</h2>
232 <div class="mt-2 flex gap-2">
233 <div class="h-1 w-10 rounded-full" style="background: {theme.accent};"></div>
234 <div class="h-1 w-9 rounded-full" style="background: {theme.accent2};"></div>
235 </div>
236 </div>
237 <!-- svelte-ignore a11y_consider_explicit_label -->
238 <button
239 onclick={closeLoginModal}
240 class="rounded-xl p-2 transition-all hover:scale-110"
241 style="color: {theme.fg}66; hover:color: {theme.fg};"
242 >
243 <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
244 <path
245 stroke-linecap="round"
246 stroke-linejoin="round"
247 stroke-width="2.5"
248 d="M6 18L18 6M6 6l12 12"
249 />
250 </svg>
251 </button>
252 </div>
253
254 <div class="space-y-5">
255 <div>
256 <label for="handle" class="mb-2 block text-sm font-semibold" style="color: {theme.fg}cc;">
257 handle
258 </label>
259 <input
260 id="handle"
261 type="text"
262 bind:value={loginHandle}
263 placeholder="example.bsky.social"
264 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"
265 style="background: {theme.accent}08; border-color: {theme.accent}66; color: {theme.fg};"
266 disabled={isLoggingIn}
267 />
268 </div>
269
270 <div>
271 <label
272 for="password"
273 class="mb-2 block text-sm font-semibold"
274 style="color: {theme.fg}cc;"
275 >
276 app password
277 </label>
278 <input
279 id="password"
280 type="password"
281 bind:value={loginPassword}
282 placeholder="xxxx-xxxx-xxxx-xxxx"
283 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"
284 style="background: {theme.accent}08; border-color: {theme.accent}66; color: {theme.fg};"
285 disabled={isLoggingIn}
286 />
287 </div>
288
289 {#if loginError}
290 <div
291 class="rounded-sm border-2 p-4"
292 style="background: #ef444422; border-color: #ef4444;"
293 >
294 <p class="text-sm font-medium" style="color: #fca5a5;">{loginError}</p>
295 </div>
296 {/if}
297
298 <div class="flex gap-3 pt-3">
299 <button
300 onclick={closeLoginModal}
301 class="flex-1 rounded-sm border-2 px-5 py-3 font-semibold transition-all hover:scale-105"
302 style="background: {theme.bg}; border-color: {theme.fg}33; color: {theme.fg};"
303 disabled={isLoggingIn}
304 >
305 cancel
306 </button>
307 <button
308 onclick={handleLogin}
309 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"
310 style="background: linear-gradient(135deg, {theme.accent}, {theme.accent2}); border-color: transparent; color: {theme.fg};"
311 disabled={isLoggingIn}
312 >
313 {isLoggingIn ? 'logging in...' : 'login'}
314 </button>
315 </div>
316 </div>
317 </div>
318 </div>
319{/if}