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).slice(0, 4) as recentLogin (recentLogin.did)}
130 <div class="group">
131 <div
132 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"
133 >
134 <div class="flex items-center gap-2">
135 <Avatar class="size-6" src={recentLogin.avatar} />
136 {recentLogin.handle}
137 </div>
138 <button
139 class="z-20 cursor-pointer"
140 onclick={() => {
141 value = recentLogin.handle;
142 selectedActor = recentLogin;
143 if (loginOnSelect) onSubmit();
144 else focusSubmit();
145 }}
146 >
147 <div class="absolute inset-0 h-full w-full"></div>
148 <span class="sr-only">login</span>
149 </button>
150
151 <button
152 onclick={() => {
153 removeRecentLogin(recentLogin.did);
154 }}
155 class="z-30 cursor-pointer rounded-full p-0.5"
156 >
157 <svg
158 xmlns="http://www.w3.org/2000/svg"
159 fill="none"
160 viewBox="0 0 24 24"
161 stroke-width="1.5"
162 stroke="currentColor"
163 class="size-3"
164 >
165 <path
166 stroke-linecap="round"
167 stroke-linejoin="round"
168 d="M6 18 18 6M6 6l12 12"
169 />
170 </svg>
171 <span class="sr-only">sign in with other account</span>
172 </button>
173 </div>
174 </div>
175 {/each}
176 </div>
177 {:else if !selectedActor}
178 <div class="mt-4 w-full">
179 <HandleInput
180 bind:value
181 onselected={(a) => {
182 selectedActor = a;
183 value = a.handle;
184 if (loginOnSelect) onSubmit();
185 else focusSubmit();
186 }}
187 bind:ref={input}
188 />
189 </div>
190 {:else}
191 <div
192 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"
193 >
194 <div class="flex items-center gap-2">
195 <Avatar class="size-6" src={selectedActor.avatar} />
196 {selectedActor.handle}
197 </div>
198
199 <button
200 onclick={() => {
201 selectedActor = undefined;
202 value = '';
203 }}
204 class="cursor-pointer rounded-full p-0.5"
205 >
206 <svg
207 xmlns="http://www.w3.org/2000/svg"
208 fill="none"
209 viewBox="0 0 24 24"
210 stroke-width="1.5"
211 stroke="currentColor"
212 class="size-3"
213 >
214 <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
215 </svg>
216 <span class="sr-only">sign in with other account</span>
217 </button>
218 </div>
219 {/if}
220
221 {#if error}
222 <p class="text-accent-500 text-sm font-semibold">{error}</p>
223 {/if}
224
225 <div class="mt-4">
226 {#if showRecentLogins}
227 <div class="mt-2 mb-4 text-sm font-medium">Or login with new handle</div>
228
229 <Button
230 onclick={() => {
231 recentLoginsView = false;
232 focusInput();
233 }}
234 class="w-full">Login with new handle</Button
235 >
236 {:else}
237 <Button bind:ref={submitButton} type="submit" disabled={loadingLogin} class="w-full"
238 >{loadingLogin ? 'Loading...' : 'Login'}</Button
239 >
240 {/if}
241 </div>
242
243 {#if signUp}
244 <div
245 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"
246 >
247 Don't have an account?
248 <div class="mt-3">
249 <SecondaryButton
250 onclick={async () => {
251 loadingSignup = true;
252 await signup();
253 }}
254 disabled={loadingSignup}
255 class="w-full">{loadingSignup ? 'Loading...' : 'Sign Up'}</SecondaryButton
256 >
257 </div>
258 </div>
259 {/if}
260 </form>
261 </div>
262 </div>
263 </div>
264 </div>
265{/if}