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">→</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>