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: repo browser and record viewer

Owais Jamil (Jun 17, 2026, 10:38 AM -0500) f8e5778d 3755b165

+604 -46
+5
.changeset/salty-wasps-bow.md
··· 1 + --- 2 + 'intrepid-ibex': minor 3 + --- 4 + 5 + expand repo browser capabilities with more information
+19 -7
README.md
··· 9 9 10 10 ### Nautilus 11 11 12 - The main ATProto browser. It lists repo collections, mirrors them in Places, and 13 - opens records from the selected collection. 12 + The main repo browser. It lists public collections, groups them by namespace, 13 + filters collections, pages through records, searches cached records, and opens 14 + records in gedit or media in Eye of GNOME. 14 15 15 - ### About 16 + ### gedit 16 17 17 - A small “About This Computer” window with OS, account, DID, PDS, and project links. 18 + A read-only record viewer. It opens JSON records from Nautilus with copy, word 19 + wrap, syntax highlighting, and tabs for JSON, inferred schema details, and record 20 + info such as AT URI, CID, raw PDS link, and external links. 18 21 19 - ### GEdit 22 + ### Eye of GNOME 20 23 21 - A read-only JSON viewer for records. It opens from Nautilus, supports copy and word 22 - wrap, and uses a native GTK-style window. 24 + A media viewer for repo blobs and record-attached media. It is used when Nautilus 25 + finds image or media blob references in a selected record. 26 + 27 + ### Identity Inspector 28 + 29 + A public identity window for DID documents, aliases, services, verification 30 + methods, rotation keys, handle checks, and PDS links. 31 + 32 + ### About 33 + 34 + A small “About This Computer” window with OS, account, DID, PDS, and project links. 23 35 24 36 ## Theme 25 37
+26 -2
TODO.md
··· 20 20 - `/labels` -> Label Browser. 21 21 - `/car` -> Archive Manager. 22 22 - `/servers/:host` -> Network Servers. 23 + - `/lexicons` and `/lexicons/:nsid` -> Web Browser. 23 24 - Route loaders should stay browser-only and public/read-only. 24 25 - DIDs remain canonical in URLs; handles are accepted as lookup/input affordances and 25 26 resolved client-side. ··· 84 85 - [ ] **Network Servers** 85 86 - Inspect public PDS host info, version, available domains, and repo list. 86 87 - Link repos into Nautilus and open server firehose views in System Monitor. 88 + - [ ] **Web Browser** 89 + - GNOME-style browser for reading Lexicon schemas and resolution traces. 90 + - Accept NSIDs, `at://` lexicon records, and HTTPS URLs in an address bar. 91 + - Parse NSIDs into domain authority, authority domain, name segment, and optional 92 + fragment. 93 + - Vendor a small explicit snapshot of common `app.bsky.*` and `com.atproto.*` 94 + lexicons for instant/offline display. 95 + - Label vendored schemas as local snapshots with source and snapshot metadata; do not 96 + present them as canonical truth. 97 + - For live lookup, resolve the NSID authority domain through DNS from the browser, 98 + likely via DNS-over-HTTPS rather than a backend resolver. 99 + - Prefer DNS records that explicitly advertise a Lexicon document URL or lexicon 100 + service endpoint for the authority domain. 101 + - Show a resolution trace with each attempted DNS name, record type, answer, fetch URL, 102 + status, and error details. 103 + - Display Lexicon JSON and a readable schema view with definitions, primary type, 104 + required fields, refs, unions, known values, and descriptions. 105 + - Link gedit Schema tabs and Nautilus collection schema actions into the matching 106 + browser view. 107 + - Keep lookup public/read-only and browser-only; do not add a backend resolver 108 + - Make unsupported NSIDs clear: ATProto does not define universal automated NSID 109 + schema fetching or enumeration. 87 110 - [ ] **About This Computer** 88 111 - Show OS/theme info and current public repo identity metadata when available. 89 112 90 113 ## Priority Order 91 114 92 115 - [x] Add canonical `/repos/:did/...` routing. 93 - - [ ] Deepen Nautilus and gedit with collection controls plus record JSON/schema/info tabs. 116 + - [x] Deepen Nautilus and gedit with collection controls plus record JSON/schema/info tabs. 94 117 - [x] Add Identity Inspector. 95 118 - [ ] Add System Monitor with Jetstream first. 96 119 - [ ] Add Network Map. 97 120 - [ ] Add Archive Manager. 98 121 - [ ] Add Eye of GNOME blob browsing and previews. 99 122 - [ ] Add Label Browser. 123 + - [ ] Add Web Browser for Lexicon viewing. 100 124 - [ ] Add Log Viewer. 101 125 - [ ] Add Network Servers. 102 126 ··· 112 136 - first-run setup and handle typeahead states. 113 137 - Nautilus collection selection, search, pagination, and record opening. 114 138 - gedit JSON display, copy behavior, and record metadata tabs. 139 + - Web Browser NSID parsing, vendored schema display, and resolution trace states. 115 140 - window manager open, focus, minimize, maximize, and route-driven gedit opening. 116 141 - Playwright E2E/smoke tests for critical flows: 117 142 - `/` boots the desktop. ··· 139 164 - Multi-repo switching from the panel without implying authentication. 140 165 141 166 - Relay 142 - - Web Browser for lexicon viewing (DNS resolution)
+29 -14
src/lib/atproto/repo.svelte.ts
··· 1 1 import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 2 import type { ActorIdentifier, Nsid } from '@atcute/lexicons/syntax'; 3 3 import type {} from '@atcute/atproto'; 4 - import type { CachedRecord, CachedRecordInput } from '$lib/db'; 4 + import type { CachedRecordInput } from '$lib/db'; 5 5 import { errorMessage } from '$lib/utils/errors'; 6 6 import { appLabelForCollection, collectionIconMatch } from './collection-icons'; 7 7 import { listRecordPages, type RecordPage } from './pagination'; 8 8 import { isRecordValue } from './types'; 9 9 import type { AccountIdentity, CollectionSummary, RepoRecordSummary, UnknownRecord } from './types'; 10 + import { truncate } from '$lib/utils/text'; 10 11 11 12 class RepoBrowserState { 12 13 collections = $state<CollectionSummary[]>([]); ··· 18 19 canLoadMoreRecords = $state(false); 19 20 isSearching = $state(false); 20 21 searchQuery = $state(''); 22 + recordPageSize = $state(25); 21 23 error = $state<string | null>(null); 22 24 loadedDid = $state<string | null>(null); 23 25 selectedRecord = $state<RepoRecordSummary | null>(null); ··· 69 71 70 72 try { 71 73 this.records = []; 72 - this.recordPages = listRecordPages({ identity, collection: collectionName, limit: 25 }); 74 + this.recordPages = listRecordPages({ identity, collection: collectionName, limit: this.recordPageSize }); 73 75 await this.loadNextRecordPage(identity, { throwOnError: true }); 74 76 } catch (unknownError) { 75 77 const loadedFromCache = await this.loadRecordsFromCache(identity, collectionName); ··· 154 156 repoDid: identity.did, 155 157 collection: this.selectedCollection ?? undefined 156 158 }); 157 - this.records = results.map((record) => summarizeCachedRecord(record, identity.handle)); 159 + this.records = results.map((record) => { 160 + return summarizeRecord({ uri: record.uri, cid: record.cid, value: record.value }, identity.handle); 161 + }); 158 162 } catch (searchError) { 159 163 this.records = []; 160 164 this.error = errorMessage(searchError, 'Could not search records.'); ··· 183 187 184 188 this.collections = collectionNames.sort().map((name) => collectionSummaryForName(name, null)); 185 189 this.selectedCollection = collectionName; 186 - this.recordPages = listRecordPages({ identity, collection: collectionName, limit: 25 }); 190 + this.recordPages = listRecordPages({ identity, collection: collectionName, limit: this.recordPageSize }); 187 191 await this.loadNextRecordPage(identity, { throwOnError: false }); 188 192 189 193 const summary = summarizeRecord(record, identity.handle); ··· 208 212 try { 209 213 const { getDatabase, listCachedRecords } = await import('$lib/db'); 210 214 const db = await getDatabase(); 211 - const records = await listCachedRecords(db, { repoDid: identity.did, collection: collectionName, limit: 25 }); 215 + const records = await listCachedRecords(db, { 216 + repoDid: identity.did, 217 + collection: collectionName, 218 + limit: this.recordPageSize 219 + }); 212 220 213 221 if (records.length === 0) return false; 214 222 215 223 this.recordPages = null; 216 224 this.canLoadMoreRecords = false; 217 - this.records = records.map((record) => summarizeCachedRecord(record, identity.handle)); 225 + this.records = records.map((record) => { 226 + return summarizeRecord({ uri: record.uri, cid: record.cid, value: record.value }, identity.handle); 227 + }); 228 + 218 229 this.collections = this.collections.map((collection) => 219 230 collection.name === collectionName ? { ...collection, loadedCount: records.length } : collection 220 231 ); ··· 222 233 } catch (cacheError) { 223 234 console.warn('Could not read cached records.', cacheError); 224 235 return false; 236 + } 237 + } 238 + 239 + async setRecordPageSize(identity: AccountIdentity, pageSize: number) { 240 + if (this.recordPageSize === pageSize) return; 241 + 242 + this.recordPageSize = pageSize; 243 + 244 + if (this.selectedCollection && !this.searchQuery) { 245 + await this.selectCollection(identity, this.selectedCollection); 225 246 } 226 247 } 227 248 ··· 236 257 this.canLoadMoreRecords = false; 237 258 this.isSearching = false; 238 259 this.searchQuery = ''; 260 + this.recordPageSize = 25; 239 261 this.error = null; 240 262 this.loadedDid = null; 241 263 this.recordPages = null; ··· 270 292 const text = stringifyField(value.text) ?? stringifyField(value.name) ?? stringifyField(value.displayName); 271 293 const type = stringifyField(value.$type) ?? collectionFromUri(record.uri); 272 294 const createdAt = stringifyField(value.createdAt) ?? stringifyField(value.indexedAt); 295 + // TODO: we should probably only show rkey 273 296 const title = text ? truncate(text.replaceAll('\n', ' '), 54) : recordKeyFromUri(record.uri); 274 297 const body = text ?? `Record type: ${type}`; 275 298 ··· 289 312 }; 290 313 } 291 314 292 - function summarizeCachedRecord(record: CachedRecord, handle: string): RepoRecordSummary { 293 - return summarizeRecord({ uri: record.uri, cid: record.cid, value: record.value }, handle); 294 - } 295 - 296 315 async function cacheLiveRecords( 297 316 id: AccountIdentity, 298 317 name: string, ··· 344 363 345 364 function stringifyField(value: unknown) { 346 365 return typeof value === 'string' && value.length > 0 ? value : null; 347 - } 348 - 349 - function truncate(value: string, maxLength: number) { 350 - return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value; 351 366 } 352 367 353 368 function collectionFromUri(uri: string) {
+167 -5
src/lib/components/CollectionBrowser.svelte
··· 6 6 import { accountSetup } from '$lib/atproto/setup.svelte'; 7 7 import { repoBrowser } from '$lib/atproto/repo.svelte'; 8 8 import { blobPath, collectionPath, identityPath, recordPath } from '$lib/atproto/routes'; 9 + import { isRecordValue } from '$lib/atproto/types'; 9 10 import type { CollectionSummary, RepoRecordSummary } from '$lib/atproto/types'; 10 11 import { SvelteMap } from 'svelte/reactivity'; 12 + 13 + type PreviewField = 'summary' | 'text' | 'type' | 'createdAt' | 'uri' | 'cid'; 11 14 12 15 let searchQuery = $state(''); 13 - const collectionGroups = $derived.by(() => groupCollections(repoBrowser.collections)); 16 + let collectionFilter = $state(''); 17 + let reverseRecords = $state(false); 18 + let previewField = $state<PreviewField>('summary'); 19 + 20 + const filteredCollections = $derived.by(() => { 21 + const query = collectionFilter.trim().toLowerCase(); 22 + if (!query) return repoBrowser.collections; 23 + 24 + return repoBrowser.collections.filter((collection) => { 25 + const label = collection.appLabel?.toLowerCase() ?? ''; 26 + return collection.name.toLowerCase().includes(query) || label.includes(query); 27 + }); 28 + }); 29 + const collectionGroups = $derived.by(() => groupCollections(filteredCollections)); 30 + const visibleRecords = $derived.by(() => { 31 + if (!reverseRecords) return repoBrowser.records; 32 + return [...repoBrowser.records].reverse(); 33 + }); 14 34 type RepoPathname = `/repos/${string}`; 15 35 const navigateTo = goto as (url: string, options?: Parameters<typeof goto>[1]) => ReturnType<typeof goto>; 16 36 ··· 69 89 navigate(identityPath(identity.did)); 70 90 } 71 91 92 + function updatePageSize(event: Event) { 93 + const identity = accountSetup.identity; 94 + const select = event.currentTarget; 95 + 96 + if (!(select instanceof HTMLSelectElement) || !identity) return; 97 + 98 + void repoBrowser.setRecordPageSize(identity, Number(select.value)); 99 + } 100 + 101 + function updatePreviewField(event: Event) { 102 + const select = event.currentTarget; 103 + if (!(select instanceof HTMLSelectElement)) return; 104 + 105 + previewField = select.value as PreviewField; 106 + } 107 + 108 + function previewTitle(record: RepoRecordSummary) { 109 + if (previewField === 'summary') return record.title; 110 + if (previewField === 'text') return stringRecordField(record, 'text') ?? record.title; 111 + if (previewField === 'type') return stringRecordField(record, '$type') ?? record.collection; 112 + if (previewField === 'createdAt') return stringRecordField(record, 'createdAt') ?? record.modified; 113 + if (previewField === 'uri') return record.uri; 114 + if (previewField === 'cid') return record.cid || 'No CID'; 115 + 116 + return record.title; 117 + } 118 + 119 + function previewBody(record: RepoRecordSummary) { 120 + if (previewField === 'summary') return record.body; 121 + if (previewField === 'text') return record.body; 122 + if (previewField === 'type') return `Collection: ${record.collection}`; 123 + if (previewField === 'createdAt') return `Indexed as ${record.modified}`; 124 + if (previewField === 'uri') return `${record.collection}/${record.rkey}`; 125 + if (previewField === 'cid') return record.cid ? `Record CID: ${record.cid}` : 'Record returned without a CID.'; 126 + 127 + return record.body; 128 + } 129 + 130 + function stringRecordField(record: RepoRecordSummary, key: string) { 131 + if (!isRecordValue(record.value)) return null; 132 + 133 + const value = record.value[key]; 134 + return typeof value === 'string' && value.length > 0 ? value : null; 135 + } 136 + 72 137 function navigate(path: string) { 73 138 void navigateTo(resolve(path as RepoPathname), { keepFocus: true, noScroll: true }); 74 139 } ··· 108 173 <p>{accountSetup.identity?.handle ?? 'at:// repo folders'}</p> 109 174 </header> 110 175 176 + <label class="collection-filter"> 177 + <span>Filter</span> 178 + <input bind:value={collectionFilter} placeholder="Collection name" /> 179 + </label> 180 + 111 181 <div class="collection-tree" role="tree" aria-label="Repository namespaces"> 112 182 {#if repoBrowser.isLoadingCollections} 113 183 <p class="empty-row">Loading collections…</p> 184 + {:else if filteredCollections.length === 0} 185 + <p class="empty-row">No collections match that filter.</p> 114 186 {:else} 115 187 {#each collectionGroups as group (group.namespace)} 116 188 <details class="namespace-group" open> ··· 168 240 </p> 169 241 </div> 170 242 <div class="summary-controls"> 243 + <div class="collection-controls" aria-label="Collection controls"> 244 + <label> 245 + <span>Page size</span> 246 + <select 247 + value={repoBrowser.recordPageSize} 248 + disabled={!repoBrowser.selectedCollection || repoBrowser.isLoadingRecords || repoBrowser.isSearching} 249 + onchange={updatePageSize}> 250 + <option value="10">10 records</option> 251 + <option value="25">25 records</option> 252 + <option value="50">50 records</option> 253 + <option value="100">100 records</option> 254 + </select> 255 + </label> 256 + <label> 257 + <span>Preview</span> 258 + <select value={previewField} onchange={updatePreviewField}> 259 + <option value="summary">Summary</option> 260 + <option value="text">Text</option> 261 + <option value="type">Schema type</option> 262 + <option value="createdAt">Created time</option> 263 + <option value="uri">AT URI</option> 264 + <option value="cid">CID</option> 265 + </select> 266 + </label> 267 + <label class="reverse-toggle"> 268 + <input type="checkbox" bind:checked={reverseRecords} /> 269 + <span>Reverse order</span> 270 + </label> 271 + </div> 171 272 <form 172 273 class="search-box" 173 274 role="search" ··· 219 320 {/if} 220 321 </p> 221 322 {:else} 222 - {#each repoBrowser.records as record (record.uri)} 323 + {#each visibleRecords as record (record.uri)} 223 324 <button class="record-row" type="button" onclick={() => openRecord(record)}> 224 325 <img src={record.icon} alt="" width="32" height="32" /> 225 326 <div> 226 - <h3>{record.title}</h3> 227 - <p>{record.body}</p> 327 + <h3>{previewTitle(record)}</h3> 328 + <p>{previewBody(record)}</p> 228 329 </div> 229 330 <strong>{record.author}</strong> 230 331 <time>{record.modified}</time> ··· 258 359 259 360 .sidebar { 260 361 display: grid; 261 - grid-template-rows: auto minmax(0, 1fr); 362 + grid-template-rows: auto auto minmax(0, 1fr); 262 363 min-height: 0; 263 364 background: linear-gradient(90deg, #cdb690, #e6d5b8); 264 365 border-right: 1px solid #9e8057; ··· 270 371 box-shadow: 0 1px 0 rgb(255 255 255 / 0.45); 271 372 } 272 373 374 + .collection-filter { 375 + display: grid; 376 + gap: var(--space-1); 377 + padding: var(--space-2) var(--space-3); 378 + border-bottom: 1px solid #b89a70; 379 + } 380 + 381 + .collection-filter span, 382 + .collection-controls span { 383 + color: #4b3824; 384 + font-size: var(--text-0); 385 + font-weight: 700; 386 + text-transform: uppercase; 387 + } 388 + 389 + .collection-filter input, 390 + .collection-controls select { 391 + min-width: 0; 392 + padding: 0.3rem 0.4rem; 393 + color: var(--text); 394 + background: #fffdf8; 395 + border: 1px solid #a88b63; 396 + border-radius: var(--radius-2); 397 + box-shadow: var(--shadow-sunken); 398 + font: inherit; 399 + font-size: var(--text-1); 400 + } 401 + 273 402 .sidebar h2, 274 403 .summary-card h2 { 275 404 font-size: var(--text-4); ··· 284 413 } 285 414 286 415 .collection-tree { 416 + min-height: 0; 287 417 overflow: auto; 288 418 padding: var(--space-2); 289 419 } ··· 410 540 gap: var(--space-1); 411 541 justify-self: end; 412 542 width: 100%; 543 + } 544 + 545 + .collection-controls { 546 + display: grid; 547 + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); 548 + gap: var(--space-2); 549 + align-items: end; 550 + padding: var(--space-2); 551 + background: rgb(255 253 248 / 0.58); 552 + border: 1px solid #c0a47a; 553 + border-radius: var(--radius-2); 554 + box-shadow: 0 1px 0 rgb(255 255 255 / 0.5) inset; 555 + } 556 + 557 + .collection-controls label { 558 + display: grid; 559 + gap: var(--space-1); 560 + } 561 + 562 + .collection-controls .reverse-toggle { 563 + grid-column: 1 / -1; 564 + display: flex; 565 + align-items: center; 566 + gap: var(--space-2); 567 + min-height: 1.5rem; 568 + } 569 + 570 + .reverse-toggle input { 571 + width: 0.95rem; 572 + height: 0.95rem; 573 + margin: 0; 574 + accent-color: #c96c1c; 413 575 } 414 576 415 577 .search-box label {
+355 -18
src/lib/components/Gedit.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 3 import type { ThemeRegistration } from 'shiki'; 4 + import { accountSetup } from '$lib/atproto/setup.svelte'; 5 + import { isRecordValue } from '$lib/atproto/types'; 4 6 import type { RepoRecordSummary } from '$lib/atproto/types'; 7 + import { truncate } from '$lib/utils/text'; 5 8 6 9 type Props = { record: RepoRecordSummary }; 7 10 type TokenLine = Array<{ content: string; color?: string; fontStyle?: number }>; 11 + type RecordTab = 'json' | 'schema' | 'info'; 12 + type SchemaField = { name: string; valueType: string; preview: string }; 8 13 9 14 let { record }: Props = $props(); 10 15 let tokenLines = $state<TokenLine[]>([]); 16 + let activeTab = $state<RecordTab>('json'); 11 17 let wordWrap = $state(false); 12 18 let copied = $state(false); 13 19 let copyTimeout: ReturnType<typeof setTimeout> | null = null; 20 + 21 + const recordType = $derived.by(() => { 22 + if (!isRecordValue(record.value)) return record.collection; 23 + 24 + const type = record.value.$type; 25 + if (typeof type === 'string' && type.length > 0) return type; 26 + 27 + return record.collection; 28 + }); 29 + const schemaFields = $derived.by(() => schemaFieldsForRecord(record.value)); 30 + const rawPdsLink = $derived.by(() => { 31 + const pds = accountSetup.identity?.pds; 32 + const repo = accountSetup.identity?.did; 33 + if (!pds || !repo) return null; 34 + 35 + const params = new URLSearchParams({ repo, collection: record.collection, rkey: record.rkey }); 36 + 37 + return `${pds}/xrpc/com.atproto.repo.getRecord?${params.toString()}`; 38 + }); 39 + const externalLinks = $derived.by(() => linksForRecord(record)); 14 40 15 41 const themeName = 'ubuntu-iterm2b24'; 16 42 const ubuntuTheme: ThemeRegistration = { ··· 77 103 }, 1400); 78 104 } 79 105 106 + function schemaFieldsForRecord(value: unknown): SchemaField[] { 107 + if (!isRecordValue(value)) return []; 108 + 109 + return Object.entries(value).map(([name, fieldValue]) => ({ 110 + name, 111 + valueType: valueTypeLabel(fieldValue), 112 + preview: valuePreview(fieldValue) 113 + })); 114 + } 115 + 116 + function valueTypeLabel(value: unknown) { 117 + if (Array.isArray(value)) return 'array'; 118 + if (value === null) return 'null'; 119 + return typeof value; 120 + } 121 + 122 + function valuePreview(value: unknown) { 123 + if (typeof value === 'string') return truncate(value.replaceAll('\n', ' '), 72); 124 + if (typeof value === 'number' || typeof value === 'boolean') return String(value); 125 + if (value === null) return 'null'; 126 + if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? '' : 's'}`; 127 + if (isRecordValue(value)) return `${Object.keys(value).length} field object`; 128 + 129 + return 'unknown'; 130 + } 131 + 132 + function linksForRecord(summary: RepoRecordSummary) { 133 + const links = [ 134 + { label: 'AT URI', href: summary.uri }, 135 + { label: 'pds.ls', href: `https://pds.ls/${summary.uri}` } 136 + ]; 137 + 138 + if (summary.collection === 'app.bsky.feed.post') { 139 + links.push({ 140 + label: 'Bluesky', 141 + href: `https://bsky.app/profile/${accountSetup.identity?.did}/post/${summary.rkey}` 142 + }); 143 + } 144 + 145 + return links; 146 + } 147 + 80 148 onMount(() => { 81 149 void highlight(); 82 150 }); ··· 94 162 Word Wrap 95 163 </button> 96 164 </div> 165 + <nav class="record-tabs" aria-label="Record tabs"> 166 + <button 167 + type="button" 168 + class:active={activeTab === 'json'} 169 + aria-pressed={activeTab === 'json'} 170 + onclick={() => (activeTab = 'json')}>JSON</button> 171 + <button 172 + type="button" 173 + class:active={activeTab === 'schema'} 174 + aria-pressed={activeTab === 'schema'} 175 + onclick={() => (activeTab = 'schema')}>Schema</button> 176 + <button 177 + type="button" 178 + class:active={activeTab === 'info'} 179 + aria-pressed={activeTab === 'info'} 180 + onclick={() => (activeTab = 'info')}>Info</button> 181 + </nav> 97 182 <div class="document-path"> 98 183 <img src={record.icon} alt="" width="18" height="18" /> 99 184 <span>{record.collection}/{record.rkey}.json</span> ··· 103 188 </div> 104 189 </header> 105 190 106 - <div class="editor-shell" class:word-wrap={wordWrap}> 107 - <aside class="line-gutter" aria-hidden="true"> 108 - {#each record.json.split('\n') as line, index (`line-${record.uri}-${index}`)} 109 - <span aria-label={line}>{index + 1}</span> 110 - {/each} 111 - </aside> 191 + {#if activeTab === 'json'} 192 + <div class="editor-shell" class:word-wrap={wordWrap}> 193 + <aside class="line-gutter" aria-hidden="true"> 194 + {#each record.json.split('\n') as line, index (`line-${record.uri}-${index}`)} 195 + <span aria-label={line}>{index + 1}</span> 196 + {/each} 197 + </aside> 198 + 199 + <div class="code-pane"> 200 + <pre class="gedit-code"><code 201 + >{#if tokenLines.length > 0}{#each tokenLines as line, lineIndex (`${record.uri}-${lineIndex}`)}<span 202 + class="code-line" 203 + >{#each line as token, tokenIndex (`${record.uri}-${lineIndex}-${tokenIndex}`)}<span 204 + style:color={token.color}>{token.content}</span 205 + >{/each}</span 206 + >{/each}{:else}{#each record.json.split('\n') as line, index (`fallback-${record.uri}-${index}`)}<span 207 + class="code-line">{line}</span 208 + >{/each}{/if}</code></pre> 209 + </div> 210 + </div> 211 + {:else if activeTab === 'schema'} 212 + <div class="tab-panel schema-panel"> 213 + <section class="schema-summary" aria-label="Record schema summary"> 214 + <img src={record.icon} alt="" width="38" height="38" /> 215 + <div> 216 + <p class="panel-label">Lexicon</p> 217 + <h2>{recordType}</h2> 218 + <p>{record.collection}</p> 219 + </div> 220 + </section> 221 + 222 + <table> 223 + <thead> 224 + <tr> 225 + <th>Field</th> 226 + <th>Type</th> 227 + <th>Preview</th> 228 + </tr> 229 + </thead> 230 + <tbody> 231 + {#each schemaFields as field (field.name)} 232 + <tr> 233 + <td>{field.name}</td> 234 + <td>{field.valueType}</td> 235 + <td>{field.preview}</td> 236 + </tr> 237 + {:else} 238 + <tr> 239 + <td colspan="3">Record value is not a JSON object.</td> 240 + </tr> 241 + {/each} 242 + </tbody> 243 + </table> 244 + </div> 245 + {:else} 246 + <div class="tab-panel info-panel"> 247 + <dl> 248 + <div> 249 + <dt>AT URI</dt> 250 + <dd>{record.uri}</dd> 251 + </div> 252 + <div> 253 + <dt>CID</dt> 254 + <dd> 255 + {#if record.cid} 256 + {record.cid} 257 + {:else} 258 + No CID returned 259 + {/if} 260 + </dd> 261 + </div> 262 + <div> 263 + <dt>Collection</dt> 264 + <dd>{record.collection}</dd> 265 + </div> 266 + <div> 267 + <dt>Record key</dt> 268 + <dd>{record.rkey}</dd> 269 + </div> 270 + <div> 271 + <dt>Read-only verification</dt> 272 + <dd> 273 + {#if record.cid} 274 + Loaded as a public repo record with an immutable CID. 275 + {:else} 276 + Loaded as a public repo record; the PDS did not include a CID. 277 + {/if} 278 + </dd> 279 + </div> 280 + <div> 281 + <dt>Raw PDS link</dt> 282 + <dd> 283 + {#if rawPdsLink} 284 + <a href={rawPdsLink} target="_blank" rel="external noreferrer">{record.rkey}</a> 285 + {:else} 286 + No PDS endpoint is available for this record. 287 + {/if} 288 + </dd> 289 + </div> 290 + </dl> 112 291 113 - <div class="code-pane"> 114 - <pre class="gedit-code"><code 115 - >{#if tokenLines.length > 0}{#each tokenLines as line, lineIndex (`${record.uri}-${lineIndex}`)}<span 116 - class="code-line" 117 - >{#each line as token, tokenIndex (`${record.uri}-${lineIndex}-${tokenIndex}`)}<span 118 - style:color={token.color}>{token.content}</span 119 - >{/each}</span 120 - >{/each}{:else}{#each record.json.split('\n') as line, index (`fallback-${record.uri}-${index}`)}<span 121 - class="code-line">{line}</span 122 - >{/each}{/if}</code></pre> 292 + <section class="external-links" aria-label="External app links"> 293 + <h2>External app links</h2> 294 + <ul> 295 + {#each externalLinks as link (link.label)} 296 + <li><a href={link.href} target="_blank" rel="external noreferrer">{link.label}</a></li> 297 + {/each} 298 + </ul> 299 + </section> 123 300 </div> 124 - </div> 301 + {/if} 125 302 126 303 <footer class="statusbar"> 127 304 <span>{record.uri}</span> 128 - <span>JSON</span> 305 + <span>{activeTab}</span> 129 306 <span>{wordWrap ? 'Wrap on' : 'Wrap off'}</span> 130 307 </footer> 131 308 </section> ··· 178 355 gap: var(--space-1); 179 356 } 180 357 358 + .record-tabs { 359 + display: flex; 360 + align-items: end; 361 + gap: 2px; 362 + align-self: stretch; 363 + } 364 + 181 365 button { 182 366 height: 1.65rem; 183 367 padding: 0 var(--space-3); ··· 190 374 cursor: default; 191 375 } 192 376 377 + .record-tabs button { 378 + align-self: end; 379 + border-bottom-color: #777a75; 380 + border-radius: var(--radius-2) var(--radius-2) 0 0; 381 + box-shadow: 0 1px 0 rgb(255 255 255 / 0.55) inset; 382 + } 383 + 193 384 button.active, 194 385 button.copied { 195 386 color: white; ··· 250 441 .code-pane { 251 442 overflow: auto; 252 443 min-width: 0; 444 + } 445 + 446 + .tab-panel { 447 + overflow: auto; 448 + min-width: 0; 449 + min-height: 0; 450 + padding: var(--space-3); 451 + color: var(--base07); 452 + background: var(--base00); 453 + } 454 + 455 + .schema-summary { 456 + display: flex; 457 + align-items: center; 458 + gap: var(--space-3); 459 + margin-bottom: var(--space-3); 460 + padding: var(--space-3); 461 + background: var(--base10); 462 + border: 1px solid var(--base02); 463 + } 464 + 465 + .schema-summary img { 466 + object-fit: contain; 467 + } 468 + 469 + .panel-label { 470 + color: var(--base04); 471 + font-size: var(--text-0); 472 + font-weight: 700; 473 + text-transform: uppercase; 474 + } 475 + 476 + .schema-summary h2, 477 + .external-links h2 { 478 + color: var(--base07); 479 + font-size: var(--text-3); 480 + line-height: var(--leading-tight); 481 + } 482 + 483 + .schema-summary p:last-child { 484 + color: var(--base05); 485 + font-size: var(--text-1); 486 + } 487 + 488 + table { 489 + width: 100%; 490 + border-collapse: collapse; 491 + font-family: var(--font-mono); 492 + font-size: var(--text-1); 493 + } 494 + 495 + th, 496 + td { 497 + padding: var(--space-2); 498 + border: 1px solid var(--base02); 499 + text-align: left; 500 + vertical-align: top; 501 + } 502 + 503 + th { 504 + color: var(--base01); 505 + background: linear-gradient(#d3d7cf, #b3b7b0); 506 + font-family: var(--font-sans); 507 + font-size: var(--text-0); 508 + text-transform: uppercase; 509 + } 510 + 511 + td { 512 + background: var(--base10); 513 + } 514 + 515 + td:first-child, 516 + td:nth-child(2) { 517 + color: var(--base0a); 518 + white-space: nowrap; 519 + } 520 + 521 + .info-panel { 522 + display: grid; 523 + gap: var(--space-4); 524 + align-content: start; 525 + } 526 + 527 + dl { 528 + display: grid; 529 + gap: 1px; 530 + margin: 0; 531 + border: 1px solid var(--base02); 532 + background: var(--base02); 533 + } 534 + 535 + dl div { 536 + display: grid; 537 + grid-template-columns: 10rem minmax(0, 1fr); 538 + background: var(--base10); 539 + } 540 + 541 + dt, 542 + dd { 543 + margin: 0; 544 + padding: var(--space-2); 545 + } 546 + 547 + dt { 548 + color: var(--base06); 549 + background: var(--base01); 550 + font-weight: 700; 551 + } 552 + 553 + dd { 554 + overflow-wrap: anywhere; 555 + font-family: var(--font-mono); 556 + font-size: var(--text-1); 557 + } 558 + 559 + a { 560 + color: var(--base16); 561 + text-decoration: underline; 562 + } 563 + 564 + .external-links { 565 + display: grid; 566 + gap: var(--space-2); 567 + } 568 + 569 + .external-links ul { 570 + display: flex; 571 + flex-wrap: wrap; 572 + gap: var(--space-2); 573 + margin: 0; 574 + padding: 0; 575 + list-style: none; 576 + } 577 + 578 + .external-links a { 579 + display: inline-block; 580 + padding: var(--space-2) var(--space-3); 581 + color: var(--base01); 582 + background: linear-gradient(#eeeeec, #b3b7b0); 583 + border: 1px solid var(--base03); 584 + border-radius: var(--radius-2); 585 + box-shadow: var(--shadow-raised); 586 + font-family: var(--font-sans); 587 + font-size: var(--text-1); 588 + font-weight: 700; 589 + text-decoration: none; 253 590 } 254 591 255 592 .gedit-code {
+3
src/lib/utils/text.ts
··· 1 + export function truncate(value: string, maxLength: number) { 2 + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value; 3 + }