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 await initiateLogin(did.value, handle);
75 } catch (error) {
76 loginError = `login failed: ${error}`;
77 loggingIn.set(null);
78 } finally {
79 isLoggingIn = false;
80 }
81 };
82
83 const initiateLogin = async (did: AtprotoDid, handle: Handle | null) => {
84 loggingIn.set({ did, handle });
85 const result = await flow.start(handle ?? did);
86 if (!result.ok) throw result.error;
87 };
88
89 const handleKeydown = (event: KeyboardEvent) => {
90 if (event.key === 'Enter' && !isLoggingIn) handleLogin();
91 };
92
93 const closeDropdown = () => {
94 isDropdownOpen = false;
95 };
96</script>
97
98<svelte:window onclick={closeDropdown} />
99
100<div class="relative">
101 <button
102 onclick={toggleDropdown}
103 class="flex h-13 w-13 items-center justify-center rounded-sm shadow-md transition-all hover:scale-110 hover:shadow-xl hover:saturate-150"
104 >
105 {#if selectedDid}
106 <ProfilePicture {client} did={selectedDid} size={13} />
107 {:else}
108 <PfpPlaceholder color="var(--nucleus-accent)" size={13} />
109 {/if}
110 </button>
111
112 {#if isDropdownOpen}
113 <!-- svelte-ignore a11y_click_events_have_key_events -->
114 <!-- svelte-ignore a11y_no_static_element_interactions -->
115 <div
116 class="absolute bottom-full z-20 mb-1 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"
117 onclick={(e) => e.stopPropagation()}
118 >
119 {#if accounts.length > 0}
120 <div class="p-2">
121 {#each accounts as account (account.did)}
122 {@const color = generateColorForDid(account.did)}
123 {#snippet action(name: string, icon: string, onClick: () => void)}
124 <div
125 title={name}
126 onclick={onClick}
127 class="hidden text-(--nucleus-accent) transition-all group-hover:block hover:scale-[1.2] hover:shadow-md"
128 >
129 <Icon class="h-5 w-5" {icon} />
130 </div>
131 {/snippet}
132 <button
133 onclick={() => selectAccount(account.did)}
134 class="
135 group flex w-full items-center gap-3 rounded-sm p-2 text-left text-sm font-medium transition-all
136 {account.did === selectedDid ? 'shadow-lg' : ''}
137 "
138 style="color: {color}; background: {account.did === selectedDid
139 ? `linear-gradient(135deg, color-mix(in srgb, var(--nucleus-accent) 20%, transparent), color-mix(in srgb, var(--nucleus-accent2) 20%, transparent))`
140 : 'transparent'};"
141 >
142 <span>@{account.handle}</span>
143
144 <div class="grow"></div>
145
146 {@render action('relogin', 'heroicons:arrow-path-rounded-square-solid', () =>
147 initiateLogin(account.did, account.handle)
148 )}
149 {@render action('logout', 'heroicons:trash-solid', () => onLogout(account.did))}
150
151 {#if account.did === selectedDid}
152 <Icon
153 icon="heroicons:check-16-solid"
154 class="h-5 w-5 scale-125 text-(--nucleus-accent) group-hover:hidden"
155 />
156 {/if}
157 </button>
158 {/each}
159 </div>
160 <div class="mx-2 h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div>
161 {/if}
162 <button
163 onclick={openLoginModal}
164 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]"
165 >
166 <Icon class="h-5 w-5 scale-[130%]" icon="heroicons:plus-16-solid" />
167 <span>add account</span>
168 </button>
169 </div>
170 {/if}
171</div>
172
173<Popup bind:isOpen={isLoginModalOpen} onClose={closeLoginModal} title="add account">
174 <!-- svelte-ignore a11y_no_static_element_interactions -->
175 <div class="space-y-2" onkeydown={handleKeydown}>
176 <div>
177 <label for="handle" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80">
178 account handle
179 </label>
180 <input
181 id="handle"
182 type="text"
183 bind:value={loginHandle}
184 placeholder="example.bsky.social"
185 class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3"
186 disabled={isLoggingIn}
187 />
188 </div>
189
190 {#if loginError}
191 <div class="error-disclaimer">
192 <p>
193 <Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" />
194 {loginError}
195 </p>
196 </div>
197 {/if}
198
199 <div class="flex gap-3 pt-3">
200 <button onclick={closeLoginModal} class="flex-1 action-button" disabled={isLoggingIn}>
201 cancel
202 </button>
203 <button
204 onclick={handleLogin}
205 class="flex-1 action-button border-transparent text-(--nucleus-fg)"
206 style="background: linear-gradient(135deg, var(--nucleus-accent), var(--nucleus-accent2));"
207 disabled={isLoggingIn}
208 >
209 {isLoggingIn ? 'logging in...' : 'login'}
210 </button>
211 </div>
212 </div>
213</Popup>