browse the protocol like its 2008 ibex.desertthunder.dev
ubuntu atproto svelte
7

Configure Feed

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

feat: search UI

Owais Jamil (Jun 9, 2026, 1:14 PM -0500) c570b0bf 77105407

+168 -80
+1 -6
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 - All notable changes to this project will be documented in this file. 4 - 5 - The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 - and this project targets [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 - 8 3 ## [Unreleased] 9 4 10 5 Target release: `v1.0.0`. ··· 20 15 recovery actions. 21 16 - Cache-aware AT Protocol browsing: live record fetches populate PGlite and offline 22 17 failures can fall back to cached collections/records. 23 - - Shared `errorMessage()` utility for consistent unknown-error messages. 18 + - Full-text cache search in the Nautilus-style collection browser. 24 19 25 20 ### 2026-06-08 26 21
+35
src/lib/atproto/repo.svelte.ts
··· 6 6 getDatabase, 7 7 listCachedCollections, 8 8 listCachedRecords, 9 + searchCachedRecords, 9 10 updateCollectionSyncState, 10 11 type CachedRecord, 11 12 type CachedRecordInput ··· 27 28 records = $state<RepoRecordSummary[]>([]); 28 29 isLoadingCollections = $state(false); 29 30 isLoadingRecords = $state(false); 31 + isSearching = $state(false); 32 + searchQuery = $state(''); 30 33 error = $state<string | null>(null); 31 34 loadedDid = $state<string | null>(null); 32 35 selectedRecord = $state<RepoRecordSummary | null>(null); ··· 71 74 72 75 async selectCollection(identity: AccountIdentity, collectionName: string) { 73 76 this.selectedCollection = collectionName; 77 + this.searchQuery = ''; 74 78 this.isLoadingRecords = true; 75 79 this.error = null; 76 80 ··· 127 131 } 128 132 } 129 133 134 + async searchRecords(identity: AccountIdentity, query: string) { 135 + const normalizedQuery = query.trim(); 136 + this.searchQuery = normalizedQuery; 137 + 138 + if (!normalizedQuery) { 139 + if (this.selectedCollection) { 140 + await this.selectCollection(identity, this.selectedCollection); 141 + } 142 + return; 143 + } 144 + 145 + this.isSearching = true; 146 + this.error = null; 147 + 148 + try { 149 + const db = await getDatabase(); 150 + const results = await searchCachedRecords(db, normalizedQuery, { 151 + repoDid: identity.did, 152 + collection: this.selectedCollection ?? undefined 153 + }); 154 + this.records = results.map((record) => summarizeCachedRecord(record, identity.handle)); 155 + } catch (searchError) { 156 + this.records = []; 157 + this.error = errorMessage(searchError, 'Could not search cached records.'); 158 + } finally { 159 + this.isSearching = false; 160 + } 161 + } 162 + 130 163 async loadRecordsFromCache(identity: AccountIdentity, collectionName: string) { 131 164 try { 132 165 const db = await getDatabase(); ··· 152 185 this.selectedRecord = null; 153 186 this.isLoadingCollections = false; 154 187 this.isLoadingRecords = false; 188 + this.isSearching = false; 189 + this.searchQuery = ''; 155 190 this.error = null; 156 191 this.loadedDid = null; 157 192 }
+21 -70
src/lib/components/BootSplash.svelte
··· 14 14 </script> 15 15 16 16 <section class="boot-splash" aria-label="Starting Intrepid Ibex" aria-busy={error ? 'false' : 'true'}> 17 - <div class="boot-orb" aria-hidden="true"></div> 18 17 <div class="boot-card"> 19 18 <img src={favicon} alt="" class="boot-logo" /> 20 19 <div class="boot-copy"> ··· 44 43 z-index: 9999; 45 44 display: grid; 46 45 place-items: center; 47 - overflow: hidden; 48 - color: #fff7e8; 49 - background: 50 - radial-gradient(circle at 52% 42%, rgb(255 132 34 / 0.28), transparent 0 11rem), 51 - radial-gradient(circle at 30% 30%, rgb(119 36 87 / 0.72), transparent 0 22rem), 52 - radial-gradient(circle at 78% 78%, rgb(238 105 16 / 0.28), transparent 0 24rem), 53 - linear-gradient(145deg, #2c001e 0%, #441134 48%, #230017 100%); 54 - } 55 - 56 - .boot-splash::before { 57 - position: absolute; 58 - inset: 0; 59 - content: ''; 60 - background-image: 61 - linear-gradient(rgb(255 255 255 / 0.035) 1px, transparent 1px), 62 - linear-gradient(90deg, rgb(255 255 255 / 0.025) 1px, transparent 1px); 63 - background-size: 18px 18px; 64 - mask-image: radial-gradient(circle at center, black, transparent 78%); 65 - } 66 - 67 - .boot-orb { 68 - position: absolute; 69 - width: min(30rem, 74vw); 70 - aspect-ratio: 1; 71 - border-radius: 50%; 72 - background: radial-gradient(circle, rgb(255 151 51 / 0.18), transparent 62%); 73 - box-shadow: 0 0 6rem rgb(255 107 24 / 0.32); 74 - animation: breathe 2.8s ease-in-out infinite; 46 + color: #eeeeec; 47 + background: #300a24; 75 48 } 76 49 77 50 .boot-card { 78 - position: relative; 79 51 display: grid; 80 52 justify-items: center; 81 53 width: min(24rem, calc(100vw - 2rem)); 82 - padding: var(--space-8) var(--space-6) var(--space-6); 54 + padding: var(--space-8) var(--space-6); 83 55 text-align: center; 84 - background: linear-gradient(rgb(255 255 255 / 0.08), rgb(0 0 0 / 0.06)); 85 - border: 1px solid rgb(255 229 198 / 0.22); 86 - border-radius: 10px; 87 - box-shadow: 88 - 0 2rem 5rem rgb(0 0 0 / 0.38), 89 - 0 1px 0 rgb(255 255 255 / 0.18) inset; 90 - backdrop-filter: blur(4px); 91 56 } 92 57 93 58 .boot-logo { 94 59 width: 6rem; 95 60 height: 6rem; 96 - padding: var(--space-3); 97 - border-radius: 1.4rem; 98 - filter: drop-shadow(0 1rem 1.5rem rgb(0 0 0 / 0.4)); 99 - background: radial-gradient(circle at 38% 32%, rgb(255 255 255 / 0.14), rgb(255 255 255 / 0.04)); 61 + color: #e95420; 62 + filter: drop-shadow(0 0.35rem 0.75rem rgb(0 0 0 / 0.28)); 100 63 } 101 64 102 65 .boot-copy { 103 66 margin-top: var(--space-5); 67 + } 68 + 69 + .boot-kicker, 70 + .boot-copy h1 { 71 + color: #e95420; 104 72 } 105 73 106 74 .boot-kicker { 107 - color: #f4b15f; 108 75 font-size: var(--text-1); 109 76 font-weight: 700; 110 77 letter-spacing: 0.08em; 111 78 text-transform: uppercase; 112 - text-shadow: 0 1px 1px rgb(0 0 0 / 0.55); 113 79 } 114 80 115 81 .boot-copy h1 { 116 82 margin-top: var(--space-1); 117 83 font-size: clamp(1.6rem, 4vw, 2.2rem); 118 84 line-height: var(--leading-tight); 119 - text-shadow: 0 2px 8px rgb(0 0 0 / 0.45); 120 85 } 121 86 122 87 .boot-step { 123 88 min-height: 1.3rem; 124 89 margin-top: var(--space-3); 125 - color: #f0d6c2; 90 + color: #eeeeec; 126 91 font-size: var(--text-2); 127 92 } 128 93 129 94 .boot-meter { 130 95 position: relative; 131 96 width: min(13rem, 70vw); 132 - height: 0.55rem; 97 + height: 0.45rem; 133 98 margin-top: var(--space-6); 134 99 overflow: hidden; 135 - background: rgb(24 0 17 / 0.62); 136 - border: 1px solid rgb(255 255 255 / 0.16); 100 + background: rgb(238 238 236 / 0.18); 137 101 border-radius: var(--radius-round); 138 - box-shadow: 0 1px 2px rgb(0 0 0 / 0.45) inset; 139 102 } 140 103 141 104 .boot-meter span { ··· 145 108 left: -45%; 146 109 width: 45%; 147 110 border-radius: inherit; 148 - background: linear-gradient(90deg, transparent, #f28c22, #ffd08a, transparent); 111 + background: #e95420; 149 112 animation: loading 1.35s ease-in-out infinite; 150 113 } 151 114 ··· 159 122 160 123 .boot-actions button { 161 124 padding: 0.45rem 0.75rem; 162 - color: #2c001e; 125 + color: #eeeeec; 163 126 font: inherit; 164 127 font-size: var(--text-1); 165 128 font-weight: 700; 166 - background: linear-gradient(#fff4d8, #d9b06e); 167 - border: 1px solid #7a481a; 129 + background: #e95420; 130 + border: 1px solid #e95420; 168 131 border-radius: var(--radius-2); 169 - box-shadow: var(--shadow-raised); 170 132 } 171 133 172 134 .boot-actions button.quiet { 173 - color: #fff3df; 174 - background: rgb(255 255 255 / 0.08); 175 - border-color: rgb(255 255 255 / 0.22); 135 + color: #eeeeec; 136 + background: transparent; 137 + border-color: rgb(238 238 236 / 0.45); 176 138 } 177 139 178 140 @keyframes loading { ··· 181 143 } 182 144 } 183 145 184 - @keyframes breathe { 185 - 50% { 186 - transform: scale(1.05); 187 - opacity: 0.82; 188 - } 189 - } 190 - 191 146 @media (prefers-reduced-motion: reduce) { 192 - .boot-orb, 193 - .boot-meter span { 194 - animation: none; 195 - } 196 - 197 147 .boot-meter span { 198 148 left: 0; 199 149 width: 100%; 150 + animation: none; 200 151 } 201 152 } 202 153 </style>
+101 -4
src/lib/components/CollectionBrowser.svelte
··· 5 5 import type { RepoRecordSummary } from '$lib/atproto/types'; 6 6 import { windowManager } from '$lib/window-manager.svelte'; 7 7 8 + let searchQuery = $state(''); 9 + 8 10 onMount(() => { 9 11 const identity = accountSetup.identity; 10 12 ··· 17 19 const identity = accountSetup.identity; 18 20 19 21 if (identity) { 22 + searchQuery = ''; 20 23 repoBrowser.selectCollection(identity, collectionName); 21 24 } 25 + } 26 + 27 + function searchRecords() { 28 + const identity = accountSetup.identity; 29 + 30 + if (identity) { 31 + repoBrowser.searchRecords(identity, searchQuery); 32 + } 33 + } 34 + 35 + function clearSearch() { 36 + searchQuery = ''; 37 + searchRecords(); 22 38 } 23 39 24 40 function openRecord(record: RepoRecordSummary) { ··· 68 84 <p> 69 85 {#if repoBrowser.isLoadingRecords} 70 86 Fetching public records from {accountSetup.identity?.pds ?? 'the public API'}… 87 + {:else if repoBrowser.searchQuery} 88 + Searching cached records for “{repoBrowser.searchQuery}”. 71 89 {:else} 72 90 Browse records as if they were tidy Nautilus files. 73 91 {/if} 74 92 </p> 75 93 </div> 94 + <form class="search-box" role="search" onsubmit={(event) => { event.preventDefault(); searchRecords(); }}> 95 + <label for="record-search">Search cache</label> 96 + <div> 97 + <input 98 + id="record-search" 99 + bind:value={searchQuery} 100 + placeholder="Search cached records" 101 + disabled={!repoBrowser.selectedCollection || repoBrowser.isLoadingRecords || repoBrowser.isSearching} /> 102 + <button type="submit" disabled={!searchQuery.trim() || repoBrowser.isSearching}>Search</button> 103 + {#if repoBrowser.searchQuery} 104 + <button type="button" class="clear-search" onclick={clearSearch}>Clear</button> 105 + {/if} 106 + </div> 107 + </form> 76 108 </div> 77 109 78 110 <div class="record-list"> ··· 84 116 85 117 {#if repoBrowser.error} 86 118 <p class="message error">{repoBrowser.error}</p> 87 - {:else if repoBrowser.isLoadingRecords} 88 - <p class="message">Loading records…</p> 119 + {:else if repoBrowser.isLoadingRecords || repoBrowser.isSearching} 120 + <p class="message">{repoBrowser.isSearching ? 'Searching cached records…' : 'Loading records…'}</p> 89 121 {:else if repoBrowser.records.length === 0} 90 - <p class="message">No public records found for this collection.</p> 122 + <p class="message"> 123 + {repoBrowser.searchQuery ? 'No cached records matched that search.' : 'No public records found for this collection.'} 124 + </p> 91 125 {:else} 92 126 {#each repoBrowser.records as record (record.uri)} 93 127 <button class="record-row" type="button" onclick={() => openRecord(record)}> ··· 204 238 } 205 239 206 240 .summary-card { 207 - display: flex; 241 + display: grid; 242 + grid-template-columns: 48px minmax(0, 1fr) minmax(16rem, 22rem); 208 243 gap: var(--space-3); 209 244 align-items: center; 210 245 padding: var(--space-4); ··· 212 247 border-bottom: 1px solid #b29366; 213 248 } 214 249 250 + .search-box { 251 + display: grid; 252 + gap: var(--space-1); 253 + justify-self: end; 254 + width: 100%; 255 + } 256 + 257 + .search-box label { 258 + color: var(--text-muted); 259 + font-size: var(--text-0); 260 + font-weight: 700; 261 + text-transform: uppercase; 262 + } 263 + 264 + .search-box div { 265 + display: flex; 266 + gap: var(--space-1); 267 + } 268 + 269 + .search-box input { 270 + min-width: 0; 271 + flex: 1; 272 + padding: 0.35rem 0.45rem; 273 + color: var(--text); 274 + background: #fffdf8; 275 + border: 1px solid #a88b63; 276 + border-radius: var(--radius-2); 277 + box-shadow: var(--shadow-sunken); 278 + font: inherit; 279 + font-size: var(--text-1); 280 + } 281 + 282 + .search-box button { 283 + padding: 0.35rem 0.55rem; 284 + color: #2c180d; 285 + background: linear-gradient(#fff8e8, #d3b17d); 286 + border: 1px solid #9d7a4b; 287 + border-radius: var(--radius-2); 288 + box-shadow: var(--shadow-raised); 289 + font: inherit; 290 + font-size: var(--text-1); 291 + font-weight: 700; 292 + } 293 + 294 + .search-box button:disabled, 295 + .search-box input:disabled { 296 + opacity: 0.62; 297 + } 298 + 299 + .search-box .clear-search { 300 + font-weight: 500; 301 + } 302 + 215 303 .eyebrow { 216 304 font-weight: 700; 217 305 letter-spacing: 0.04em; ··· 288 376 289 377 .sidebar { 290 378 display: none; 379 + } 380 + 381 + .summary-card { 382 + grid-template-columns: 48px minmax(0, 1fr); 383 + } 384 + 385 + .search-box { 386 + grid-column: 1 / -1; 387 + justify-self: stretch; 291 388 } 292 389 293 390 .record-list > header,
+10
src/routes/+layout.svelte
··· 81 81 }); 82 82 83 83 async function bootDesktop() { 84 + const bootStartedAt = performance.now(); 84 85 bootStatus = 'booting'; 85 86 bootError = null; 86 87 ··· 90 91 bootStep = 'Applying migrations'; 91 92 await runMigrations(db); 92 93 bootStep = 'Starting desktop'; 94 + await waitForMinimumBootTime(bootStartedAt); 93 95 accountSetup.load(); 94 96 bootStatus = 'ready'; 95 97 } catch (error) { ··· 116 118 cacheDisabled = true; 117 119 bootStatus = 'ready'; 118 120 accountSetup.load(); 121 + } 122 + 123 + async function waitForMinimumBootTime(startedAt: number) { 124 + const remaining = 2500 - (performance.now() - startedAt); 125 + 126 + if (remaining > 0) { 127 + await new Promise((resolve) => setTimeout(resolve, remaining)); 128 + } 119 129 } 120 130 </script> 121 131