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