this repo has no description
1<script lang="ts"> 2 import { getAuthState } from '../lib/auth.svelte' 3 import { navigate } from '../lib/router.svelte' 4 import { api, ApiError } from '../lib/api' 5 const auth = getAuthState() 6 type View = 'collections' | 'records' | 'record' | 'create' 7 let view = $state<View>('collections') 8 let collections = $state<string[]>([]) 9 let selectedCollection = $state<string | null>(null) 10 let records = $state<Array<{ uri: string; cid: string; value: unknown; rkey: string }>>([]) 11 let recordsCursor = $state<string | undefined>(undefined) 12 let selectedRecord = $state<{ uri: string; cid: string; value: unknown; rkey: string } | null>(null) 13 let loading = $state(true) 14 let loadingMore = $state(false) 15 let error = $state<{ code?: string; message: string } | null>(null) 16 let success = $state<string | null>(null) 17 function setError(e: unknown) { 18 if (e instanceof ApiError) { 19 error = { code: e.error, message: e.message } 20 } else if (e instanceof Error) { 21 error = { message: e.message } 22 } else { 23 error = { message: 'An unknown error occurred' } 24 } 25 } 26 let newCollection = $state('') 27 let newRkey = $state('') 28 let recordJson = $state('') 29 let jsonError = $state<string | null>(null) 30 let saving = $state(false) 31 let filter = $state('') 32 $effect(() => { 33 if (!auth.loading && !auth.session) { 34 navigate('/login') 35 } 36 }) 37 $effect(() => { 38 if (auth.session) { 39 loadCollections() 40 } 41 }) 42 async function loadCollections() { 43 if (!auth.session) return 44 loading = true 45 error = null 46 try { 47 const result = await api.describeRepo(auth.session.accessJwt, auth.session.did) 48 collections = result.collections.sort() 49 } catch (e) { 50 setError(e) 51 } finally { 52 loading = false 53 } 54 } 55 async function selectCollection(collection: string) { 56 if (!auth.session) return 57 selectedCollection = collection 58 records = [] 59 recordsCursor = undefined 60 view = 'records' 61 loading = true 62 error = null 63 try { 64 const result = await api.listRecords(auth.session.accessJwt, auth.session.did, collection, { limit: 50 }) 65 records = result.records.map(r => ({ 66 ...r, 67 rkey: r.uri.split('/').pop()! 68 })) 69 recordsCursor = result.cursor 70 } catch (e) { 71 setError(e) 72 } finally { 73 loading = false 74 } 75 } 76 async function loadMoreRecords() { 77 if (!auth.session || !selectedCollection || !recordsCursor) return 78 loadingMore = true 79 try { 80 const result = await api.listRecords(auth.session.accessJwt, auth.session.did, selectedCollection, { 81 limit: 50, 82 cursor: recordsCursor 83 }) 84 records = [...records, ...result.records.map(r => ({ 85 ...r, 86 rkey: r.uri.split('/').pop()! 87 }))] 88 recordsCursor = result.cursor 89 } catch (e) { 90 setError(e) 91 } finally { 92 loadingMore = false 93 } 94 } 95 async function selectRecord(record: { uri: string; cid: string; value: unknown; rkey: string }) { 96 selectedRecord = record 97 recordJson = JSON.stringify(record.value, null, 2) 98 jsonError = null 99 view = 'record' 100 } 101 function startCreate(collection?: string) { 102 newCollection = collection || 'app.bsky.feed.post' 103 newRkey = '' 104 const exampleRecords: Record<string, unknown> = { 105 'app.bsky.feed.post': { 106 $type: 'app.bsky.feed.post', 107 text: 'Hello from my PDS! This is my first post.', 108 createdAt: new Date().toISOString(), 109 }, 110 'app.bsky.actor.profile': { 111 $type: 'app.bsky.actor.profile', 112 displayName: 'Your Display Name', 113 description: 'A short bio about yourself.', 114 }, 115 'app.bsky.graph.follow': { 116 $type: 'app.bsky.graph.follow', 117 subject: 'did:web:example.com', 118 createdAt: new Date().toISOString(), 119 }, 120 'app.bsky.feed.like': { 121 $type: 'app.bsky.feed.like', 122 subject: { 123 uri: 'at://did:web:example.com/app.bsky.feed.post/abc123', 124 cid: 'bafyreiabc123...', 125 }, 126 createdAt: new Date().toISOString(), 127 }, 128 } 129 const example = exampleRecords[collection || 'app.bsky.feed.post'] || { 130 $type: collection || 'app.bsky.feed.post', 131 } 132 recordJson = JSON.stringify(example, null, 2) 133 jsonError = null 134 view = 'create' 135 } 136 function validateJson(): unknown | null { 137 try { 138 const parsed = JSON.parse(recordJson) 139 jsonError = null 140 return parsed 141 } catch (e) { 142 jsonError = e instanceof Error ? e.message : 'Invalid JSON' 143 return null 144 } 145 } 146 async function handleCreate(e: Event) { 147 e.preventDefault() 148 if (!auth.session) return 149 const record = validateJson() 150 if (!record) return 151 if (!newCollection.trim()) { 152 error = { message: 'Collection is required' } 153 return 154 } 155 saving = true 156 error = null 157 try { 158 const result = await api.createRecord( 159 auth.session.accessJwt, 160 auth.session.did, 161 newCollection.trim(), 162 record, 163 newRkey.trim() || undefined 164 ) 165 success = `Record created: ${result.uri}` 166 await loadCollections() 167 await selectCollection(newCollection.trim()) 168 } catch (e) { 169 setError(e) 170 } finally { 171 saving = false 172 } 173 } 174 async function handleUpdate(e: Event) { 175 e.preventDefault() 176 if (!auth.session || !selectedRecord || !selectedCollection) return 177 const record = validateJson() 178 if (!record) return 179 saving = true 180 error = null 181 try { 182 await api.putRecord( 183 auth.session.accessJwt, 184 auth.session.did, 185 selectedCollection, 186 selectedRecord.rkey, 187 record 188 ) 189 success = 'Record updated' 190 const updated = await api.getRecord( 191 auth.session.accessJwt, 192 auth.session.did, 193 selectedCollection, 194 selectedRecord.rkey 195 ) 196 selectedRecord = { ...updated, rkey: selectedRecord.rkey } 197 recordJson = JSON.stringify(updated.value, null, 2) 198 } catch (e) { 199 setError(e) 200 } finally { 201 saving = false 202 } 203 } 204 async function handleDelete() { 205 if (!auth.session || !selectedRecord || !selectedCollection) return 206 if (!confirm(`Delete record ${selectedRecord.rkey}? This cannot be undone.`)) return 207 saving = true 208 error = null 209 try { 210 await api.deleteRecord( 211 auth.session.accessJwt, 212 auth.session.did, 213 selectedCollection, 214 selectedRecord.rkey 215 ) 216 success = 'Record deleted' 217 selectedRecord = null 218 await selectCollection(selectedCollection) 219 } catch (e) { 220 setError(e) 221 } finally { 222 saving = false 223 } 224 } 225 function goBack() { 226 if (view === 'record' || view === 'create') { 227 if (selectedCollection) { 228 view = 'records' 229 } else { 230 view = 'collections' 231 } 232 } else if (view === 'records') { 233 selectedCollection = null 234 view = 'collections' 235 } 236 error = null 237 success = null 238 } 239 let filteredCollections = $derived( 240 filter 241 ? collections.filter(c => c.toLowerCase().includes(filter.toLowerCase())) 242 : collections 243 ) 244 let filteredRecords = $derived( 245 filter 246 ? records.filter(r => 247 r.rkey.toLowerCase().includes(filter.toLowerCase()) || 248 JSON.stringify(r.value).toLowerCase().includes(filter.toLowerCase()) 249 ) 250 : records 251 ) 252 function groupCollectionsByAuthority(cols: string[]): Map<string, string[]> { 253 const groups = new Map<string, string[]>() 254 for (const col of cols) { 255 const parts = col.split('.') 256 const authority = parts.slice(0, -1).join('.') 257 const name = parts[parts.length - 1] 258 if (!groups.has(authority)) { 259 groups.set(authority, []) 260 } 261 groups.get(authority)!.push(name) 262 } 263 return groups 264 } 265 let groupedCollections = $derived(groupCollectionsByAuthority(filteredCollections)) 266</script> 267<div class="page"> 268 <header> 269 <div class="breadcrumb"> 270 <a href="#/dashboard" class="back">&larr; Dashboard</a> 271 {#if view !== 'collections'} 272 <span class="sep">/</span> 273 <button class="breadcrumb-link" onclick={goBack}> 274 {view === 'records' || view === 'create' ? 'Collections' : selectedCollection} 275 </button> 276 {/if} 277 {#if view === 'record' && selectedRecord} 278 <span class="sep">/</span> 279 <span class="current">{selectedRecord.rkey}</span> 280 {/if} 281 {#if view === 'create'} 282 <span class="sep">/</span> 283 <span class="current">New Record</span> 284 {/if} 285 </div> 286 <h1> 287 {#if view === 'collections'} 288 Repository Explorer 289 {:else if view === 'records'} 290 {selectedCollection} 291 {:else if view === 'record'} 292 Record Detail 293 {:else} 294 Create Record 295 {/if} 296 </h1> 297 {#if auth.session} 298 <p class="did">{auth.session.did}</p> 299 {/if} 300 </header> 301 {#if error} 302 <div class="message error"> 303 {#if error.code} 304 <strong class="error-code">{error.code}</strong> 305 {/if} 306 <span class="error-message">{error.message}</span> 307 </div> 308 {/if} 309 {#if success} 310 <div class="message success">{success}</div> 311 {/if} 312 {#if loading} 313 <p class="loading-text">Loading...</p> 314 {:else if view === 'collections'} 315 <div class="toolbar"> 316 <input 317 type="text" 318 placeholder="Filter collections..." 319 bind:value={filter} 320 class="filter-input" 321 /> 322 <button class="primary" onclick={() => startCreate()}>Create Record</button> 323 </div> 324 {#if collections.length === 0} 325 <p class="empty">No collections yet. Create your first record to get started.</p> 326 {:else} 327 <div class="collections"> 328 {#each [...groupedCollections.entries()] as [authority, nsids]} 329 <div class="collection-group"> 330 <h3 class="authority">{authority}</h3> 331 <ul class="nsid-list"> 332 {#each nsids as nsid} 333 <li> 334 <button class="collection-link" onclick={() => selectCollection(`${authority}.${nsid}`)}> 335 <span class="nsid">{nsid}</span> 336 <span class="arrow">&rarr;</span> 337 </button> 338 </li> 339 {/each} 340 </ul> 341 </div> 342 {/each} 343 </div> 344 {/if} 345 {:else if view === 'records'} 346 <div class="toolbar"> 347 <input 348 type="text" 349 placeholder="Filter records..." 350 bind:value={filter} 351 class="filter-input" 352 /> 353 <button class="primary" onclick={() => startCreate(selectedCollection!)}>Create Record</button> 354 </div> 355 {#if records.length === 0} 356 <p class="empty">No records in this collection.</p> 357 {:else} 358 <ul class="record-list"> 359 {#each filteredRecords as record} 360 <li> 361 <button class="record-item" onclick={() => selectRecord(record)}> 362 <div class="record-info"> 363 <span class="rkey">{record.rkey}</span> 364 <span class="cid" title={record.cid}>{record.cid.slice(0, 12)}...</span> 365 </div> 366 <pre class="record-preview">{JSON.stringify(record.value, null, 2).slice(0, 200)}{JSON.stringify(record.value).length > 200 ? '...' : ''}</pre> 367 </button> 368 </li> 369 {/each} 370 </ul> 371 {#if recordsCursor} 372 <div class="load-more"> 373 <button onclick={loadMoreRecords} disabled={loadingMore}> 374 {loadingMore ? 'Loading...' : 'Load More'} 375 </button> 376 </div> 377 {/if} 378 {/if} 379 {:else if view === 'record' && selectedRecord} 380 <div class="record-detail"> 381 <div class="record-meta"> 382 <dl> 383 <dt>URI</dt> 384 <dd class="mono">{selectedRecord.uri}</dd> 385 <dt>CID</dt> 386 <dd class="mono">{selectedRecord.cid}</dd> 387 </dl> 388 </div> 389 <form onsubmit={handleUpdate}> 390 <div class="editor-container"> 391 <label for="record-json">Record JSON</label> 392 <textarea 393 id="record-json" 394 bind:value={recordJson} 395 oninput={() => validateJson()} 396 class:has-error={jsonError} 397 spellcheck="false" 398 ></textarea> 399 {#if jsonError} 400 <p class="json-error">{jsonError}</p> 401 {/if} 402 </div> 403 <div class="actions"> 404 <button type="submit" class="primary" disabled={saving || !!jsonError}> 405 {saving ? 'Saving...' : 'Update Record'} 406 </button> 407 <button type="button" class="danger" onclick={handleDelete} disabled={saving}> 408 Delete 409 </button> 410 </div> 411 </form> 412 </div> 413 {:else if view === 'create'} 414 <form class="create-form" onsubmit={handleCreate}> 415 <div class="field"> 416 <label for="collection">Collection (NSID)</label> 417 <input 418 id="collection" 419 type="text" 420 bind:value={newCollection} 421 placeholder="app.bsky.feed.post" 422 disabled={saving} 423 required 424 /> 425 </div> 426 <div class="field"> 427 <label for="rkey">Record Key (optional)</label> 428 <input 429 id="rkey" 430 type="text" 431 bind:value={newRkey} 432 placeholder="Auto-generated if empty (TID)" 433 disabled={saving} 434 /> 435 <p class="hint">Leave empty to auto-generate a TID-based key</p> 436 </div> 437 <div class="editor-container"> 438 <label for="new-record-json">Record JSON</label> 439 <textarea 440 id="new-record-json" 441 bind:value={recordJson} 442 oninput={() => validateJson()} 443 class:has-error={jsonError} 444 spellcheck="false" 445 ></textarea> 446 {#if jsonError} 447 <p class="json-error">{jsonError}</p> 448 {/if} 449 </div> 450 <div class="actions"> 451 <button type="submit" class="primary" disabled={saving || !!jsonError || !newCollection.trim()}> 452 {saving ? 'Creating...' : 'Create Record'} 453 </button> 454 <button type="button" class="secondary" onclick={goBack}> 455 Cancel 456 </button> 457 </div> 458 </form> 459 {/if} 460</div> 461<style> 462 .page { 463 max-width: 900px; 464 margin: 0 auto; 465 padding: 2rem; 466 } 467 header { 468 margin-bottom: 1.5rem; 469 } 470 .breadcrumb { 471 display: flex; 472 align-items: center; 473 gap: 0.5rem; 474 font-size: 0.875rem; 475 margin-bottom: 0.5rem; 476 } 477 .back { 478 color: var(--text-secondary); 479 text-decoration: none; 480 } 481 .back:hover { 482 color: var(--accent); 483 } 484 .sep { 485 color: var(--text-muted); 486 } 487 .breadcrumb-link { 488 background: none; 489 border: none; 490 padding: 0; 491 color: var(--accent); 492 cursor: pointer; 493 font-size: inherit; 494 } 495 .breadcrumb-link:hover { 496 text-decoration: underline; 497 } 498 .current { 499 color: var(--text-secondary); 500 } 501 h1 { 502 margin: 0; 503 font-size: 1.5rem; 504 } 505 .did { 506 margin: 0.25rem 0 0 0; 507 font-family: monospace; 508 font-size: 0.75rem; 509 color: var(--text-muted); 510 word-break: break-all; 511 } 512 .message { 513 padding: 1rem; 514 border-radius: 8px; 515 margin-bottom: 1rem; 516 } 517 .message.error { 518 background: var(--error-bg); 519 border: 1px solid var(--error-border); 520 color: var(--error-text); 521 display: flex; 522 flex-direction: column; 523 gap: 0.25rem; 524 } 525 .error-code { 526 font-family: monospace; 527 font-size: 0.875rem; 528 opacity: 0.9; 529 } 530 .error-message { 531 font-size: 0.9375rem; 532 line-height: 1.5; 533 } 534 .message.success { 535 background: var(--success-bg); 536 border: 1px solid var(--success-border); 537 color: var(--success-text); 538 } 539 .loading-text { 540 text-align: center; 541 color: var(--text-secondary); 542 padding: 2rem; 543 } 544 .toolbar { 545 display: flex; 546 gap: 0.5rem; 547 margin-bottom: 1rem; 548 } 549 .filter-input { 550 flex: 1; 551 padding: 0.5rem 0.75rem; 552 border: 1px solid var(--border-color-light); 553 border-radius: 4px; 554 font-size: 0.875rem; 555 background: var(--bg-input); 556 color: var(--text-primary); 557 } 558 .filter-input:focus { 559 outline: none; 560 border-color: var(--accent); 561 } 562 button.primary { 563 padding: 0.5rem 1rem; 564 background: var(--accent); 565 color: white; 566 border: none; 567 border-radius: 4px; 568 cursor: pointer; 569 font-size: 0.875rem; 570 } 571 button.primary:hover:not(:disabled) { 572 background: var(--accent-hover); 573 } 574 button.primary:disabled { 575 opacity: 0.6; 576 cursor: not-allowed; 577 } 578 button.secondary { 579 padding: 0.5rem 1rem; 580 background: transparent; 581 color: var(--text-secondary); 582 border: 1px solid var(--border-color-light); 583 border-radius: 4px; 584 cursor: pointer; 585 font-size: 0.875rem; 586 } 587 button.secondary:hover:not(:disabled) { 588 background: var(--bg-secondary); 589 } 590 button.danger { 591 padding: 0.5rem 1rem; 592 background: transparent; 593 color: var(--error-text); 594 border: 1px solid var(--error-text); 595 border-radius: 4px; 596 cursor: pointer; 597 font-size: 0.875rem; 598 } 599 button.danger:hover:not(:disabled) { 600 background: var(--error-bg); 601 } 602 .empty { 603 text-align: center; 604 color: var(--text-secondary); 605 padding: 3rem; 606 background: var(--bg-secondary); 607 border-radius: 8px; 608 } 609 .collections { 610 display: flex; 611 flex-direction: column; 612 gap: 1rem; 613 } 614 .collection-group { 615 background: var(--bg-secondary); 616 border-radius: 8px; 617 padding: 1rem; 618 } 619 .authority { 620 margin: 0 0 0.75rem 0; 621 font-size: 0.875rem; 622 color: var(--text-secondary); 623 font-weight: 500; 624 } 625 .nsid-list { 626 list-style: none; 627 padding: 0; 628 margin: 0; 629 display: flex; 630 flex-direction: column; 631 gap: 0.25rem; 632 } 633 .collection-link { 634 display: flex; 635 justify-content: space-between; 636 align-items: center; 637 width: 100%; 638 padding: 0.75rem; 639 background: var(--bg-card); 640 border: 1px solid var(--border-color); 641 border-radius: 4px; 642 cursor: pointer; 643 text-align: left; 644 color: var(--text-primary); 645 transition: border-color 0.15s; 646 } 647 .collection-link:hover { 648 border-color: var(--accent); 649 } 650 .nsid { 651 font-weight: 500; 652 color: var(--accent); 653 } 654 .arrow { 655 color: var(--text-muted); 656 } 657 .record-list { 658 list-style: none; 659 padding: 0; 660 margin: 0; 661 display: flex; 662 flex-direction: column; 663 gap: 0.5rem; 664 } 665 .record-item { 666 display: block; 667 width: 100%; 668 padding: 1rem; 669 background: var(--bg-card); 670 border: 1px solid var(--border-color); 671 border-radius: 4px; 672 cursor: pointer; 673 text-align: left; 674 color: var(--text-primary); 675 transition: border-color 0.15s; 676 } 677 .record-item:hover { 678 border-color: var(--accent); 679 } 680 .record-info { 681 display: flex; 682 justify-content: space-between; 683 margin-bottom: 0.5rem; 684 } 685 .rkey { 686 font-family: monospace; 687 font-weight: 500; 688 color: var(--accent); 689 } 690 .cid { 691 font-family: monospace; 692 font-size: 0.75rem; 693 color: var(--text-muted); 694 } 695 .record-preview { 696 margin: 0; 697 padding: 0.5rem; 698 background: var(--bg-secondary); 699 border-radius: 4px; 700 font-family: monospace; 701 font-size: 0.75rem; 702 color: var(--text-secondary); 703 white-space: pre-wrap; 704 word-break: break-word; 705 max-height: 100px; 706 overflow: hidden; 707 } 708 .load-more { 709 text-align: center; 710 padding: 1rem; 711 } 712 .load-more button { 713 padding: 0.5rem 2rem; 714 background: var(--bg-secondary); 715 border: 1px solid var(--border-color); 716 border-radius: 4px; 717 cursor: pointer; 718 color: var(--text-primary); 719 } 720 .load-more button:hover:not(:disabled) { 721 background: var(--bg-card); 722 } 723 .record-detail { 724 display: flex; 725 flex-direction: column; 726 gap: 1.5rem; 727 } 728 .record-meta { 729 background: var(--bg-secondary); 730 padding: 1rem; 731 border-radius: 8px; 732 } 733 .record-meta dl { 734 display: grid; 735 grid-template-columns: auto 1fr; 736 gap: 0.5rem 1rem; 737 margin: 0; 738 } 739 .record-meta dt { 740 font-weight: 500; 741 color: var(--text-secondary); 742 } 743 .record-meta dd { 744 margin: 0; 745 } 746 .mono { 747 font-family: monospace; 748 font-size: 0.75rem; 749 word-break: break-all; 750 } 751 .field { 752 margin-bottom: 1rem; 753 } 754 .field label { 755 display: block; 756 font-size: 0.875rem; 757 font-weight: 500; 758 margin-bottom: 0.25rem; 759 } 760 .field input { 761 width: 100%; 762 padding: 0.75rem; 763 border: 1px solid var(--border-color-light); 764 border-radius: 4px; 765 font-size: 1rem; 766 background: var(--bg-input); 767 color: var(--text-primary); 768 box-sizing: border-box; 769 } 770 .field input:focus { 771 outline: none; 772 border-color: var(--accent); 773 } 774 .hint { 775 font-size: 0.75rem; 776 color: var(--text-muted); 777 margin: 0.25rem 0 0 0; 778 } 779 .editor-container { 780 margin-bottom: 1rem; 781 } 782 .editor-container label { 783 display: block; 784 font-size: 0.875rem; 785 font-weight: 500; 786 margin-bottom: 0.25rem; 787 } 788 textarea { 789 width: 100%; 790 min-height: 300px; 791 padding: 1rem; 792 border: 1px solid var(--border-color-light); 793 border-radius: 4px; 794 font-family: monospace; 795 font-size: 0.875rem; 796 background: var(--bg-input); 797 color: var(--text-primary); 798 resize: vertical; 799 box-sizing: border-box; 800 } 801 textarea:focus { 802 outline: none; 803 border-color: var(--accent); 804 } 805 textarea.has-error { 806 border-color: var(--error-text); 807 } 808 .json-error { 809 margin: 0.25rem 0 0 0; 810 font-size: 0.75rem; 811 color: var(--error-text); 812 } 813 .actions { 814 display: flex; 815 gap: 0.5rem; 816 } 817 .create-form { 818 background: var(--bg-secondary); 819 padding: 1.5rem; 820 border-radius: 8px; 821 } 822</style>