@zooid/web 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/assets/index-7P1i28a8.js +66 -0
  2. package/dist/assets/index-BeqmFlCW.css +1 -0
  3. package/dist/assets/json-editor-Cvlnnf1Q.css +1 -0
  4. package/dist/assets/json-editor-iJIPDKxB.js +84 -0
  5. package/dist/index.html +2 -2
  6. package/package.json +11 -5
  7. package/src/App.svelte +678 -0
  8. package/src/app.css +178 -0
  9. package/src/lib/api.ts +78 -0
  10. package/src/lib/components/admin-dropdown.svelte +89 -0
  11. package/src/lib/components/auth-modal.svelte +114 -0
  12. package/src/lib/components/avatar.svelte +29 -0
  13. package/src/lib/components/channel-header.svelte +73 -0
  14. package/src/lib/components/create-channel-modal.svelte +137 -0
  15. package/src/lib/components/edit-channel-modal.svelte +234 -0
  16. package/src/lib/components/event-card.svelte +221 -0
  17. package/src/lib/components/event-feed.svelte +50 -0
  18. package/src/lib/components/homepage.svelte +86 -0
  19. package/src/lib/components/json-editor.svelte +57 -0
  20. package/src/lib/components/keys-and-tokens-page.svelte +216 -0
  21. package/src/lib/components/keys-modal.svelte +120 -0
  22. package/src/lib/components/message-bar.svelte +290 -0
  23. package/src/lib/components/mint-token-modal.svelte +141 -0
  24. package/src/lib/components/ref-link.svelte +33 -0
  25. package/src/lib/components/ref-side-sheet.svelte +105 -0
  26. package/src/lib/components/server-config-modal.svelte +141 -0
  27. package/src/lib/components/server-config-page.svelte +130 -0
  28. package/src/lib/components/sidebar.svelte +144 -0
  29. package/src/lib/components/status-bar.svelte +40 -0
  30. package/src/lib/pretty-json.test.ts +200 -0
  31. package/src/lib/pretty-json.ts +79 -0
  32. package/src/lib/time.ts +38 -0
  33. package/src/lib/zooid-uri.test.ts +102 -0
  34. package/src/lib/zooid-uri.ts +74 -0
  35. package/src/main.ts +7 -0
  36. package/src/vite-env.d.ts +2 -0
  37. package/dist/assets/index-C_9lWQjz.css +0 -1
  38. package/dist/assets/index-DWNlOxX1.js +0 -66
  39. package/dist/assets/json-editor-CPxgFho2.js +0 -84
  40. package/dist/assets/json-editor-DfH04Znl.css +0 -1
@@ -0,0 +1,234 @@
1
+ <script lang="ts">
2
+ import { Button } from '@ui/components/button/index';
3
+ import { Input } from '@ui/components/input/index';
4
+ import type { ZooidClient } from '@zooid/sdk';
5
+ import type { ChannelInfo } from '../api';
6
+
7
+ let {
8
+ open,
9
+ channel,
10
+ client,
11
+ defaultConfig,
12
+ onClose,
13
+ onSaved,
14
+ onDeleted,
15
+ }: {
16
+ open: boolean;
17
+ channel: ChannelInfo | null;
18
+ client: ZooidClient;
19
+ defaultConfig?: { storage?: { retention_days?: number } };
20
+ onClose: () => void;
21
+ onSaved: () => void;
22
+ onDeleted: () => void;
23
+ } = $props();
24
+
25
+ let saving = $state(false);
26
+ let error = $state('');
27
+ let confirmDelete = $state(false);
28
+ let deleting = $state(false);
29
+
30
+ // Channel fields
31
+ let name = $state('');
32
+ let description = $state('');
33
+ let tags = $state('');
34
+ let isPublic = $state(true);
35
+
36
+ // Config fields
37
+ let retentionDays = $state('');
38
+ let strictTypes = $state(false);
39
+ let typesJson = $state('');
40
+
41
+ $effect(() => {
42
+ if (open && channel) {
43
+ error = '';
44
+ confirmDelete = false;
45
+
46
+ name = channel.name;
47
+ description = channel.description ?? '';
48
+ tags = (channel.tags ?? []).join(', ');
49
+ isPublic = channel.is_public;
50
+
51
+ const cfg = channel.config as Record<string, unknown> | null;
52
+ const storageCfg = cfg?.storage as Record<string, unknown> | null;
53
+ retentionDays = storageCfg?.retention_days != null ? String(storageCfg.retention_days) : '';
54
+ strictTypes = !!cfg?.strict_types;
55
+ typesJson = cfg?.types ? JSON.stringify(cfg.types, null, 2) : '';
56
+ }
57
+ });
58
+
59
+ async function handleSave(e: Event) {
60
+ e.preventDefault();
61
+ if (!channel) return;
62
+
63
+ saving = true;
64
+ error = '';
65
+
66
+ // Build config
67
+ let config: Record<string, unknown> | null = null;
68
+ const hasConfig = retentionDays || typesJson || strictTypes;
69
+
70
+ if (hasConfig) {
71
+ config = {};
72
+ if (retentionDays) {
73
+ const num = parseInt(retentionDays, 10);
74
+ if (!isNaN(num) && num > 0) config.storage = { retention_days: num };
75
+ }
76
+ if (typesJson.trim()) {
77
+ try {
78
+ config.types = JSON.parse(typesJson);
79
+ } catch {
80
+ error = 'Invalid JSON in types definition';
81
+ saving = false;
82
+ return;
83
+ }
84
+ }
85
+ if (strictTypes) {
86
+ if (!config.types) {
87
+ error = 'strict_types requires types to be defined';
88
+ saving = false;
89
+ return;
90
+ }
91
+ config.strict_types = true;
92
+ }
93
+ }
94
+
95
+ try {
96
+ await client.updateChannel(channel.id, {
97
+ name: name.trim() || undefined,
98
+ description: description.trim() || null,
99
+ tags: tags ? tags.split(',').map((t) => t.trim()).filter(Boolean) : null,
100
+ is_public: isPublic,
101
+ config,
102
+ });
103
+ onSaved();
104
+ onClose();
105
+ } catch (err) {
106
+ error = err instanceof Error ? err.message : 'Failed to save';
107
+ } finally {
108
+ saving = false;
109
+ }
110
+ }
111
+
112
+ async function handleDelete() {
113
+ if (!channel) return;
114
+ if (!confirmDelete) {
115
+ confirmDelete = true;
116
+ return;
117
+ }
118
+
119
+ deleting = true;
120
+ error = '';
121
+ try {
122
+ await client.deleteChannel(channel.id);
123
+ onDeleted();
124
+ onClose();
125
+ } catch (err) {
126
+ error = err instanceof Error ? err.message : 'Failed to delete';
127
+ } finally {
128
+ deleting = false;
129
+ }
130
+ }
131
+
132
+ function handleBackdrop(e: MouseEvent) {
133
+ if (e.target === e.currentTarget) onClose();
134
+ }
135
+
136
+ function handleKeydown(e: KeyboardEvent) {
137
+ if (e.key === 'Escape') onClose();
138
+ }
139
+ </script>
140
+
141
+ {#if open && channel}
142
+ <div
143
+ class="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4"
144
+ role="dialog"
145
+ aria-modal="true"
146
+ aria-label="Edit channel"
147
+ tabindex="-1"
148
+ onclick={handleBackdrop}
149
+ onkeydown={handleKeydown}
150
+ >
151
+ <div class="bg-card border border-border rounded-lg shadow-lg w-full max-w-sm p-6 max-h-[90vh] overflow-y-auto">
152
+ <h2 class="font-semibold text-sm mb-1">Edit Channel</h2>
153
+ <p class="text-xs text-muted-foreground mb-4 font-mono">{channel.id}</p>
154
+
155
+ <form onsubmit={handleSave} class="flex flex-col gap-3">
156
+ <label class="flex flex-col gap-1">
157
+ <span class="text-xs text-muted-foreground">Name</span>
158
+ <Input bind:value={name} placeholder="Channel name" />
159
+ </label>
160
+ <label class="flex flex-col gap-1">
161
+ <span class="text-xs text-muted-foreground">Description</span>
162
+ <Input bind:value={description} placeholder="What this channel is for" />
163
+ </label>
164
+ <label class="flex flex-col gap-1">
165
+ <span class="text-xs text-muted-foreground">Tags (comma-separated)</span>
166
+ <Input bind:value={tags} placeholder="ai, signals" />
167
+ </label>
168
+ <label class="flex items-center gap-2 cursor-pointer">
169
+ <input type="checkbox" bind:checked={isPublic} class="rounded border-border" />
170
+ <span class="text-xs text-muted-foreground">Public channel</span>
171
+ </label>
172
+
173
+ <!-- Config section -->
174
+ <div class="border-t border-border pt-3 mt-1">
175
+ <div class="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Config</div>
176
+
177
+ <div class="flex flex-col gap-3">
178
+ <label class="flex flex-col gap-1">
179
+ <span class="text-xs text-muted-foreground">Retention (days)</span>
180
+ <Input bind:value={retentionDays} type="number" placeholder="Default ({defaultConfig?.storage?.retention_days ?? 7})" />
181
+ </label>
182
+
183
+ <label class="flex flex-col gap-1">
184
+ <span class="text-xs text-muted-foreground">Event types (JSON)</span>
185
+ <textarea
186
+ bind:value={typesJson}
187
+ placeholder={'{\n "message": { ... },\n "alert": { ... }\n}'}
188
+ class="w-full bg-background border border-input rounded-md px-3 py-2 text-xs font-mono min-h-[80px] resize-y focus:outline-none focus:ring-1 focus:ring-ring"
189
+ spellcheck="false"
190
+ ></textarea>
191
+ <span class="text-[10px] text-muted-foreground/60">Define event type schemas as JSON</span>
192
+ </label>
193
+
194
+ <label class="flex items-center gap-2 cursor-pointer">
195
+ <input type="checkbox" bind:checked={strictTypes} class="rounded border-border" />
196
+ <span class="text-xs text-muted-foreground">Strict types (reject unknown types)</span>
197
+ </label>
198
+ </div>
199
+ </div>
200
+
201
+ {#if error}
202
+ <p class="text-xs text-destructive">{error}</p>
203
+ {/if}
204
+
205
+ <div class="flex gap-2 mt-1">
206
+ <Button type="submit" size="sm" class="flex-1" disabled={saving || !name.trim()}>
207
+ {saving ? 'Saving...' : 'Save'}
208
+ </Button>
209
+ <Button variant="outline" size="sm" class="flex-1" onclick={onClose}>Cancel</Button>
210
+ </div>
211
+
212
+ <!-- Delete -->
213
+ <div class="border-t border-border pt-3 mt-1">
214
+ <Button
215
+ type="button"
216
+ variant="destructive"
217
+ size="sm"
218
+ class="w-full text-[11px]"
219
+ disabled={deleting}
220
+ onclick={handleDelete}
221
+ >
222
+ {#if deleting}
223
+ Deleting...
224
+ {:else if confirmDelete}
225
+ Confirm delete "{channel.id}"
226
+ {:else}
227
+ Delete channel
228
+ {/if}
229
+ </Button>
230
+ </div>
231
+ </form>
232
+ </div>
233
+ </div>
234
+ {/if}
@@ -0,0 +1,221 @@
1
+ <script lang="ts">
2
+ import type { ZooidEvent } from '../api';
3
+ import { parsePretty, renderMarkdown, type PrettyNode } from '../pretty-json';
4
+ import { formatRelative, formatFull } from '../time';
5
+ import Avatar from './avatar.svelte';
6
+ import RefLink from './ref-link.svelte';
7
+
8
+ let {
9
+ event,
10
+ viewMode = 'pretty',
11
+ canReply = false,
12
+ onReply,
13
+ onOpenRef,
14
+ }: {
15
+ event: ZooidEvent;
16
+ viewMode?: 'pretty' | 'raw';
17
+ canReply?: boolean;
18
+ onReply?: (eventId: string) => void;
19
+ onOpenRef?: (detail: { channel: string; eventId: string }) => void;
20
+ } = $props();
21
+
22
+ let rawData = $derived(formatRaw(event.data));
23
+ let prettyEntries = $derived(parsePretty(event.data));
24
+ let messageBody = $derived(extractMessageBody(event));
25
+ let relativeTime = $derived(formatRelative(event.created_at));
26
+ let fullTime = $derived(formatFull(event.created_at));
27
+ let publisherLabel = $derived(formatPublisher(event));
28
+ let inReplyTo = $derived(event.reply_to ?? null);
29
+
30
+ let detailOpen = $state(false);
31
+
32
+ function formatRaw(raw: string): string {
33
+ try {
34
+ return JSON.stringify(JSON.parse(raw), null, 2);
35
+ } catch {
36
+ return raw;
37
+ }
38
+ }
39
+
40
+ function scrollToEvent(id: string) {
41
+ const el = document.getElementById(`event-${id}`);
42
+ if (el) {
43
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
44
+ el.classList.add('bg-primary/10');
45
+ setTimeout(() => el.classList.remove('bg-primary/10'), 1500);
46
+ }
47
+ }
48
+
49
+ function extractMessageBody(e: ZooidEvent): string | null {
50
+ if (e.type !== 'message') return null;
51
+ try {
52
+ const obj = JSON.parse(e.data);
53
+ if (typeof obj?.body === 'string') return obj.body;
54
+ } catch {}
55
+ return null;
56
+ }
57
+
58
+ function formatPublisher(e: ZooidEvent): string | null {
59
+ const id = e.publisher_id;
60
+ const name = e.publisher_name;
61
+ if (!name && !id) return null;
62
+ const colonIdx = id?.indexOf(':') ?? -1;
63
+ const issuer = colonIdx > 0 ? id!.slice(0, colonIdx) : null;
64
+ const isExternal = issuer && issuer !== 'local';
65
+ if (name && isExternal) return `${name} (@${issuer})`;
66
+ if (name) return name;
67
+ return id;
68
+ }
69
+
70
+ function toggleDetail() {
71
+ detailOpen = !detailOpen;
72
+ }
73
+
74
+ function closeDetail() {
75
+ detailOpen = false;
76
+ }
77
+ </script>
78
+
79
+ {#snippet prettyNode(node: PrettyNode, depth: number)}
80
+ {#if node.kind === 'text' && node.multiline}
81
+ <div style={depth > 0 ? `padding-left: ${depth * 0.75}rem` : ''}>
82
+ <span class="font-semibold text-foreground/90">{node.key}</span><span class="text-foreground/50">:</span>
83
+ </div>
84
+ <div class="break-words overflow-wrap-anywhere text-foreground/70" style="padding-left: {(depth + 1) * 0.75}rem">
85
+ {#if node.markdown}<span class="prose-inline">{@html renderMarkdown(node.value)}</span>{:else}{#each node.value.split('\n') as line, i}{#if i > 0}<br />{/if}{line}{/each}{/if}
86
+ </div>
87
+ {:else if node.kind === 'ref'}
88
+ <div class="break-words overflow-wrap-anywhere" style={depth > 0 ? `padding-left: ${depth * 0.75}rem` : ''}>
89
+ <span class="font-semibold text-foreground/90">{node.key}</span><span class="text-foreground/50">: </span>
90
+ <RefLink ref={node.value} {onOpenRef} />
91
+ </div>
92
+ {:else if node.kind === 'text'}
93
+ <div class="break-words overflow-wrap-anywhere" style={depth > 0 ? `padding-left: ${depth * 0.75}rem` : ''}>
94
+ {#if node.key}<span class="font-semibold text-foreground/90">{node.key}</span><span class="text-foreground/50">: </span>{/if}{#if node.markdown}<span class="prose-inline">{@html renderMarkdown(node.value)}</span>{:else}{node.value}{/if}
95
+ </div>
96
+ {:else if node.kind === 'group'}
97
+ <div style={depth > 0 ? `padding-left: ${depth * 0.75}rem` : ''}>
98
+ <span class="font-semibold text-foreground/90">{node.key}</span><span class="text-foreground/50">:</span>
99
+ </div>
100
+ {#each node.children as child}
101
+ {@render prettyNode(child, depth + 1)}
102
+ {/each}
103
+ {:else}
104
+ <div style={depth > 0 ? `padding-left: ${depth * 0.75}rem` : ''}>
105
+ <span class="font-semibold text-foreground/90">{node.key}</span><span class="text-foreground/50">:</span>
106
+ </div>
107
+ <ol class="list-decimal list-inside" style="padding-left: {(depth + 1) * 0.75}rem">
108
+ {#each node.items as fields}
109
+ <li class="text-foreground/80">
110
+ {#if fields.length === 1 && fields[0].kind === 'text' && !fields[0].key}
111
+ {fields[0].value}
112
+ {:else}
113
+ {#each fields as child}
114
+ {@render prettyNode(child, depth + 2)}
115
+ {/each}
116
+ {/if}
117
+ </li>
118
+ {/each}
119
+ </ol>
120
+ {/if}
121
+ {/snippet}
122
+
123
+ <div id="event-{event.id}" class="group py-2 px-2 hover:bg-secondary/30 rounded transition-colors">
124
+ <!-- Header: publisher + type + time -->
125
+ <div class="flex items-center gap-2 mb-0.5">
126
+ <Avatar {event} size={20} />
127
+ <span class="font-semibold text-sm text-foreground">
128
+ {publisherLabel ?? 'anonymous'}
129
+ </span>
130
+ {#if event.type}
131
+ <span class="text-[10px] font-mono text-muted-foreground/60 bg-secondary/60 px-1.5 py-0 rounded">{event.type}</span>
132
+ {/if}
133
+ {#if inReplyTo}
134
+ <button
135
+ class="text-[10px] font-mono text-primary/60 hover:text-primary bg-primary/10 hover:bg-primary/20 px-1.5 py-0 rounded cursor-pointer transition-colors"
136
+ onclick={() => scrollToEvent(inReplyTo!)}
137
+ title="Scroll to parent event"
138
+ >&#8593; reply</button>
139
+ {/if}
140
+ <div class="ml-auto shrink-0 flex items-center gap-1">
141
+ {#if canReply}
142
+ <button
143
+ class="text-[10px] text-muted-foreground/0 group-hover:text-muted-foreground/40 hover:!text-foreground transition-colors px-1"
144
+ onclick={() => onReply?.(event.id)}
145
+ title="Reply"
146
+ >
147
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
148
+ </button>
149
+ {/if}
150
+ <button
151
+ class="text-[10px] text-muted-foreground/40 hover:text-foreground transition-colors relative"
152
+ onclick={toggleDetail}
153
+ >
154
+ {relativeTime}
155
+ </button>
156
+ </div>
157
+ </div>
158
+
159
+ <!-- Body -->
160
+ {#if viewMode === 'pretty' && messageBody}
161
+ <div class="text-sm text-foreground/80 prose-block">{@html renderMarkdown(messageBody)}</div>
162
+ {:else if viewMode === 'pretty' && prettyEntries}
163
+ <div class="text-sm text-foreground/80 flex flex-col gap-0.5 pl-0">
164
+ {#each prettyEntries as node}
165
+ {@render prettyNode(node, 0)}
166
+ {/each}
167
+ </div>
168
+ {:else}
169
+ <pre class="text-xs text-foreground/60 overflow-x-auto whitespace-pre-wrap break-all font-mono">{rawData}</pre>
170
+ {/if}
171
+ </div>
172
+
173
+ <!-- Detail slide-over panel (from right) -->
174
+ {#if detailOpen}
175
+ <button
176
+ type="button"
177
+ class="fixed inset-0 bg-background/40 z-40"
178
+ onclick={closeDetail}
179
+ aria-label="Close detail panel"
180
+ ></button>
181
+ <div class="fixed inset-y-0 right-0 w-72 max-w-[80vw] bg-card border-l border-border shadow-lg z-50 p-4 flex flex-col gap-3 animate-slide-in-right">
182
+ <div class="flex items-center justify-between">
183
+ <h3 class="text-sm font-semibold">Event detail</h3>
184
+ <button class="text-muted-foreground hover:text-foreground transition-colors" onclick={closeDetail} aria-label="Close">
185
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
186
+ </button>
187
+ </div>
188
+ <div class="flex flex-col gap-2 text-xs">
189
+ <div>
190
+ <div class="text-muted-foreground mb-0.5">Created</div>
191
+ <div class="text-foreground font-mono">{fullTime}</div>
192
+ </div>
193
+ <div>
194
+ <div class="text-muted-foreground mb-0.5">Event ID</div>
195
+ <div class="text-foreground font-mono break-all select-all">{event.id}</div>
196
+ </div>
197
+ {#if event.type}
198
+ <div>
199
+ <div class="text-muted-foreground mb-0.5">Type</div>
200
+ <div class="text-foreground font-mono">{event.type}</div>
201
+ </div>
202
+ {/if}
203
+ {#if publisherLabel}
204
+ <div>
205
+ <div class="text-muted-foreground mb-0.5">Publisher</div>
206
+ <div class="text-foreground">{publisherLabel}</div>
207
+ </div>
208
+ {/if}
209
+ </div>
210
+ </div>
211
+ {/if}
212
+
213
+ <style>
214
+ @keyframes slide-in-right {
215
+ from { transform: translateX(100%); }
216
+ to { transform: translateX(0); }
217
+ }
218
+ .animate-slide-in-right {
219
+ animation: slide-in-right 0.15s ease-out;
220
+ }
221
+ </style>
@@ -0,0 +1,50 @@
1
+ <script lang="ts">
2
+ import { tick } from 'svelte';
3
+ import EventCard from './event-card.svelte';
4
+ import type { ZooidEvent } from '../api';
5
+
6
+ let {
7
+ events,
8
+ viewMode = 'pretty',
9
+ canReply = false,
10
+ onReply,
11
+ onOpenRef,
12
+ }: {
13
+ events: ZooidEvent[];
14
+ viewMode?: 'pretty' | 'raw';
15
+ canReply?: boolean;
16
+ onReply?: (eventId: string) => void;
17
+ onOpenRef?: (detail: { channel: string; eventId: string }) => void;
18
+ } = $props();
19
+
20
+ let reversed = $derived([...events].reverse());
21
+
22
+ let container: HTMLDivElement | undefined = $state();
23
+
24
+ $effect(() => {
25
+ events.length;
26
+ if (container) {
27
+ tick().then(() => {
28
+ container!.scrollTop = container!.scrollHeight;
29
+ });
30
+ }
31
+ });
32
+ </script>
33
+
34
+ <div class="flex-1 overflow-auto px-4 flex flex-col" bind:this={container}>
35
+ {#if events.length === 0}
36
+ <div class="flex items-center justify-center h-32 text-sm text-muted-foreground">
37
+ No events yet. Waiting for signals...
38
+ </div>
39
+ {:else}
40
+ <div class="mt-auto"></div>
41
+ <div class="flex flex-col pt-2 pb-4">
42
+ {#each reversed as event, i (event.id)}
43
+ {#if i > 0}
44
+ <div class="border-t border-border/30 mx-2"></div>
45
+ {/if}
46
+ <EventCard {event} {viewMode} {canReply} {onReply} {onOpenRef} />
47
+ {/each}
48
+ </div>
49
+ {/if}
50
+ </div>
@@ -0,0 +1,86 @@
1
+ <script lang="ts">
2
+ import { Badge } from '@ui/components/badge/index';
3
+ import { Card, CardContent } from '@ui/components/card/index';
4
+ import { Separator } from '@ui/components/separator/index';
5
+ import { fetchServerMeta, listChannels, type ChannelInfo } from '../api';
6
+ import { formatRelativeUlid } from '../time';
7
+
8
+ const baseUrl = window.location.origin;
9
+
10
+ let serverName = $state('Zooid');
11
+ let serverDesc = $state<string | null>(null);
12
+ let channels = $state<ChannelInfo[]>([]);
13
+ let loading = $state(true);
14
+
15
+ async function load() {
16
+ const [meta, chs] = await Promise.all([
17
+ fetchServerMeta(baseUrl),
18
+ listChannels(baseUrl),
19
+ ]);
20
+ serverName = meta.server_name;
21
+ serverDesc = meta.server_description;
22
+ channels = chs;
23
+ loading = false;
24
+ }
25
+
26
+ load();
27
+
28
+ </script>
29
+
30
+ <div class="min-h-screen max-w-2xl mx-auto px-4 py-12 flex flex-col">
31
+ <header class="mb-10">
32
+ <h1 class="text-2xl font-bold tracking-tight mb-1">{serverName}</h1>
33
+ <p class="text-sm text-muted-foreground">{serverDesc ?? 'Channels on this server:'}</p>
34
+ </header>
35
+
36
+ {#if loading}
37
+ <p class="text-sm text-muted-foreground">Loading channels...</p>
38
+ {:else if channels.length === 0}
39
+ <div class="text-sm text-muted-foreground border border-dashed border-border rounded-lg p-8 text-center">
40
+ <p>No channels yet.</p>
41
+ <p class="mt-1">Create one with <code class="text-foreground">npx zooid channel create</code></p>
42
+ </div>
43
+ {:else}
44
+ <div class="flex flex-col gap-3">
45
+ {#each channels as ch (ch.id)}
46
+ <a href="/{ch.id}" class="block group no-underline">
47
+ <Card class="transition-colors group-hover:border-primary/40">
48
+ <CardContent class="p-4">
49
+ <div class="flex items-center justify-between gap-2 mb-1">
50
+ <div class="flex items-center gap-2">
51
+ <span class="font-semibold text-sm">{ch.name}</span>
52
+ {#if ch.is_public}
53
+ <Badge variant="secondary" class="text-[10px] px-1.5 py-0">public</Badge>
54
+ {:else}
55
+ <Badge variant="outline" class="text-[10px] px-1.5 py-0">private</Badge>
56
+ {/if}
57
+ </div>
58
+ <span class="text-[10px] text-muted-foreground shrink-0">
59
+ {ch.event_count} event{ch.event_count === 1 ? '' : 's'}
60
+ </span>
61
+ </div>
62
+
63
+ {#if ch.description}
64
+ <p class="text-xs text-muted-foreground mb-2">{ch.description}</p>
65
+ {/if}
66
+
67
+ <div class="flex items-center gap-3 text-[10px] text-muted-foreground/60">
68
+ <span class="font-mono">{ch.id}</span>
69
+ {#if ch.last_event_id}
70
+ <Separator orientation="vertical" class="h-3" />
71
+ <span>latest {formatRelativeUlid(ch.last_event_id)}</span>
72
+ {/if}
73
+ </div>
74
+ </CardContent>
75
+ </Card>
76
+ </a>
77
+ {/each}
78
+ </div>
79
+ {/if}
80
+
81
+ <div class="flex-1"></div>
82
+ <footer class="mt-12 pt-4 pb-[env(safe-area-inset-bottom)] border-t border-border text-[10px] text-muted-foreground/40 flex items-center justify-between">
83
+ <span>Powered by <a href="https://zooid.dev" class="underline hover:text-muted-foreground">Zooid</a></span>
84
+ <a href="https://github.com/zooid-ai/zooid" class="underline hover:text-muted-foreground">Star on GitHub</a>
85
+ </footer>
86
+ </div>
@@ -0,0 +1,57 @@
1
+ <script lang="ts">
2
+ import { JSONEditor, createAjvValidator, Mode } from 'svelte-jsoneditor';
3
+ import type { Content } from 'svelte-jsoneditor';
4
+
5
+ let {
6
+ content = $bindable({ json: {} }),
7
+ schema,
8
+ }: {
9
+ content: Content;
10
+ schema?: Record<string, unknown> | null;
11
+ } = $props();
12
+
13
+ let validator = $derived.by(() => {
14
+ if (!schema) return undefined;
15
+ try {
16
+ return createAjvValidator({ schema });
17
+ } catch {
18
+ return undefined;
19
+ }
20
+ });
21
+ </script>
22
+
23
+ <div class="json-editor-wrapper">
24
+ <JSONEditor
25
+ bind:content
26
+ mode={Mode.text}
27
+ mainMenuBar={false}
28
+ navigationBar={false}
29
+ statusBar={false}
30
+ {validator}
31
+ />
32
+ </div>
33
+
34
+ <style>
35
+ .json-editor-wrapper {
36
+ --jse-theme-color: oklch(0.21 0 0);
37
+ --jse-theme-color-highlight: oklch(0.25 0 0);
38
+ --jse-background-color: oklch(0.18 0 0);
39
+ --jse-text-color: oklch(0.85 0 0);
40
+ --jse-panel-background: oklch(0.15 0 0);
41
+ --jse-panel-border: oklch(0.25 0 0);
42
+ --jse-main-border: 1px solid oklch(0.25 0 0);
43
+ --jse-key-color: oklch(0.7 0.1 200);
44
+ --jse-value-color-string: oklch(0.75 0.1 150);
45
+ --jse-value-color-number: oklch(0.75 0.1 60);
46
+ --jse-delimiter-color: oklch(0.5 0 0);
47
+ --jse-error-color: oklch(0.65 0.2 25);
48
+ border-radius: 0.375rem;
49
+ overflow: hidden;
50
+ max-height: 150px;
51
+ }
52
+
53
+ .json-editor-wrapper :global(.jse-text-mode) {
54
+ min-height: 60px;
55
+ max-height: 150px;
56
+ }
57
+ </style>