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