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