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 Dropdown from './Dropdown.svelte';
9 import { flow } from '$lib/at/oauth';
10 import { isHandle, type AtprotoDid } from '@atcute/lexicons/syntax';
11 import Icon from '@iconify/svelte';
12
13 interface Props {
14 client: AtpClient;
15 accounts: Array<Account>;
16 selectedDid?: AtprotoDid | null;
17 onAccountSelected: (did: AtprotoDid) => void;
18 onLogout: (did: AtprotoDid) => void;
19 }
20
21 let {
22 client,
23 accounts = [],
24 selectedDid = $bindable(null),
25 onAccountSelected,
26 onLogout
27 }: Props = $props();
28
29 let isDropdownOpen = $state(false);
30 let isLoginModalOpen = $state(false);
31 let loginHandle = $state('');
32 let loginError = $state('');
33 let isLoggingIn = $state(false);
34
35 const toggleDropdown = () => (isDropdownOpen = !isDropdownOpen);
36 const closeDropdown = () => (isDropdownOpen = false);
37
38 const selectAccount = (did: AtprotoDid) => {
39 onAccountSelected(did);
40 closeDropdown();
41 };
42
43 const openLoginModal = () => {
44 isLoginModalOpen = true;
45 closeDropdown();
46 loginHandle = '';
47 loginError = '';
48 // HACK: i hate this but it works so it doesnt really matter
49 setTimeout(() => document.getElementById('handle')?.focus(), 100);
50 };
51
52 const closeLoginModal = () => {
53 document.getElementById('handle')?.blur();
54 isLoginModalOpen = false;
55 loginHandle = '';
56 loginError = '';
57 };
58
59 const handleLogin = async () => {
60 try {
61 if (!loginHandle) throw 'please enter handle';
62
63 isLoggingIn = true;
64 loginError = '';
65
66 let handle: Handle;
67 if (isHandle(loginHandle)) handle = loginHandle;
68 else throw 'handle is invalid';
69
70 let did = await client.resolveHandle(handle);
71 if (!did.ok) throw did.error;
72
73 await initiateLogin(did.value, handle);
74 } catch (error) {
75 loginError = `login failed: ${error}`;
76 loggingIn.set(null);
77 } finally {
78 isLoggingIn = false;
79 }
80 };
81
82 const initiateLogin = async (did: AtprotoDid, handle: Handle | null) => {
83 loggingIn.set({ did, handle });
84 const result = await flow.start(handle ?? did);
85 if (!result.ok) throw result.error;
86 };
87
88 const handleKeydown = (event: KeyboardEvent) => {
89 if (event.key === 'Enter' && !isLoggingIn) handleLogin();
90 };
91</script>
92
93<Dropdown
94 class="min-w-52 rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl"
95 bind:isOpen={isDropdownOpen}
96>
97 {#snippet trigger()}
98 <button
99 onclick={toggleDropdown}
100 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"
101 >
102 {#if selectedDid}
103 <ProfilePicture {client} did={selectedDid} size={13} />
104 {:else}
105 <PfpPlaceholder color="var(--nucleus-accent)" size={13} />
106 {/if}
107 </button>
108 {/snippet}
109
110 {#if accounts.length > 0}
111 <div class="p-2">
112 {#each accounts as account (account.did)}
113 {@const color = generateColorForDid(account.did)}
114 {#snippet action(name: string, icon: string, onClick: () => void)}
115 <!-- svelte-ignore a11y_click_events_have_key_events -->
116 <!-- svelte-ignore a11y_no_static_element_interactions -->
117 <div
118 title={name}
119 onclick={onClick}
120 class="hidden text-(--nucleus-accent) transition-all group-hover:block hover:scale-[1.2] hover:shadow-md"
121 >
122 <Icon class="h-5 w-5" {icon} />
123 </div>
124 {/snippet}
125 <button
126 onclick={() => selectAccount(account.did)}
127 class="
128 group flex w-full items-center gap-3 rounded-sm p-2 text-left text-sm font-medium transition-all
129 {account.did === selectedDid ? 'shadow-lg' : ''}
130 "
131 style="color: {color}; background: {account.did === selectedDid
132 ? `linear-gradient(135deg, color-mix(in srgb, var(--nucleus-accent) 20%, transparent), color-mix(in srgb, var(--nucleus-accent2) 20%, transparent))`
133 : 'transparent'};"
134 >
135 <span>@{account.handle}</span>
136
137 <div class="grow"></div>
138
139 {@render action('relogin', 'heroicons:arrow-path-rounded-square-solid', () =>
140 initiateLogin(account.did, account.handle)
141 )}
142 {@render action('logout', 'heroicons:trash-solid', () => onLogout(account.did))}
143
144 {#if account.did === selectedDid}
145 <Icon
146 icon="heroicons:check-16-solid"
147 class="h-5 w-5 scale-125 text-(--nucleus-accent) group-hover:hidden"
148 />
149 {/if}
150 </button>
151 {/each}
152 </div>
153 <div class="mx-2 h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div>
154 {/if}
155 <button
156 onclick={openLoginModal}
157 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]"
158 >
159 <Icon class="h-5 w-5 scale-[130%]" icon="heroicons:plus-16-solid" />
160 <span>add account</span>
161 </button>
162</Dropdown>
163
164<Popup bind:isOpen={isLoginModalOpen} onClose={closeLoginModal} title="add account">
165 <!-- svelte-ignore a11y_no_static_element_interactions -->
166 <div class="space-y-2" onkeydown={handleKeydown}>
167 <div>
168 <label for="handle" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80">
169 account handle
170 </label>
171 <input
172 id="handle"
173 type="text"
174 bind:value={loginHandle}
175 placeholder="example.bsky.social"
176 class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3"
177 disabled={isLoggingIn}
178 />
179 </div>
180
181 {#if loginError}
182 <div class="error-disclaimer">
183 <p>
184 <Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" />
185 {loginError}
186 </p>
187 </div>
188 {/if}
189
190 <div class="flex gap-3 pt-3">
191 <button onclick={closeLoginModal} class="flex-1 action-button" disabled={isLoggingIn}>
192 cancel
193 </button>
194 <button
195 onclick={handleLogin}
196 class="flex-1 action-button border-transparent text-(--nucleus-fg)"
197 style="background: linear-gradient(135deg, var(--nucleus-accent), var(--nucleus-accent2));"
198 disabled={isLoggingIn}
199 >
200 {isLoggingIn ? 'logging in...' : 'login'}
201 </button>
202 </div>
203 </div>
204</Popup>