your personal website on atproto - mirror
blento.app
1<script lang="ts" module>
2 export const loginModalState = $state({
3 visible: false,
4 show: () => (loginModalState.visible = true),
5 hide: () => (loginModalState.visible = false)
6 });
7</script>
8
9<script lang="ts">
10 import { login, signup } from '$lib/atproto';
11 import type { ActorIdentifier, Did } from '@atcute/lexicons';
12 import Button from './Button.svelte';
13 import { onMount, tick } from 'svelte';
14 import SecondaryButton from './SecondaryButton.svelte';
15 import HandleInput from './HandleInput.svelte';
16 import { AppBskyActorDefs } from '@atcute/bluesky';
17 import { Avatar } from '@foxui/core';
18
19 let { signUp = true, loginOnSelect = true }: { signUp?: boolean; loginOnSelect?: boolean } =
20 $props();
21
22 let value = $state('');
23 let error: string | null = $state(null);
24 let loadingLogin = $state(false);
25 let loadingSignup = $state(false);
26
27 async function onSubmit(event?: Event) {
28 event?.preventDefault();
29 if (loadingLogin) return;
30
31 error = null;
32 loadingLogin = true;
33
34 try {
35 await login(value as ActorIdentifier);
36 } catch (err) {
37 error = err instanceof Error ? err.message : String(err);
38 } finally {
39 loadingLogin = false;
40 }
41 }
42
43 let input: HTMLInputElement | null = $state(null);
44 let submitButton: HTMLButtonElement | null = $state(null);
45
46 $effect(() => {
47 if (!loginModalState.visible) {
48 error = null;
49 value = '';
50 loadingLogin = false;
51 selectedActor = undefined;
52 } else {
53 focusInput();
54 }
55 });
56
57 function focusInput() {
58 tick().then(() => {
59 input?.focus();
60 });
61 }
62 function focusSubmit() {
63 tick().then(() => {
64 submitButton?.focus();
65 });
66 }
67
68 let selectedActor: AppBskyActorDefs.ProfileViewBasic | undefined = $state();
69
70 let recentLogins: Record<Did, AppBskyActorDefs.ProfileViewBasic> = $state({});
71
72 onMount(() => {
73 try {
74 recentLogins = JSON.parse(localStorage.getItem('recent-logins') || '{}');
75 } catch {
76 console.error('Failed to load recent logins');
77 }
78 });
79
80 function removeRecentLogin(did: Did) {
81 try {
82 delete recentLogins[did];
83
84 localStorage.setItem('recent-logins', JSON.stringify(recentLogins));
85 } catch {
86 console.error('Failed to remove recent login');
87 }
88 }
89
90 let recentLoginsView = $state(true);
91
92 let showRecentLogins = $derived(
93 Object.keys(recentLogins).length > 0 && !loadingLogin && !selectedActor && recentLoginsView
94 );
95</script>
96
97{#if loginModalState.visible}
98 <div
99 class="fixed inset-0 z-100 w-screen overflow-y-auto"
100 aria-labelledby="modal-title"
101 role="dialog"
102 aria-modal="true"
103 >
104 <div
105 class="bg-base-50/90 dark:bg-base-950/90 fixed inset-0 backdrop-blur-sm transition-opacity"
106 onclick={() => (loginModalState.visible = false)}
107 aria-hidden="true"
108 ></div>
109
110 <div class="pointer-events-none fixed inset-0 isolate z-10 w-screen overflow-y-auto">
111 <div
112 class="flex min-h-full w-screen items-end justify-center p-4 text-center sm:items-center sm:p-0"
113 >
114 <div
115 class="border-base-200 bg-base-100 dark:border-base-700 dark:bg-base-800 pointer-events-auto relative w-full transform overflow-hidden rounded-2xl border px-4 pt-4 pb-4 text-left shadow-xl transition-all sm:my-8 sm:max-w-sm sm:p-6"
116 >
117 <h3 class="text-base-900 dark:text-base-100 font-semibold" id="modal-title">
118 Login with your internet handle
119 </h3>
120
121 <div class="text-base-800 dark:text-base-200 mt-2 mb-2 text-xs font-light">
122 e.g. your bluesky account
123 </div>
124
125 <form onsubmit={onSubmit} class="mt-2 flex w-full flex-col gap-2">
126 {#if showRecentLogins}
127 <div class="mt-2 mb-2 text-sm font-medium">Recent logins</div>
128 <div class="flex flex-col gap-2">
129 {#each Object.values(recentLogins)
130 .filter((l) => l.handle && l.handle !== 'handle.invalid')
131 .slice(0, 4) as recentLogin (recentLogin.did)}
132 <div class="group">
133 <div
134 class="group-hover:bg-base-300 bg-base-200 dark:bg-base-700 dark:hover:bg-base-600 dark:border-base-500/50 border-base-300 relative flex h-10 w-full items-center justify-between gap-2 rounded-full border px-2 font-semibold transition-colors duration-100"
135 >
136 <div class="flex items-center gap-2">
137 <Avatar class="size-6" src={recentLogin.avatar} />
138 {recentLogin.handle}
139 </div>
140 <button
141 class="z-20 cursor-pointer"
142 onclick={() => {
143 value = recentLogin.handle;
144 selectedActor = recentLogin;
145 if (loginOnSelect) onSubmit();
146 else focusSubmit();
147 }}
148 >
149 <div class="absolute inset-0 h-full w-full"></div>
150 <span class="sr-only">login</span>
151 </button>
152
153 <button
154 onclick={() => {
155 removeRecentLogin(recentLogin.did);
156 }}
157 class="z-30 cursor-pointer rounded-full p-0.5"
158 >
159 <svg
160 xmlns="http://www.w3.org/2000/svg"
161 fill="none"
162 viewBox="0 0 24 24"
163 stroke-width="1.5"
164 stroke="currentColor"
165 class="size-3"
166 >
167 <path
168 stroke-linecap="round"
169 stroke-linejoin="round"
170 d="M6 18 18 6M6 6l12 12"
171 />
172 </svg>
173 <span class="sr-only">sign in with other account</span>
174 </button>
175 </div>
176 </div>
177 {/each}
178 </div>
179 {:else if !selectedActor}
180 <div class="mt-4 w-full">
181 <HandleInput
182 bind:value
183 onselected={(a) => {
184 selectedActor = a;
185 value = a.handle;
186 if (loginOnSelect) onSubmit();
187 else focusSubmit();
188 }}
189 bind:ref={input}
190 />
191 </div>
192 {:else}
193 <div
194 class="bg-base-200 dark:bg-base-700 border-base-300 dark:border-base-600 mt-4 flex h-10 w-full items-center justify-between gap-2 rounded-full border px-2 font-semibold"
195 >
196 <div class="flex items-center gap-2">
197 <Avatar class="size-6" src={selectedActor.avatar} />
198 {selectedActor.handle}
199 </div>
200
201 <button
202 onclick={() => {
203 selectedActor = undefined;
204 value = '';
205 }}
206 class="cursor-pointer rounded-full p-0.5"
207 >
208 <svg
209 xmlns="http://www.w3.org/2000/svg"
210 fill="none"
211 viewBox="0 0 24 24"
212 stroke-width="1.5"
213 stroke="currentColor"
214 class="size-3"
215 >
216 <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
217 </svg>
218 <span class="sr-only">sign in with other account</span>
219 </button>
220 </div>
221 {/if}
222
223 {#if error}
224 <p class="text-accent-500 text-sm font-semibold">{error}</p>
225 {/if}
226
227 <div class="mt-4">
228 {#if showRecentLogins}
229 <div class="mt-2 mb-4 text-sm font-medium">Or login with new handle</div>
230
231 <Button
232 onclick={() => {
233 recentLoginsView = false;
234 focusInput();
235 }}
236 class="w-full">Login with new handle</Button
237 >
238 {:else}
239 <Button bind:ref={submitButton} type="submit" disabled={loadingLogin} class="w-full"
240 >{loadingLogin ? 'Loading...' : 'Login'}</Button
241 >
242 {/if}
243 </div>
244
245 {#if signUp}
246 <div
247 class="border-base-200 dark:border-base-700 text-base-800 dark:text-base-200 mt-4 border-t pt-4 text-sm leading-7"
248 >
249 Don't have an account?
250 <div class="mt-3">
251 <SecondaryButton
252 onclick={async () => {
253 loadingSignup = true;
254 await signup();
255 }}
256 disabled={loadingSignup}
257 class="w-full">{loadingSignup ? 'Loading...' : 'Sign Up'}</SecondaryButton
258 >
259 </div>
260 </div>
261 {/if}
262 </form>
263 </div>
264 </div>
265 </div>
266 </div>
267{/if}