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 ? $_('repoExplorer.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 ? $_('repoExplorer.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 } 499 500 .back:hover { 501 color: var(--accent); 502 } 503 504 .sep { 505 color: var(--text-muted); 506 } 507 508 .breadcrumb-link { 509 background: none; 510 border: none; 511 padding: 0; 512 color: var(--accent); 513 cursor: pointer; 514 font-size: inherit; 515 } 516 517 .breadcrumb-link:hover { 518 text-decoration: underline; 519 } 520 521 .current { 522 color: var(--text-secondary); 523 } 524 525 h1 { 526 margin: 0; 527 font-size: var(--text-xl); 528 } 529 530 .did { 531 margin: var(--space-1) 0 0 0; 532 font-family: monospace; 533 font-size: var(--text-xs); 534 color: var(--text-muted); 535 word-break: break-all; 536 } 537 538 .message { 539 padding: var(--space-4); 540 border-radius: var(--radius-xl); 541 margin-bottom: var(--space-4); 542 } 543 544 .message.error { 545 background: var(--error-bg); 546 border: 1px solid var(--error-border); 547 color: var(--error-text); 548 display: flex; 549 flex-direction: column; 550 gap: var(--space-1); 551 } 552 553 .error-code { 554 font-family: monospace; 555 font-size: var(--text-sm); 556 opacity: 0.9; 557 } 558 559 .error-message { 560 font-size: var(--text-sm); 561 line-height: 1.5; 562 } 563 564 .message.success { 565 background: var(--success-bg); 566 border: 1px solid var(--success-border); 567 color: var(--success-text); 568 } 569 570 .loading-text { 571 text-align: center; 572 color: var(--text-secondary); 573 padding: var(--space-7); 574 } 575 576 .toolbar { 577 display: flex; 578 gap: var(--space-2); 579 margin-bottom: var(--space-4); 580 } 581 582 .filter-input { 583 flex: 1; 584 padding: var(--space-2) var(--space-3); 585 border: 1px solid var(--border-color); 586 border-radius: var(--radius-md); 587 font-size: var(--text-sm); 588 background: var(--bg-input); 589 color: var(--text-primary); 590 } 591 592 .filter-input:focus { 593 outline: none; 594 border-color: var(--accent); 595 } 596 597 button.primary { 598 padding: var(--space-2) var(--space-4); 599 background: var(--accent); 600 color: var(--text-inverse); 601 border: none; 602 border-radius: var(--radius-md); 603 cursor: pointer; 604 font-size: var(--text-sm); 605 } 606 607 button.primary:hover:not(:disabled) { 608 background: var(--accent-hover); 609 } 610 611 button.primary:disabled { 612 opacity: 0.6; 613 cursor: not-allowed; 614 } 615 616 button.secondary { 617 padding: var(--space-2) var(--space-4); 618 background: transparent; 619 color: var(--text-secondary); 620 border: 1px solid var(--border-color); 621 border-radius: var(--radius-md); 622 cursor: pointer; 623 font-size: var(--text-sm); 624 } 625 626 button.secondary:hover:not(:disabled) { 627 background: var(--bg-secondary); 628 } 629 630 button.danger { 631 padding: var(--space-2) var(--space-4); 632 background: transparent; 633 color: var(--error-text); 634 border: 1px solid var(--error-text); 635 border-radius: var(--radius-md); 636 cursor: pointer; 637 font-size: var(--text-sm); 638 } 639 640 button.danger:hover:not(:disabled) { 641 background: var(--error-bg); 642 } 643 644 .empty { 645 text-align: center; 646 color: var(--text-secondary); 647 padding: var(--space-8); 648 background: var(--bg-secondary); 649 border-radius: var(--radius-xl); 650 } 651 652 .collections { 653 display: flex; 654 flex-direction: column; 655 gap: var(--space-4); 656 } 657 658 .collection-group { 659 background: var(--bg-secondary); 660 border-radius: var(--radius-xl); 661 padding: var(--space-4); 662 } 663 664 .authority { 665 margin: 0 0 var(--space-3) 0; 666 font-size: var(--text-sm); 667 color: var(--text-secondary); 668 font-weight: var(--font-medium); 669 } 670 671 .nsid-list { 672 list-style: none; 673 padding: 0; 674 margin: 0; 675 display: flex; 676 flex-direction: column; 677 gap: var(--space-1); 678 } 679 680 .collection-link { 681 display: flex; 682 justify-content: space-between; 683 align-items: center; 684 width: 100%; 685 padding: var(--space-3); 686 background: var(--bg-card); 687 border: 1px solid var(--border-color); 688 border-radius: var(--radius-md); 689 cursor: pointer; 690 text-align: left; 691 color: var(--text-primary); 692 transition: border-color var(--transition-fast); 693 } 694 695 .collection-link:hover { 696 border-color: var(--accent); 697 } 698 699 .nsid { 700 font-weight: var(--font-medium); 701 color: var(--accent); 702 } 703 704 .arrow { 705 color: var(--text-muted); 706 } 707 708 .record-list { 709 list-style: none; 710 padding: 0; 711 margin: 0; 712 display: flex; 713 flex-direction: column; 714 gap: var(--space-2); 715 } 716 717 .record-item { 718 display: block; 719 width: 100%; 720 padding: var(--space-4); 721 background: var(--bg-card); 722 border: 1px solid var(--border-color); 723 border-radius: var(--radius-md); 724 cursor: pointer; 725 text-align: left; 726 color: var(--text-primary); 727 transition: border-color var(--transition-fast); 728 } 729 730 .record-item:hover { 731 border-color: var(--accent); 732 } 733 734 .record-info { 735 display: flex; 736 justify-content: space-between; 737 margin-bottom: var(--space-2); 738 } 739 740 .rkey { 741 font-family: monospace; 742 font-weight: var(--font-medium); 743 color: var(--accent); 744 } 745 746 .cid { 747 font-family: monospace; 748 font-size: var(--text-xs); 749 color: var(--text-muted); 750 } 751 752 .record-preview { 753 margin: 0; 754 padding: var(--space-2); 755 background: var(--bg-secondary); 756 border-radius: var(--radius-md); 757 font-family: monospace; 758 font-size: var(--text-xs); 759 color: var(--text-secondary); 760 white-space: pre-wrap; 761 word-break: break-word; 762 max-height: 100px; 763 overflow: hidden; 764 } 765 766 .skeleton-records { 767 display: flex; 768 flex-direction: column; 769 gap: var(--space-2); 770 margin-top: var(--space-2); 771 } 772 773 .skeleton-record { 774 padding: var(--space-4); 775 background: var(--bg-card); 776 border: 1px solid var(--border-color); 777 border-radius: var(--radius-md); 778 } 779 780 .skeleton-record-header { 781 display: flex; 782 justify-content: space-between; 783 margin-bottom: var(--space-2); 784 } 785 786 .skeleton-line { 787 height: 14px; 788 background: var(--bg-tertiary); 789 border-radius: var(--radius-sm); 790 animation: skeleton-pulse 1.5s ease-in-out infinite; 791 } 792 793 .skeleton-line.short { 794 width: 120px; 795 } 796 797 .skeleton-line.tiny { 798 width: 80px; 799 } 800 801 .skeleton-preview { 802 height: 60px; 803 background: var(--bg-secondary); 804 border-radius: var(--radius-md); 805 animation: skeleton-pulse 1.5s ease-in-out infinite; 806 } 807 808 @keyframes skeleton-pulse { 809 0%, 100% { opacity: 1; } 810 50% { opacity: 0.4; } 811 } 812 813 .record-detail { 814 display: flex; 815 flex-direction: column; 816 gap: var(--space-6); 817 } 818 819 .record-meta { 820 background: var(--bg-secondary); 821 padding: var(--space-4); 822 border-radius: var(--radius-xl); 823 } 824 825 .record-meta dl { 826 display: grid; 827 grid-template-columns: auto 1fr; 828 gap: var(--space-2) var(--space-4); 829 margin: 0; 830 } 831 832 .record-meta dt { 833 font-weight: var(--font-medium); 834 color: var(--text-secondary); 835 } 836 837 .record-meta dd { 838 margin: 0; 839 } 840 841 .mono { 842 font-family: monospace; 843 font-size: var(--text-xs); 844 word-break: break-all; 845 } 846 847 .field { 848 margin-bottom: var(--space-4); 849 } 850 851 .field label { 852 display: block; 853 font-size: var(--text-sm); 854 font-weight: var(--font-medium); 855 margin-bottom: var(--space-1); 856 } 857 858 .field input { 859 width: 100%; 860 padding: var(--space-3); 861 border: 1px solid var(--border-color); 862 border-radius: var(--radius-md); 863 font-size: var(--text-base); 864 background: var(--bg-input); 865 color: var(--text-primary); 866 box-sizing: border-box; 867 } 868 869 .field input:focus { 870 outline: none; 871 border-color: var(--accent); 872 } 873 874 .hint { 875 font-size: var(--text-xs); 876 color: var(--text-muted); 877 margin: var(--space-1) 0 0 0; 878 } 879 880 .editor-container { 881 margin-bottom: var(--space-4); 882 } 883 884 .editor-container label { 885 display: block; 886 font-size: var(--text-sm); 887 font-weight: var(--font-medium); 888 margin-bottom: var(--space-1); 889 } 890 891 textarea { 892 width: 100%; 893 min-height: 300px; 894 padding: var(--space-4); 895 border: 1px solid var(--border-color); 896 border-radius: var(--radius-md); 897 font-family: monospace; 898 font-size: var(--text-sm); 899 background: var(--bg-input); 900 color: var(--text-primary); 901 resize: vertical; 902 box-sizing: border-box; 903 } 904 905 textarea:focus { 906 outline: none; 907 border-color: var(--accent); 908 } 909 910 textarea.has-error { 911 border-color: var(--error-text); 912 } 913 914 .json-error { 915 margin: var(--space-1) 0 0 0; 916 font-size: var(--text-xs); 917 color: var(--error-text); 918 } 919 920 .actions { 921 display: flex; 922 gap: var(--space-2); 923 } 924 925 .create-form { 926 background: var(--bg-secondary); 927 padding: var(--space-6); 928 border-radius: var(--radius-xl); 929 } 930</style>