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 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 .loading-text { 603 text-align: center; 604 color: var(--text-secondary); 605 padding: var(--space-7); 606 } 607 608 .toolbar { 609 display: flex; 610 gap: var(--space-2); 611 margin-bottom: var(--space-4); 612 } 613 614 .filter-input { 615 flex: 1; 616 padding: var(--space-2) var(--space-3); 617 border: 1px solid var(--border-color); 618 border-radius: var(--radius-md); 619 font-size: var(--text-sm); 620 background: var(--bg-input); 621 color: var(--text-primary); 622 } 623 624 .filter-input:focus { 625 outline: none; 626 border-color: var(--accent); 627 } 628 629 button.primary { 630 padding: var(--space-2) var(--space-4); 631 background: var(--accent); 632 color: var(--text-inverse); 633 border: none; 634 border-radius: var(--radius-md); 635 cursor: pointer; 636 font-size: var(--text-sm); 637 } 638 639 button.primary:hover:not(:disabled) { 640 background: var(--accent-hover); 641 } 642 643 button.primary:disabled { 644 opacity: 0.6; 645 cursor: not-allowed; 646 } 647 648 button.secondary { 649 padding: var(--space-2) var(--space-4); 650 background: transparent; 651 color: var(--text-secondary); 652 border: 1px solid var(--border-color); 653 border-radius: var(--radius-md); 654 cursor: pointer; 655 font-size: var(--text-sm); 656 } 657 658 button.secondary:hover:not(:disabled) { 659 background: var(--bg-secondary); 660 } 661 662 button.danger { 663 padding: var(--space-2) var(--space-4); 664 background: transparent; 665 color: var(--error-text); 666 border: 1px solid var(--error-text); 667 border-radius: var(--radius-md); 668 cursor: pointer; 669 font-size: var(--text-sm); 670 } 671 672 button.danger:hover:not(:disabled) { 673 background: var(--error-bg); 674 } 675 676 .empty { 677 text-align: center; 678 color: var(--text-secondary); 679 padding: var(--space-8); 680 background: var(--bg-secondary); 681 border-radius: var(--radius-xl); 682 } 683 684 .collections { 685 display: flex; 686 flex-direction: column; 687 gap: var(--space-4); 688 } 689 690 .collection-group { 691 background: var(--bg-secondary); 692 border-radius: var(--radius-xl); 693 padding: var(--space-4); 694 } 695 696 .authority { 697 margin: 0 0 var(--space-3) 0; 698 font-size: var(--text-sm); 699 color: var(--text-secondary); 700 font-weight: var(--font-medium); 701 } 702 703 .nsid-list { 704 list-style: none; 705 padding: 0; 706 margin: 0; 707 display: flex; 708 flex-direction: column; 709 gap: var(--space-1); 710 } 711 712 .collection-link { 713 display: flex; 714 justify-content: space-between; 715 align-items: center; 716 width: 100%; 717 padding: var(--space-3); 718 background: var(--bg-primary); 719 border: 1px solid var(--border-color); 720 border-radius: var(--radius-md); 721 cursor: pointer; 722 text-align: left; 723 color: var(--text-primary); 724 transition: background var(--transition-fast), border-color var(--transition-fast); 725 } 726 727 .collection-link:hover { 728 background: var(--bg-secondary); 729 border-color: var(--accent); 730 } 731 732 .collection-link:focus { 733 outline: 2px solid var(--accent); 734 outline-offset: 2px; 735 } 736 737 .collection-link:active { 738 background: var(--bg-tertiary); 739 } 740 741 .nsid { 742 font-weight: var(--font-medium); 743 color: var(--accent); 744 } 745 746 .arrow { 747 color: var(--text-muted); 748 } 749 750 .collection-link:hover .arrow { 751 color: var(--accent); 752 } 753 754 .record-list { 755 list-style: none; 756 padding: 0; 757 margin: 0; 758 display: flex; 759 flex-direction: column; 760 gap: var(--space-2); 761 } 762 763 .record-item { 764 display: block; 765 width: 100%; 766 padding: var(--space-4); 767 background: var(--bg-primary); 768 border: 1px solid var(--border-color); 769 border-radius: var(--radius-md); 770 cursor: pointer; 771 text-align: left; 772 color: var(--text-primary); 773 transition: background var(--transition-fast), border-color var(--transition-fast); 774 } 775 776 .record-item:hover { 777 background: var(--bg-secondary); 778 border-color: var(--accent); 779 } 780 781 .record-item:focus { 782 outline: 2px solid var(--accent); 783 outline-offset: 2px; 784 } 785 786 .record-item:active { 787 background: var(--bg-tertiary); 788 } 789 790 .record-info { 791 display: flex; 792 justify-content: space-between; 793 margin-bottom: var(--space-2); 794 } 795 796 .rkey { 797 font-family: monospace; 798 font-weight: var(--font-medium); 799 color: var(--accent); 800 } 801 802 .cid { 803 font-family: monospace; 804 font-size: var(--text-xs); 805 color: var(--text-muted); 806 } 807 808 .record-preview { 809 margin: 0; 810 padding: var(--space-2); 811 background: var(--bg-secondary); 812 border-radius: var(--radius-md); 813 font-family: monospace; 814 font-size: var(--text-xs); 815 color: var(--text-secondary); 816 white-space: pre-wrap; 817 word-break: break-word; 818 max-height: 100px; 819 overflow: hidden; 820 } 821 822 .skeleton-records { 823 display: flex; 824 flex-direction: column; 825 gap: var(--space-2); 826 margin-top: var(--space-2); 827 } 828 829 .skeleton-record { 830 padding: var(--space-4); 831 background: var(--bg-card); 832 border: 1px solid var(--border-color); 833 border-radius: var(--radius-md); 834 } 835 836 .skeleton-record-header { 837 display: flex; 838 justify-content: space-between; 839 margin-bottom: var(--space-2); 840 } 841 842 .skeleton-line { 843 height: 14px; 844 background: var(--bg-tertiary); 845 border-radius: var(--radius-sm); 846 animation: skeleton-pulse 1.5s ease-in-out infinite; 847 } 848 849 .skeleton-line.short { 850 width: 120px; 851 } 852 853 .skeleton-line.tiny { 854 width: 80px; 855 } 856 857 .skeleton-preview { 858 height: 60px; 859 background: var(--bg-secondary); 860 border-radius: var(--radius-md); 861 animation: skeleton-pulse 1.5s ease-in-out infinite; 862 } 863 864 @keyframes skeleton-pulse { 865 0%, 100% { opacity: 1; } 866 50% { opacity: 0.4; } 867 } 868 869 .record-detail { 870 display: flex; 871 flex-direction: column; 872 gap: var(--space-6); 873 } 874 875 .record-meta { 876 background: var(--bg-secondary); 877 padding: var(--space-4); 878 border-radius: var(--radius-xl); 879 } 880 881 .record-meta dl { 882 display: grid; 883 grid-template-columns: auto 1fr; 884 gap: var(--space-2) var(--space-4); 885 margin: 0; 886 } 887 888 .record-meta dt { 889 font-weight: var(--font-medium); 890 color: var(--text-secondary); 891 } 892 893 .record-meta dd { 894 margin: 0; 895 } 896 897 .mono { 898 font-family: monospace; 899 font-size: var(--text-xs); 900 word-break: break-all; 901 } 902 903 .field { 904 margin-bottom: var(--space-4); 905 } 906 907 .field label { 908 display: block; 909 font-size: var(--text-sm); 910 font-weight: var(--font-medium); 911 margin-bottom: var(--space-1); 912 } 913 914 .field input { 915 width: 100%; 916 padding: var(--space-3); 917 border: 1px solid var(--border-color); 918 border-radius: var(--radius-md); 919 font-size: var(--text-base); 920 background: var(--bg-input); 921 color: var(--text-primary); 922 box-sizing: border-box; 923 } 924 925 .field input:focus { 926 outline: none; 927 border-color: var(--accent); 928 } 929 930 .hint { 931 font-size: var(--text-xs); 932 color: var(--text-muted); 933 margin: var(--space-1) 0 0 0; 934 } 935 936 .editor-container { 937 margin-bottom: var(--space-4); 938 } 939 940 .editor-container label { 941 display: block; 942 font-size: var(--text-sm); 943 font-weight: var(--font-medium); 944 margin-bottom: var(--space-1); 945 } 946 947 textarea { 948 width: 100%; 949 min-height: 300px; 950 padding: var(--space-4); 951 border: 1px solid var(--border-color); 952 border-radius: var(--radius-md); 953 font-family: monospace; 954 font-size: var(--text-sm); 955 background: var(--bg-input); 956 color: var(--text-primary); 957 resize: vertical; 958 box-sizing: border-box; 959 } 960 961 textarea:focus { 962 outline: none; 963 border-color: var(--accent); 964 } 965 966 textarea.has-error { 967 border-color: var(--error-text); 968 } 969 970 .json-error { 971 margin: var(--space-1) 0 0 0; 972 font-size: var(--text-xs); 973 color: var(--error-text); 974 } 975 976 .actions { 977 display: flex; 978 gap: var(--space-2); 979 } 980 981 .create-form { 982 background: var(--bg-secondary); 983 padding: var(--space-6); 984 border-radius: var(--radius-xl); 985 } 986 987 .page ::selection { 988 background: var(--accent); 989 color: var(--text-inverse); 990 } 991 992 .page ::-moz-selection { 993 background: var(--accent); 994 color: var(--text-inverse); 995 } 996 997 .skeleton-list { 998 display: flex; 999 flex-direction: column; 1000 gap: var(--space-2); 1001 } 1002 1003 .skeleton-row { 1004 height: 44px; 1005 background: var(--bg-secondary); 1006 border-radius: var(--radius-md); 1007 animation: skeleton-pulse 1.5s ease-in-out infinite; 1008 } 1009 1010 @keyframes skeleton-pulse { 1011 0%, 100% { opacity: 1; } 1012 50% { opacity: 0.5; } 1013 } 1014</style>