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