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 .toolbar {
603 display: flex;
604 gap: var(--space-2);
605 margin-bottom: var(--space-4);
606 }
607
608 .filter-input {
609 flex: 1;
610 padding: var(--space-2) var(--space-3);
611 border: 1px solid var(--border-color);
612 border-radius: var(--radius-md);
613 font-size: var(--text-sm);
614 background: var(--bg-input);
615 color: var(--text-primary);
616 }
617
618 .filter-input:focus {
619 outline: none;
620 border-color: var(--accent);
621 }
622
623 button.primary {
624 padding: var(--space-2) var(--space-4);
625 background: var(--accent);
626 color: var(--text-inverse);
627 border: none;
628 border-radius: var(--radius-md);
629 cursor: pointer;
630 font-size: var(--text-sm);
631 }
632
633 button.primary:hover:not(:disabled) {
634 background: var(--accent-hover);
635 }
636
637 button.primary:disabled {
638 opacity: 0.6;
639 cursor: not-allowed;
640 }
641
642 button.secondary {
643 padding: var(--space-2) var(--space-4);
644 background: transparent;
645 color: var(--text-secondary);
646 border: 1px solid var(--border-color);
647 border-radius: var(--radius-md);
648 cursor: pointer;
649 font-size: var(--text-sm);
650 }
651
652 button.secondary:hover:not(:disabled) {
653 background: var(--bg-secondary);
654 }
655
656 button.danger {
657 padding: var(--space-2) var(--space-4);
658 background: transparent;
659 color: var(--error-text);
660 border: 1px solid var(--error-text);
661 border-radius: var(--radius-md);
662 cursor: pointer;
663 font-size: var(--text-sm);
664 }
665
666 button.danger:hover:not(:disabled) {
667 background: var(--error-bg);
668 }
669
670 .empty {
671 text-align: center;
672 color: var(--text-secondary);
673 padding: var(--space-8);
674 background: var(--bg-secondary);
675 border-radius: var(--radius-xl);
676 }
677
678 .collections {
679 display: flex;
680 flex-direction: column;
681 gap: var(--space-4);
682 }
683
684 .collection-group {
685 background: var(--bg-secondary);
686 border-radius: var(--radius-xl);
687 padding: var(--space-4);
688 }
689
690 .authority {
691 margin: 0 0 var(--space-3) 0;
692 font-size: var(--text-sm);
693 color: var(--text-secondary);
694 font-weight: var(--font-medium);
695 }
696
697 .nsid-list {
698 list-style: none;
699 padding: 0;
700 margin: 0;
701 display: flex;
702 flex-direction: column;
703 gap: var(--space-1);
704 }
705
706 .collection-link {
707 display: flex;
708 justify-content: space-between;
709 align-items: center;
710 width: 100%;
711 padding: var(--space-3);
712 background: var(--bg-primary);
713 border: 1px solid var(--border-color);
714 border-radius: var(--radius-md);
715 cursor: pointer;
716 text-align: left;
717 color: var(--text-primary);
718 transition: background var(--transition-fast), border-color var(--transition-fast);
719 }
720
721 .collection-link:hover {
722 background: var(--bg-secondary);
723 border-color: var(--accent);
724 }
725
726 .collection-link:focus {
727 outline: 2px solid var(--accent);
728 outline-offset: 2px;
729 }
730
731 .collection-link:active {
732 background: var(--bg-tertiary);
733 }
734
735 .nsid {
736 font-weight: var(--font-medium);
737 color: var(--accent);
738 }
739
740 .arrow {
741 color: var(--text-muted);
742 }
743
744 .collection-link:hover .arrow {
745 color: var(--accent);
746 }
747
748 .record-list {
749 list-style: none;
750 padding: 0;
751 margin: 0;
752 display: flex;
753 flex-direction: column;
754 gap: var(--space-2);
755 }
756
757 .record-item {
758 display: block;
759 width: 100%;
760 padding: var(--space-4);
761 background: var(--bg-primary);
762 border: 1px solid var(--border-color);
763 border-radius: var(--radius-md);
764 cursor: pointer;
765 text-align: left;
766 color: var(--text-primary);
767 transition: background var(--transition-fast), border-color var(--transition-fast);
768 }
769
770 .record-item:hover {
771 background: var(--bg-secondary);
772 border-color: var(--accent);
773 }
774
775 .record-item:focus {
776 outline: 2px solid var(--accent);
777 outline-offset: 2px;
778 }
779
780 .record-item:active {
781 background: var(--bg-tertiary);
782 }
783
784 .record-info {
785 display: flex;
786 justify-content: space-between;
787 margin-bottom: var(--space-2);
788 }
789
790 .rkey {
791 font-family: monospace;
792 font-weight: var(--font-medium);
793 color: var(--accent);
794 }
795
796 .cid {
797 font-family: monospace;
798 font-size: var(--text-xs);
799 color: var(--text-muted);
800 }
801
802 .record-preview {
803 margin: 0;
804 padding: var(--space-2);
805 background: var(--bg-secondary);
806 border-radius: var(--radius-md);
807 font-family: monospace;
808 font-size: var(--text-xs);
809 color: var(--text-secondary);
810 white-space: pre-wrap;
811 word-break: break-word;
812 max-height: 100px;
813 overflow: hidden;
814 }
815
816 .skeleton-records {
817 display: flex;
818 flex-direction: column;
819 gap: var(--space-2);
820 margin-top: var(--space-2);
821 }
822
823 .skeleton-record {
824 padding: var(--space-4);
825 background: var(--bg-card);
826 border: 1px solid var(--border-color);
827 border-radius: var(--radius-md);
828 }
829
830 .skeleton-record-header {
831 display: flex;
832 justify-content: space-between;
833 margin-bottom: var(--space-2);
834 }
835
836 .skeleton-line {
837 height: 14px;
838 background: var(--bg-tertiary);
839 border-radius: var(--radius-sm);
840 animation: skeleton-pulse 1.5s ease-in-out infinite;
841 }
842
843 .skeleton-line.short {
844 width: 120px;
845 }
846
847 .skeleton-line.tiny {
848 width: 80px;
849 }
850
851 .skeleton-preview {
852 height: 60px;
853 background: var(--bg-secondary);
854 border-radius: var(--radius-md);
855 animation: skeleton-pulse 1.5s ease-in-out infinite;
856 }
857
858 @keyframes skeleton-pulse {
859 0%, 100% { opacity: 1; }
860 50% { opacity: 0.4; }
861 }
862
863 .record-detail {
864 display: flex;
865 flex-direction: column;
866 gap: var(--space-6);
867 }
868
869 .record-meta {
870 background: var(--bg-secondary);
871 padding: var(--space-4);
872 border-radius: var(--radius-xl);
873 }
874
875 .record-meta dl {
876 display: grid;
877 grid-template-columns: auto 1fr;
878 gap: var(--space-2) var(--space-4);
879 margin: 0;
880 }
881
882 .record-meta dt {
883 font-weight: var(--font-medium);
884 color: var(--text-secondary);
885 }
886
887 .record-meta dd {
888 margin: 0;
889 }
890
891 .mono {
892 font-family: monospace;
893 font-size: var(--text-xs);
894 word-break: break-all;
895 }
896
897 .field {
898 margin-bottom: var(--space-4);
899 }
900
901 .field label {
902 display: block;
903 font-size: var(--text-sm);
904 font-weight: var(--font-medium);
905 margin-bottom: var(--space-1);
906 }
907
908 .field input {
909 width: 100%;
910 padding: var(--space-3);
911 border: 1px solid var(--border-color);
912 border-radius: var(--radius-md);
913 font-size: var(--text-base);
914 background: var(--bg-input);
915 color: var(--text-primary);
916 box-sizing: border-box;
917 }
918
919 .field input:focus {
920 outline: none;
921 border-color: var(--accent);
922 }
923
924 .hint {
925 font-size: var(--text-xs);
926 color: var(--text-muted);
927 margin: var(--space-1) 0 0 0;
928 }
929
930 .editor-container {
931 margin-bottom: var(--space-4);
932 }
933
934 .editor-container label {
935 display: block;
936 font-size: var(--text-sm);
937 font-weight: var(--font-medium);
938 margin-bottom: var(--space-1);
939 }
940
941 textarea {
942 width: 100%;
943 min-height: 300px;
944 padding: var(--space-4);
945 border: 1px solid var(--border-color);
946 border-radius: var(--radius-md);
947 font-family: monospace;
948 font-size: var(--text-sm);
949 background: var(--bg-input);
950 color: var(--text-primary);
951 resize: vertical;
952 box-sizing: border-box;
953 }
954
955 textarea:focus {
956 outline: none;
957 border-color: var(--accent);
958 }
959
960 textarea.has-error {
961 border-color: var(--error-text);
962 }
963
964 .json-error {
965 margin: var(--space-1) 0 0 0;
966 font-size: var(--text-xs);
967 color: var(--error-text);
968 }
969
970 .actions {
971 display: flex;
972 gap: var(--space-2);
973 }
974
975 .create-form {
976 background: var(--bg-secondary);
977 padding: var(--space-6);
978 border-radius: var(--radius-xl);
979 }
980
981 .page ::selection {
982 background: var(--accent);
983 color: var(--text-inverse);
984 }
985
986 .page ::-moz-selection {
987 background: var(--accent);
988 color: var(--text-inverse);
989 }
990
991 .skeleton-list {
992 display: flex;
993 flex-direction: column;
994 gap: var(--space-2);
995 }
996
997 .skeleton-row {
998 height: 44px;
999 background: var(--bg-secondary);
1000 border-radius: var(--radius-md);
1001 animation: skeleton-pulse 1.5s ease-in-out infinite;
1002 }
1003
1004 @keyframes skeleton-pulse {
1005 0%, 100% { opacity: 1; }
1006 50% { opacity: 0.5; }
1007 }
1008</style>