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 loggingIn.set({ did: did.value, handle }); 75 const result = await flow.start(handle); 76 if (!result.ok) throw result.error; 77 } catch (error) { 78 loginError = `login failed: ${error}`; 79 loggingIn.set(null); 80 } finally { 81 isLoggingIn = false; 82 } 83 }; 84 85 const handleKeydown = (event: KeyboardEvent) => { 86 if (event.key === 'Enter' && !isLoggingIn) handleLogin(); 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<Popup bind:isOpen={isLoginModalOpen} onClose={closeLoginModal} title="add account"> 186 <!-- svelte-ignore a11y_no_static_element_interactions --> 187 <div class="space-y-2" onkeydown={handleKeydown}> 188 <div> 189 <label for="handle" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80"> 190 account handle 191 </label> 192 <input 193 id="handle" 194 type="text" 195 bind:value={loginHandle} 196 placeholder="example.bsky.social" 197 class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3" 198 disabled={isLoggingIn} 199 /> 200 </div> 201 202 {#if loginError} 203 <div class="error-disclaimer"> 204 <p> 205 <Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" /> 206 {loginError} 207 </p> 208 </div> 209 {/if} 210 211 <div class="flex gap-3 pt-3"> 212 <button onclick={closeLoginModal} class="flex-1 action-button" disabled={isLoggingIn}> 213 cancel 214 </button> 215 <button 216 onclick={handleLogin} 217 class="flex-1 action-button border-transparent text-(--nucleus-fg)" 218 style="background: linear-gradient(135deg, var(--nucleus-accent), var(--nucleus-accent2));" 219 disabled={isLoggingIn} 220 > 221 {isLoggingIn ? 'logging in...' : 'login'} 222 </button> 223 </div> 224 </div> 225</Popup>