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