replies timeline only, appview-less bluesky client
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 { flow } from '$lib/at/oauth'; 8 import { isHandle, type AtprotoDid } from '@atcute/lexicons/syntax'; 9 import Icon from '@iconify/svelte'; 10 11 interface Props { 12 client: AtpClient; 13 accounts: Array<Account>; 14 selectedDid?: AtprotoDid | null; 15 onAccountSelected: (did: AtprotoDid) => void; 16 onLogout: (did: AtprotoDid) => void; 17 } 18 19 let { 20 client, 21 accounts = [], 22 selectedDid = $bindable(null), 23 onAccountSelected, 24 onLogout 25 }: Props = $props(); 26 27 let isDropdownOpen = $state(false); 28 let isLoginModalOpen = $state(false); 29 let loginHandle = $state(''); 30 let loginError = $state(''); 31 let isLoggingIn = $state(false); 32 33 const toggleDropdown = (e: MouseEvent) => { 34 e.stopPropagation(); 35 isDropdownOpen = !isDropdownOpen; 36 }; 37 38 const selectAccount = (did: AtprotoDid) => { 39 onAccountSelected(did); 40 isDropdownOpen = false; 41 }; 42 43 const openLoginModal = () => { 44 isLoginModalOpen = true; 45 isDropdownOpen = false; 46 loginHandle = ''; 47 loginError = ''; 48 }; 49 50 const closeLoginModal = () => { 51 isLoginModalOpen = false; 52 loginHandle = ''; 53 loginError = ''; 54 }; 55 56 const handleLogin = async () => { 57 try { 58 if (!loginHandle) throw 'please enter handle'; 59 60 isLoggingIn = true; 61 loginError = ''; 62 63 let handle: Handle; 64 if (isHandle(loginHandle)) handle = loginHandle; 65 else throw 'handle is invalid'; 66 67 let did = await client.resolveHandle(handle); 68 if (!did.ok) throw did.error; 69 70 loggingIn.set({ did: did.value, handle }); 71 const result = await flow.start(handle); 72 if (!result.ok) throw result.error; 73 } catch (error) { 74 loginError = `login failed: ${error}`; 75 loggingIn.set(null); 76 } finally { 77 isLoggingIn = false; 78 } 79 }; 80 81 const handleKeydown = (event: KeyboardEvent) => { 82 if (event.key === 'Escape') { 83 closeLoginModal(); 84 } else if (event.key === 'Enter' && !isLoggingIn) { 85 handleLogin(); 86 } 87 }; 88 89 const closeDropdown = () => { 90 isDropdownOpen = false; 91 }; 92</script> 93 94<svelte:window onclick={closeDropdown} /> 95 96<div class="relative"> 97 <button 98 onclick={toggleDropdown} 99 class="flex h-16 w-16 items-center justify-center rounded-sm shadow-lg transition-all hover:scale-105 hover:shadow-xl" 100 > 101 {#if selectedDid} 102 <ProfilePicture {client} did={selectedDid} size={15} /> 103 {:else} 104 <PfpPlaceholder color="var(--nucleus-accent)" size={15} /> 105 {/if} 106 </button> 107 108 {#if isDropdownOpen} 109 <!-- svelte-ignore a11y_click_events_have_key_events --> 110 <!-- svelte-ignore a11y_no_static_element_interactions --> 111 <div 112 class="absolute left-0 z-10 mt-3 min-w-52 animate-fade-in-scale-fast overflow-hidden rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg)/94 shadow-2xl backdrop-blur-lg transition-all" 113 onclick={(e) => e.stopPropagation()} 114 > 115 {#if accounts.length > 0} 116 <div class="p-2"> 117 {#each accounts as account (account.did)} 118 {@const color = generateColorForDid(account.did)} 119 <button 120 onclick={() => selectAccount(account.did)} 121 class=" 122 group flex w-full items-center gap-3 rounded-sm p-2 text-left text-sm font-medium transition-all 123 {account.did === selectedDid ? 'shadow-lg' : ''} 124 " 125 style="color: {color}; background: {account.did === selectedDid 126 ? `linear-gradient(135deg, color-mix(in srgb, var(--nucleus-accent) 20%, transparent), color-mix(in srgb, var(--nucleus-accent2) 20%, transparent))` 127 : 'transparent'};" 128 > 129 <span>@{account.handle}</span> 130 <svg 131 xmlns="http://www.w3.org/2000/svg" 132 onclick={() => onLogout(account.did)} 133 class="ml-auto hidden h-5 w-5 text-(--nucleus-accent) transition-all group-hover:block hover:scale-[1.2] hover:shadow-md" 134 width="24" 135 height="24" 136 viewBox="0 0 20 20" 137 ><path 138 fill="currentColor" 139 fill-rule="evenodd" 140 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" 141 clip-rule="evenodd" 142 /></svg 143 > 144 145 {#if account.did === selectedDid} 146 <svg 147 xmlns="http://www.w3.org/2000/svg" 148 class="ml-auto h-5 w-5 text-(--nucleus-accent) group-hover:hidden" 149 width="24" 150 height="24" 151 viewBox="0 0 24 24" 152 ><path 153 fill="currentColor" 154 fill-rule="evenodd" 155 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" 156 clip-rule="evenodd" 157 stroke-width="1.5" 158 stroke="currentColor" 159 /></svg 160 > 161 {/if} 162 </button> 163 {/each} 164 </div> 165 <div class="mx-2 h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div> 166 {/if} 167 <button 168 onclick={openLoginModal} 169 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]" 170 > 171 <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 172 <path 173 stroke-linecap="round" 174 stroke-linejoin="round" 175 stroke-width="2.5" 176 d="M12 4v16m8-8H4" 177 /> 178 </svg> 179 <span>add account</span> 180 </button> 181 </div> 182 {/if} 183</div> 184 185{#if isLoginModalOpen} 186 <div 187 class="fixed inset-0 z-50 flex items-center justify-center bg-(--nucleus-bg)/80 backdrop-blur-sm" 188 onclick={closeLoginModal} 189 onkeydown={handleKeydown} 190 role="button" 191 tabindex="-1" 192 > 193 <!-- svelte-ignore a11y_interactive_supports_focus --> 194 <!-- svelte-ignore a11y_click_events_have_key_events --> 195 <div 196 class="w-full max-w-md animate-fade-in-scale rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) p-4 shadow-2xl transition-all" 197 onclick={(e) => e.stopPropagation()} 198 role="dialog" 199 > 200 <div class="mb-6 flex items-center justify-between"> 201 <div> 202 <h2 class="text-2xl font-bold">add account</h2> 203 <div class="mt-2 flex gap-2"> 204 <div class="h-1 w-10 rounded-full bg-(--nucleus-accent)"></div> 205 <div class="h-1 w-9 rounded-full bg-(--nucleus-accent2)"></div> 206 </div> 207 </div> 208 <!-- svelte-ignore a11y_consider_explicit_label --> 209 <button 210 onclick={closeLoginModal} 211 class="rounded-xl p-2 text-(--nucleus-fg)/40 transition-all hover:scale-110 hover:text-(--nucleus-fg)" 212 > 213 <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 214 <path 215 stroke-linecap="round" 216 stroke-linejoin="round" 217 stroke-width="2.5" 218 d="M6 18L18 6M6 6l12 12" 219 /> 220 </svg> 221 </button> 222 </div> 223 224 <div class="space-y-5"> 225 <div> 226 <label for="handle" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> 227 handle 228 </label> 229 <input 230 id="handle" 231 type="text" 232 bind:value={loginHandle} 233 placeholder="example.bsky.social" 234 class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3" 235 disabled={isLoggingIn} 236 /> 237 </div> 238 239 {#if loginError} 240 <div class="error-disclaimer"> 241 <p> 242 <Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" /> 243 {loginError} 244 </p> 245 </div> 246 {/if} 247 248 <div class="flex gap-3 pt-3"> 249 <button onclick={closeLoginModal} class="flex-1 action-button" disabled={isLoggingIn}> 250 cancel 251 </button> 252 <button 253 onclick={handleLogin} 254 class="flex-1 action-button border-transparent text-(--nucleus-fg)" 255 style="background: linear-gradient(135deg, var(--nucleus-accent), var(--nucleus-accent2));" 256 disabled={isLoggingIn} 257 > 258 {isLoggingIn ? 'logging in...' : 'login'} 259 </button> 260 </div> 261 </div> 262 </div> 263 </div> 264{/if}