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