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 Popup from './Popup.svelte';
8 import { flow } from '$lib/at/oauth';
9 import { isHandle, type AtprotoDid } from '@atcute/lexicons/syntax';
10 import Icon from '@iconify/svelte';
11
12 interface Props {
13 client: AtpClient;
14 accounts: Array<Account>;
15 selectedDid?: AtprotoDid | null;
16 onAccountSelected: (did: AtprotoDid) => void;
17 onLogout: (did: AtprotoDid) => void;
18 }
19
20 let {
21 client,
22 accounts = [],
23 selectedDid = $bindable(null),
24 onAccountSelected,
25 onLogout
26 }: Props = $props();
27
28 let isDropdownOpen = $state(false);
29 let isLoginModalOpen = $state(false);
30 let loginHandle = $state('');
31 let loginError = $state('');
32 let isLoggingIn = $state(false);
33
34 const toggleDropdown = (e: MouseEvent) => {
35 e.stopPropagation();
36 isDropdownOpen = !isDropdownOpen;
37 };
38
39 const selectAccount = (did: AtprotoDid) => {
40 onAccountSelected(did);
41 isDropdownOpen = false;
42 };
43
44 const openLoginModal = () => {
45 isLoginModalOpen = true;
46 isDropdownOpen = false;
47 loginHandle = '';
48 loginError = '';
49 // HACK: i hate this but it works so it doesnt really matter
50 setTimeout(() => document.getElementById('handle')?.focus(), 100);
51 };
52
53 const closeLoginModal = () => {
54 document.getElementById('handle')?.blur();
55 isLoginModalOpen = false;
56 loginHandle = '';
57 loginError = '';
58 };
59
60 const handleLogin = async () => {
61 try {
62 if (!loginHandle) throw 'please enter handle';
63
64 isLoggingIn = true;
65 loginError = '';
66
67 let handle: Handle;
68 if (isHandle(loginHandle)) handle = loginHandle;
69 else throw 'handle is invalid';
70
71 let did = await client.resolveHandle(handle);
72 if (!did.ok) throw did.error;
73
74 loggingIn.set({ did: did.value, handle });
75 const result = await flow.start(handle);
76 if (!result.ok) throw result.error;
77 } catch (error) {
78 loginError = `login failed: ${error}`;
79 loggingIn.set(null);
80 } finally {
81 isLoggingIn = false;
82 }
83 };
84
85 const handleKeydown = (event: KeyboardEvent) => {
86 if (event.key === 'Enter' && !isLoggingIn) handleLogin();
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<Popup bind:isOpen={isLoginModalOpen} onClose={closeLoginModal} title="add account">
186 <!-- svelte-ignore a11y_no_static_element_interactions -->
187 <div class="space-y-2" onkeydown={handleKeydown}>
188 <div>
189 <label for="handle" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80">
190 account handle
191 </label>
192 <input
193 id="handle"
194 type="text"
195 bind:value={loginHandle}
196 placeholder="example.bsky.social"
197 class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3"
198 disabled={isLoggingIn}
199 />
200 </div>
201
202 {#if loginError}
203 <div class="error-disclaimer">
204 <p>
205 <Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" />
206 {loginError}
207 </p>
208 </div>
209 {/if}
210
211 <div class="flex gap-3 pt-3">
212 <button onclick={closeLoginModal} class="flex-1 action-button" disabled={isLoggingIn}>
213 cancel
214 </button>
215 <button
216 onclick={handleLogin}
217 class="flex-1 action-button border-transparent text-(--nucleus-fg)"
218 style="background: linear-gradient(135deg, var(--nucleus-accent), var(--nucleus-accent2));"
219 disabled={isLoggingIn}
220 >
221 {isLoggingIn ? 'logging in...' : 'login'}
222 </button>
223 </div>
224 </div>
225</Popup>