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