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