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