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