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