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 Popup from './Popup.svelte'; 8 import { flow } from '$lib/at/oauth'; 9 import { isHandle, type AtprotoDid } from '@atcute/lexicons/syntax'; 10 import Icon from '@iconify/svelte'; 11 12 interface Props { 13 client: AtpClient; 14 accounts: Array<Account>; 15 selectedDid?: AtprotoDid | null; 16 onAccountSelected: (did: AtprotoDid) => void; 17 onLogout: (did: AtprotoDid) => void; 18 } 19 20 let { 21 client, 22 accounts = [], 23 selectedDid = $bindable(null), 24 onAccountSelected, 25 onLogout 26 }: Props = $props(); 27 28 let isDropdownOpen = $state(false); 29 let isLoginModalOpen = $state(false); 30 let loginHandle = $state(''); 31 let loginError = $state(''); 32 let isLoggingIn = $state(false); 33 34 const toggleDropdown = (e: MouseEvent) => { 35 e.stopPropagation(); 36 isDropdownOpen = !isDropdownOpen; 37 }; 38 39 const selectAccount = (did: AtprotoDid) => { 40 onAccountSelected(did); 41 isDropdownOpen = false; 42 }; 43 44 const openLoginModal = () => { 45 isLoginModalOpen = true; 46 isDropdownOpen = false; 47 loginHandle = ''; 48 loginError = ''; 49 // HACK: i hate this but it works so it doesnt really matter 50 setTimeout(() => document.getElementById('handle')?.focus(), 100); 51 }; 52 53 const closeLoginModal = () => { 54 document.getElementById('handle')?.blur(); 55 isLoginModalOpen = false; 56 loginHandle = ''; 57 loginError = ''; 58 }; 59 60 const handleLogin = async () => { 61 try { 62 if (!loginHandle) throw 'please enter handle'; 63 64 isLoggingIn = true; 65 loginError = ''; 66 67 let handle: Handle; 68 if (isHandle(loginHandle)) handle = loginHandle; 69 else throw 'handle is invalid'; 70 71 let did = await client.resolveHandle(handle); 72 if (!did.ok) throw did.error; 73 74 await initiateLogin(did.value, handle); 75 } catch (error) { 76 loginError = `login failed: ${error}`; 77 loggingIn.set(null); 78 } finally { 79 isLoggingIn = false; 80 } 81 }; 82 83 const initiateLogin = async (did: AtprotoDid, handle: Handle | null) => { 84 loggingIn.set({ did, handle }); 85 const result = await flow.start(handle ?? did); 86 if (!result.ok) throw result.error; 87 }; 88 89 const handleKeydown = (event: KeyboardEvent) => { 90 if (event.key === 'Enter' && !isLoggingIn) handleLogin(); 91 }; 92 93 const closeDropdown = () => { 94 isDropdownOpen = false; 95 }; 96</script> 97 98<svelte:window onclick={closeDropdown} /> 99 100<div class="relative"> 101 <button 102 onclick={toggleDropdown} 103 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" 104 > 105 {#if selectedDid} 106 <ProfilePicture {client} did={selectedDid} size={13} /> 107 {:else} 108 <PfpPlaceholder color="var(--nucleus-accent)" size={13} /> 109 {/if} 110 </button> 111 112 {#if isDropdownOpen} 113 <!-- svelte-ignore a11y_click_events_have_key_events --> 114 <!-- svelte-ignore a11y_no_static_element_interactions --> 115 <div 116 class="absolute bottom-full z-20 mb-1 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" 117 onclick={(e) => e.stopPropagation()} 118 > 119 {#if accounts.length > 0} 120 <div class="p-2"> 121 {#each accounts as account (account.did)} 122 {@const color = generateColorForDid(account.did)} 123 {#snippet action(name: string, icon: string, onClick: () => void)} 124 <div 125 title={name} 126 onclick={onClick} 127 class="hidden text-(--nucleus-accent) transition-all group-hover:block hover:scale-[1.2] hover:shadow-md" 128 > 129 <Icon class="h-5 w-5" {icon} /> 130 </div> 131 {/snippet} 132 <button 133 onclick={() => selectAccount(account.did)} 134 class=" 135 group flex w-full items-center gap-3 rounded-sm p-2 text-left text-sm font-medium transition-all 136 {account.did === selectedDid ? 'shadow-lg' : ''} 137 " 138 style="color: {color}; background: {account.did === selectedDid 139 ? `linear-gradient(135deg, color-mix(in srgb, var(--nucleus-accent) 20%, transparent), color-mix(in srgb, var(--nucleus-accent2) 20%, transparent))` 140 : 'transparent'};" 141 > 142 <span>@{account.handle}</span> 143 144 <div class="grow"></div> 145 146 {@render action('relogin', 'heroicons:arrow-path-rounded-square-solid', () => 147 initiateLogin(account.did, account.handle) 148 )} 149 {@render action('logout', 'heroicons:trash-solid', () => onLogout(account.did))} 150 151 {#if account.did === selectedDid} 152 <Icon 153 icon="heroicons:check-16-solid" 154 class="h-5 w-5 scale-125 text-(--nucleus-accent) group-hover:hidden" 155 /> 156 {/if} 157 </button> 158 {/each} 159 </div> 160 <div class="mx-2 h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div> 161 {/if} 162 <button 163 onclick={openLoginModal} 164 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]" 165 > 166 <Icon class="h-5 w-5 scale-[130%]" icon="heroicons:plus-16-solid" /> 167 <span>add account</span> 168 </button> 169 </div> 170 {/if} 171</div> 172 173<Popup bind:isOpen={isLoginModalOpen} onClose={closeLoginModal} title="add account"> 174 <!-- svelte-ignore a11y_no_static_element_interactions --> 175 <div class="space-y-2" onkeydown={handleKeydown}> 176 <div> 177 <label for="handle" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> 178 account handle 179 </label> 180 <input 181 id="handle" 182 type="text" 183 bind:value={loginHandle} 184 placeholder="example.bsky.social" 185 class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3" 186 disabled={isLoggingIn} 187 /> 188 </div> 189 190 {#if loginError} 191 <div class="error-disclaimer"> 192 <p> 193 <Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" /> 194 {loginError} 195 </p> 196 </div> 197 {/if} 198 199 <div class="flex gap-3 pt-3"> 200 <button onclick={closeLoginModal} class="flex-1 action-button" disabled={isLoggingIn}> 201 cancel 202 </button> 203 <button 204 onclick={handleLogin} 205 class="flex-1 action-button border-transparent text-(--nucleus-fg)" 206 style="background: linear-gradient(135deg, var(--nucleus-accent), var(--nucleus-accent2));" 207 disabled={isLoggingIn} 208 > 209 {isLoggingIn ? 'logging in...' : 'login'} 210 </button> 211 </div> 212 </div> 213</Popup>