Attic is a cozy space with lofty ambitions. attic.social
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

login combobox

+206 -6
+18
src/css/components/form.css
··· 12 12 inline-size: fit-content; 13 13 line-height: 1.5; 14 14 } 15 + 16 + & > * { 17 + grid-column: 1 / -1; 18 + } 19 + 20 + &[action*="login"] { 21 + grid-template-columns: 1fr auto; 22 + inline-size: min(100%, 400px); 23 + 24 + & input { 25 + inline-size: 100%; 26 + grid-column: 1 / 2; 27 + } 28 + 29 + & button { 30 + grid-column: 2 / 3; 31 + } 32 + } 15 33 }
+39 -3
src/css/main.css
··· 14 14 } 15 15 16 16 .avatar { 17 + --size: 50px; 17 18 align-items: center; 18 19 display: grid; 19 20 column-gap: 10px; 20 - grid-template-columns: 50px auto; 21 + grid-template-columns: var(--size) auto; 21 22 22 23 & img { 23 24 border-radius: calc(1px * infinity); 24 - block-size: 50px; 25 - inline-size: 50px; 25 + block-size: var(--size); 26 + inline-size: var(--size); 26 27 grid-column: 1; 27 28 grid-row: 1 / 5; 28 29 } ··· 39 40 } 40 41 } 41 42 } 43 + 44 + #handle { 45 + anchor-name: --handle; 46 + } 47 + 48 + #handle-listbox { 49 + background: white; 50 + border: 2px solid black; 51 + display: grid; 52 + inline-size: anchor-size(--handle inline); 53 + max-block-size: 230px; 54 + overflow-x: hidden; 55 + overflow-y: auto; 56 + position: fixed; 57 + position-anchor: --handle; 58 + position-area: block-end center; 59 + position-visibility: no-overflow; 60 + 61 + & .avatar { 62 + --size: 40px; 63 + padding: 10px; 64 + } 65 + 66 + & [aria-selected="true"] { 67 + background: yellow; 68 + } 69 + 70 + & li { 71 + cursor: pointer; 72 + 73 + &:hover { 74 + background: cyan; 75 + } 76 + } 77 + }
+149 -3
src/routes/+page.svelte
··· 1 + <script lang="ts" module> 2 + import { parsePublicUser, type PublicUserData } from "$lib/valibot"; 3 + 4 + type BskyUserData = PublicUserData & { avatar: string }; 5 + </script> 6 + 1 7 <script lang="ts"> 2 8 import type { PageProps } from "./$types.d.ts"; 3 9 let { data, form }: PageProps = $props(); 4 10 11 + let handle = $derived(String(form?.handle ?? "")); 12 + let handleFocus = $state(false); 13 + 14 + let bskyUsers: BskyUserData[] = $state([]); 15 + let controller = new AbortController(); 16 + 17 + let activeOption = $state<HTMLElement | null>(null); 18 + let handleListbox = $state<HTMLElement | null>(null); 19 + 20 + const handleKeydown = (ev: KeyboardEvent) => { 21 + if (!bskyUsers.length || !handleListbox) { 22 + return; 23 + } 24 + switch (ev.key) { 25 + case "ArrowDown": 26 + ev.preventDefault(); 27 + if (activeOption) { 28 + activeOption = 29 + (activeOption.nextElementSibling as HTMLElement) || 30 + (handleListbox.firstElementChild as HTMLElement); 31 + } else { 32 + activeOption = handleListbox.firstElementChild as HTMLElement; 33 + } 34 + break; 35 + case "ArrowUp": 36 + ev.preventDefault(); 37 + if (activeOption) { 38 + activeOption = 39 + (activeOption.previousElementSibling as HTMLElement) || 40 + (handleListbox.lastElementChild as HTMLElement); 41 + } else { 42 + activeOption = handleListbox.lastElementChild as HTMLElement; 43 + } 44 + break; 45 + case "Home": 46 + ev.preventDefault(); 47 + activeOption = handleListbox.firstElementChild as HTMLElement; 48 + break; 49 + case "End": 50 + ev.preventDefault(); 51 + activeOption = handleListbox.lastElementChild as HTMLElement; 52 + break; 53 + case "Enter": 54 + if (activeOption) { 55 + ev.preventDefault(); 56 + handle = activeOption.dataset.handle!; 57 + } 58 + break; 59 + case "Escape": 60 + activeOption = null; 61 + break; 62 + } 63 + if (activeOption) { 64 + activeOption.scrollIntoView(); 65 + } 66 + }; 67 + 68 + const bskySearch = async (q: string) => { 69 + if (controller.signal.aborted === false) { 70 + controller.abort(); 71 + } 72 + controller = new AbortController(); 73 + const response = await fetch( 74 + `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?limit=10&q=${q}`, 75 + { 76 + signal: controller.signal, 77 + }, 78 + ); 79 + bskyUsers.length = 0; 80 + if (response.ok === false) { 81 + return; 82 + } 83 + const data = await response.json(); 84 + for (const actor of data.actors) { 85 + try { 86 + const parsed = parsePublicUser(actor); 87 + if (parsed.handle === handle) continue; 88 + if (parsed.handle === "handle.invalid") continue; 89 + bskyUsers.push({ ...parsed, avatar: actor.avatar }); 90 + } catch { 91 + continue; 92 + } 93 + } 94 + if (activeOption && document.getElementById(activeOption.id) === null) { 95 + activeOption = null; 96 + } 97 + }; 98 + 99 + $effect(() => { 100 + const value = handle.trim(); 101 + if (value.length < 2) { 102 + bskyUsers.length = 0; 103 + return; 104 + } 105 + bskySearch(value); 106 + }); 107 + 5 108 const confirmPurge = (ev: SubmitEvent) => { 6 109 if (confirm("Are you sure?")) { 7 110 return; ··· 45 148 </form> 46 149 {:else} 47 150 <form method="POST" action="?/login"> 48 - <h2>Connect</h2> 49 - <p>Connect with your Bluesky / Atmosphere account.</p> 151 + <h2>Sign in</h2> 152 + <p>Connect with your Atmosphere account.</p> 50 153 {#if form?.action === "login" && form?.error} 51 154 <p class="error">{form.error}</p> 52 155 {/if} 53 156 <label for="handle">Handle</label> 54 - <input type="text" id="handle" name="handle" value={form?.handle} /> 157 + <input 158 + type="text" 159 + id="handle" 160 + name="handle" 161 + bind:value={handle} 162 + onfocus={() => (handleFocus = true)} 163 + onblur={() => setTimeout(() => (handleFocus = false), 100)} 164 + onkeydown={handleKeydown} 165 + autocorrect="off" 166 + spellcheck="false" 167 + role="combobox" 168 + aria-autocomplete="list" 169 + aria-expanded="false" 170 + aria-controls="handle-listbox" 171 + aria-activedescendant={activeOption ? activeOption.id : undefined} 172 + /> 173 + {#if handleFocus && bskyUsers.length} 174 + <ul 175 + bind:this={handleListbox} 176 + id="handle-listbox" 177 + role="listbox" 178 + aria-label="suggestions" 179 + tabindex="-1" 180 + > 181 + {#each bskyUsers as user, i (user.did)} 182 + <li 183 + class="avatar" 184 + role="option" 185 + aria-selected={activeOption && 186 + activeOption.id === `option:${user.did}`} 187 + id="option:{user.did}" 188 + data-handle={user.handle} 189 + onclick={() => { 190 + handle = user.handle; 191 + }} 192 + > 193 + <img alt="avatar" src={user.avatar} width="50" height="50" /> 194 + <p>{user.displayName}</p> 195 + <p><small>@{user.handle}</small></p> 196 + </li> 197 + {/each} 198 + </ul> 199 + {/if} 55 200 <button type="submit">Sign in</button> 201 + <p>Suggestions provided by Bluesky.</p> 56 202 </form> 57 203 {/if}