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