replies timeline only, appview-less bluesky client
1<script lang="ts"> 2 import { generateColorForDid, type Account } from '$lib/accounts'; 3 import { AtpClient } from '$lib/at/client'; 4 import type { Did, Handle } from '@atcute/lexicons'; 5 import { theme } from '$lib/theme.svelte'; 6 7 interface Props { 8 accounts: Array<Account>; 9 selectedDid?: Did | null; 10 onAccountSelected: (did: Did) => void; 11 onLoginSucceed: (did: Did, handle: Handle, password: string) => void; 12 onLogout: (did: Did) => void; 13 } 14 15 let { 16 accounts = [], 17 selectedDid = $bindable(null), 18 onAccountSelected, 19 onLoginSucceed, 20 onLogout 21 }: Props = $props(); 22 23 let color = $derived(selectedDid ? (generateColorForDid(selectedDid) ?? theme.fg) : theme.fg); 24 25 let isDropdownOpen = $state(false); 26 let isLoginModalOpen = $state(false); 27 let loginHandle = $state(''); 28 let loginPassword = $state(''); 29 let loginError = $state(''); 30 let isLoggingIn = $state(false); 31 32 const toggleDropdown = (e: MouseEvent) => { 33 e.stopPropagation(); 34 isDropdownOpen = !isDropdownOpen; 35 }; 36 37 const selectAccount = (did: Did) => { 38 onAccountSelected(did); 39 isDropdownOpen = false; 40 }; 41 42 const openLoginModal = () => { 43 isLoginModalOpen = true; 44 isDropdownOpen = false; 45 loginHandle = ''; 46 loginPassword = ''; 47 loginError = ''; 48 }; 49 50 const closeLoginModal = () => { 51 isLoginModalOpen = false; 52 loginHandle = ''; 53 loginPassword = ''; 54 loginError = ''; 55 }; 56 57 const handleLogin = async () => { 58 if (!loginHandle || !loginPassword) { 59 loginError = 'please enter both handle and password'; 60 return; 61 } 62 63 isLoggingIn = true; 64 loginError = ''; 65 66 try { 67 const client = new AtpClient(); 68 const result = await client.login(loginHandle as Handle, loginPassword); 69 70 if (!result.ok) { 71 loginError = result.error; 72 isLoggingIn = false; 73 return; 74 } 75 76 if (!client.didDoc) { 77 loginError = 'failed to get did document'; 78 isLoggingIn = false; 79 return; 80 } 81 82 onLoginSucceed(client.didDoc.did, loginHandle as Handle, loginPassword); 83 closeLoginModal(); 84 } catch (error) { 85 loginError = `login failed: ${error}`; 86 } finally { 87 isLoggingIn = false; 88 } 89 }; 90 91 const handleKeydown = (event: KeyboardEvent) => { 92 if (event.key === 'Escape') { 93 closeLoginModal(); 94 } else if (event.key === 'Enter' && !isLoggingIn) { 95 handleLogin(); 96 } 97 }; 98 99 const closeDropdown = () => { 100 isDropdownOpen = false; 101 }; 102 103 let selectedAccount = $derived(accounts.find((acc) => acc.did === selectedDid)); 104</script> 105 106<svelte:window onclick={closeDropdown} /> 107 108<div class="relative"> 109 <button 110 onclick={toggleDropdown} 111 class="group flex h-full items-center gap-2 rounded-sm border-2 px-2 font-medium shadow-lg transition-all hover:scale-105 hover:shadow-xl" 112 style="border-color: {theme.accent}66; background: {theme.accent}18; color: {color}; backdrop-filter: blur(8px);" 113 > 114 <span class="font-bold"> 115 {selectedAccount ? `@${selectedAccount.handle}` : 'select account'} 116 </span> 117 <svg 118 class="h-4 w-4 transition-transform {isDropdownOpen ? 'rotate-180' : ''}" 119 style="color: {theme.accent};" 120 fill="none" 121 stroke="currentColor" 122 viewBox="0 0 24 24" 123 > 124 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" /> 125 </svg> 126 </button> 127 128 {#if isDropdownOpen} 129 <!-- svelte-ignore a11y_click_events_have_key_events --> 130 <!-- svelte-ignore a11y_no_static_element_interactions --> 131 <div 132 class="absolute left-0 z-10 mt-3 min-w-52 overflow-hidden rounded-sm border-2 shadow-2xl backdrop-blur-lg" 133 style="border-color: {theme.accent}; background: {theme.bg}f0;" 134 onclick={(e) => e.stopPropagation()} 135 > 136 {#if accounts.length > 0} 137 <div class="p-2"> 138 {#each accounts as account (account.did)} 139 {@const color = generateColorForDid(account.did)} 140 <button 141 onclick={() => selectAccount(account.did)} 142 class=" 143 group flex w-full items-center gap-3 rounded-sm p-2 text-left text-sm font-medium transition-all 144 {account.did === selectedDid ? 'shadow-lg' : ''} 145 " 146 style="color: {color}; background: {account.did === selectedDid 147 ? `linear-gradient(135deg, ${theme.accent}33, ${theme.accent2}33)` 148 : 'transparent'};" 149 > 150 <span>@{account.handle}</span> 151 <svg 152 xmlns="http://www.w3.org/2000/svg" 153 onclick={() => onLogout(account.did)} 154 class="ml-auto hidden h-5 w-5 transition-all group-hover:[display:block] hover:scale-[1.2] hover:shadow-md" 155 style="color: {theme.accent};" 156 width="24" 157 height="24" 158 viewBox="0 0 20 20" 159 ><path 160 fill="currentColor" 161 fill-rule="evenodd" 162 d="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443q-1.193.115-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022l.841 10.518A2.75 2.75 0 0 0 7.596 19h4.807a2.75 2.75 0 0 0 2.742-2.53l.841-10.52l.149.023a.75.75 0 0 0 .23-1.482A41 41 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1zM10 4q1.26 0 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325Q8.74 4 10 4M8.58 7.72a.75.75 0 0 0-1.5.06l.3 7.5a.75.75 0 1 0 1.5-.06zm4.34.06a.75.75 0 1 0-1.5-.06l-.3 7.5a.75.75 0 1 0 1.5.06z" 163 clip-rule="evenodd" 164 /></svg 165 > 166 167 {#if account.did === selectedDid} 168 <svg 169 xmlns="http://www.w3.org/2000/svg" 170 class="ml-auto h-5 w-5 group-hover:hidden" 171 style="color: {theme.accent};" 172 width="24" 173 height="24" 174 viewBox="0 0 24 24" 175 ><path 176 fill="currentColor" 177 fill-rule="evenodd" 178 d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353l8.493-12.74a.75.75 0 0 1 1.04-.207" 179 clip-rule="evenodd" 180 stroke-width="1.5" 181 stroke="currentColor" 182 /></svg 183 > 184 {/if} 185 </button> 186 {/each} 187 </div> 188 <div 189 class="mx-2 h-px" 190 style="background: linear-gradient(to right, {theme.accent}, {theme.accent2});" 191 ></div> 192 {/if} 193 <button 194 onclick={openLoginModal} 195 class="group flex w-full origin-left items-center gap-3 p-3 text-left text-sm font-semibold transition-all hover:scale-[1.1]" 196 style="color: {theme.accent};" 197 > 198 <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 199 <path 200 stroke-linecap="round" 201 stroke-linejoin="round" 202 stroke-width="2.5" 203 d="M12 4v16m8-8H4" 204 /> 205 </svg> 206 <span>add account</span> 207 </button> 208 </div> 209 {/if} 210</div> 211 212{#if isLoginModalOpen} 213 <div 214 class="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm" 215 style="background: {theme.bg}cc;" 216 onclick={closeLoginModal} 217 onkeydown={handleKeydown} 218 role="button" 219 tabindex="-1" 220 > 221 <!-- svelte-ignore a11y_interactive_supports_focus --> 222 <!-- svelte-ignore a11y_click_events_have_key_events --> 223 <div 224 class="w-full max-w-md rounded-sm border-2 p-5 shadow-2xl" 225 style="background: {theme.bg}; border-color: {theme.accent};" 226 onclick={(e) => e.stopPropagation()} 227 role="dialog" 228 > 229 <div class="mb-6 flex items-center justify-between"> 230 <div> 231 <h2 class="text-2xl font-bold" style="color: {theme.fg};">add account</h2> 232 <div class="mt-2 flex gap-2"> 233 <div class="h-1 w-10 rounded-full" style="background: {theme.accent};"></div> 234 <div class="h-1 w-9 rounded-full" style="background: {theme.accent2};"></div> 235 </div> 236 </div> 237 <!-- svelte-ignore a11y_consider_explicit_label --> 238 <button 239 onclick={closeLoginModal} 240 class="rounded-xl p-2 transition-all hover:scale-110" 241 style="color: {theme.fg}66; hover:color: {theme.fg};" 242 > 243 <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 244 <path 245 stroke-linecap="round" 246 stroke-linejoin="round" 247 stroke-width="2.5" 248 d="M6 18L18 6M6 6l12 12" 249 /> 250 </svg> 251 </button> 252 </div> 253 254 <div class="space-y-5"> 255 <div> 256 <label for="handle" class="mb-2 block text-sm font-semibold" style="color: {theme.fg}cc;"> 257 handle 258 </label> 259 <input 260 id="handle" 261 type="text" 262 bind:value={loginHandle} 263 placeholder="example.bsky.social" 264 class="placeholder-opacity-40 w-full rounded-sm border-2 px-4 py-3 font-medium transition-all focus:scale-[1.02] focus:shadow-lg focus:outline-none" 265 style="background: {theme.accent}08; border-color: {theme.accent}66; color: {theme.fg};" 266 disabled={isLoggingIn} 267 /> 268 </div> 269 270 <div> 271 <label 272 for="password" 273 class="mb-2 block text-sm font-semibold" 274 style="color: {theme.fg}cc;" 275 > 276 app password 277 </label> 278 <input 279 id="password" 280 type="password" 281 bind:value={loginPassword} 282 placeholder="xxxx-xxxx-xxxx-xxxx" 283 class="placeholder-opacity-40 w-full rounded-sm border-2 px-4 py-3 font-medium transition-all focus:scale-[1.02] focus:shadow-lg focus:outline-none" 284 style="background: {theme.accent}08; border-color: {theme.accent}66; color: {theme.fg};" 285 disabled={isLoggingIn} 286 /> 287 </div> 288 289 {#if loginError} 290 <div 291 class="rounded-sm border-2 p-4" 292 style="background: #ef444422; border-color: #ef4444;" 293 > 294 <p class="text-sm font-medium" style="color: #fca5a5;">{loginError}</p> 295 </div> 296 {/if} 297 298 <div class="flex gap-3 pt-3"> 299 <button 300 onclick={closeLoginModal} 301 class="flex-1 rounded-sm border-2 px-5 py-3 font-semibold transition-all hover:scale-105" 302 style="background: {theme.bg}; border-color: {theme.fg}33; color: {theme.fg};" 303 disabled={isLoggingIn} 304 > 305 cancel 306 </button> 307 <button 308 onclick={handleLogin} 309 class="flex-1 rounded-sm border-2 px-5 py-3 font-semibold transition-all hover:scale-105 hover:shadow-xl disabled:cursor-not-allowed disabled:opacity-50" 310 style="background: linear-gradient(135deg, {theme.accent}, {theme.accent2}); border-color: transparent; color: {theme.fg};" 311 disabled={isLoggingIn} 312 > 313 {isLoggingIn ? 'logging in...' : 'login'} 314 </button> 315 </div> 316 </div> 317 </div> 318 </div> 319{/if}