@zooid/web 0.6.0 → 0.8.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 (47) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +39 -0
  3. package/dist/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  4. package/dist/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  5. package/dist/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  6. package/dist/assets/index-Bq2HBBZQ.css +1 -0
  7. package/dist/assets/index-CiUWUtw6.js +118066 -0
  8. package/dist/assets/index-wOYm83VW.js +439 -0
  9. package/dist/assets/reaction-picker-emoji-CI7LVLOX.js +716 -0
  10. package/dist/favicon.svg +1 -0
  11. package/dist/index.html +7 -35
  12. package/package.json +63 -29
  13. package/dist/assets/index-7P1i28a8.js +0 -66
  14. package/dist/assets/index-BeqmFlCW.css +0 -1
  15. package/dist/assets/json-editor-Cvlnnf1Q.css +0 -1
  16. package/dist/assets/json-editor-iJIPDKxB.js +0 -84
  17. package/dist/assets/vanilla-picker-l5rcX3cq.js +0 -8
  18. package/src/App.svelte +0 -678
  19. package/src/app.css +0 -178
  20. package/src/lib/api.ts +0 -78
  21. package/src/lib/components/admin-dropdown.svelte +0 -89
  22. package/src/lib/components/auth-modal.svelte +0 -114
  23. package/src/lib/components/avatar.svelte +0 -29
  24. package/src/lib/components/channel-header.svelte +0 -73
  25. package/src/lib/components/create-channel-modal.svelte +0 -137
  26. package/src/lib/components/edit-channel-modal.svelte +0 -234
  27. package/src/lib/components/event-card.svelte +0 -221
  28. package/src/lib/components/event-feed.svelte +0 -50
  29. package/src/lib/components/homepage.svelte +0 -86
  30. package/src/lib/components/json-editor.svelte +0 -57
  31. package/src/lib/components/keys-and-tokens-page.svelte +0 -216
  32. package/src/lib/components/keys-modal.svelte +0 -120
  33. package/src/lib/components/message-bar.svelte +0 -290
  34. package/src/lib/components/mint-token-modal.svelte +0 -141
  35. package/src/lib/components/ref-link.svelte +0 -33
  36. package/src/lib/components/ref-side-sheet.svelte +0 -105
  37. package/src/lib/components/server-config-modal.svelte +0 -141
  38. package/src/lib/components/server-config-page.svelte +0 -130
  39. package/src/lib/components/sidebar.svelte +0 -144
  40. package/src/lib/components/status-bar.svelte +0 -40
  41. package/src/lib/pretty-json.test.ts +0 -200
  42. package/src/lib/pretty-json.ts +0 -79
  43. package/src/lib/time.ts +0 -38
  44. package/src/lib/zooid-uri.test.ts +0 -102
  45. package/src/lib/zooid-uri.ts +0 -74
  46. package/src/main.ts +0 -7
  47. package/src/vite-env.d.ts +0 -2
package/src/App.svelte DELETED
@@ -1,678 +0,0 @@
1
- <script lang="ts">
2
- import Sidebar from './lib/components/sidebar.svelte';
3
- import ChannelHeader from './lib/components/channel-header.svelte';
4
- import EventFeed from './lib/components/event-feed.svelte';
5
- import MessageBar from './lib/components/message-bar.svelte';
6
- import AuthModal from './lib/components/auth-modal.svelte';
7
- import ServerConfigPage from './lib/components/server-config-page.svelte';
8
- import KeysAndTokensPage from './lib/components/keys-and-tokens-page.svelte';
9
- import CreateChannelModal from './lib/components/create-channel-modal.svelte';
10
- import EditChannelModal from './lib/components/edit-channel-modal.svelte';
11
- import RefSideSheet from './lib/components/ref-side-sheet.svelte';
12
- import {
13
- fetchServerMeta,
14
- createClient,
15
- refreshAuth,
16
- authLogout,
17
- type ChannelInfo,
18
- type ZooidEvent,
19
- type TokenClaims,
20
- } from './lib/api';
21
- import type { Component } from 'svelte';
22
-
23
- interface SettingsPageExtension {
24
- slug: string;
25
- label: string;
26
- icon?: Component;
27
- component: Component;
28
- }
29
-
30
- interface ChannelConfigDefaults {
31
- storage?: { retention_days?: number };
32
- strict_types?: boolean;
33
- types?: Record<string, unknown>;
34
- }
35
-
36
- type MaybeAsync<T> = T | (() => Promise<T>);
37
-
38
- interface WebExtension {
39
- settingsPages?: SettingsPageExtension[];
40
- headerActions?: Component[];
41
- defaults?: {
42
- channelConfig?: MaybeAsync<ChannelConfigDefaults>;
43
- };
44
- }
45
-
46
- let { extensions }: { extensions?: WebExtension } = $props();
47
-
48
- let channelConfigDefaults = $state<ChannelConfigDefaults | undefined>(undefined);
49
-
50
- const WS_POLL_THRESHOLD = 60;
51
- const RECONNECT_DELAYS = [0, 1000, 2000, 4000, 8000];
52
- const baseUrl = window.location.origin;
53
-
54
- // --- Auth state (persisted in localStorage) ---
55
- let token = $state(localStorage.getItem('zooid_token') ?? '');
56
- let claims = $state<TokenClaims | null>(null);
57
- let authModalOpen = $state(false);
58
-
59
- // Reactive SDK client
60
- let client = $derived(createClient(token || undefined));
61
-
62
- // Admin check
63
- let isAdmin = $derived(claims?.scopes?.includes('admin') ?? false);
64
-
65
- // --- Channel state ---
66
- let channels = $state<ChannelInfo[]>([]);
67
- let selectedId = $state<string | null>(null);
68
- let channel = $state<ChannelInfo | null>(null);
69
- let events = $state<ZooidEvent[]>([]);
70
- let viewMode = $state<'pretty' | 'raw'>('pretty');
71
- let status = $state<'connected' | 'polling' | 'reconnecting' | 'error' | 'idle' | 'loading'>('idle');
72
- let cursor = $state<string | null>(null);
73
- let pollTimer = $state<ReturnType<typeof setInterval> | null>(null);
74
- let pollInterval = $state(5);
75
- let ws = $state<WebSocket | null>(null);
76
- let reconnectAttempt = 0;
77
- let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
78
- let serverName = $state('Zooid');
79
- let authUrl = $state<string | undefined>(undefined);
80
- let refreshTimer = $state<ReturnType<typeof setInterval> | null>(null);
81
- let replyTo = $state<string | null>(null);
82
-
83
- // Mobile sidebar
84
- let sidebarOpen = $state(false);
85
-
86
- // Ref side sheet
87
- let refSheet = $state<{ channel: string; eventId: string } | null>(null);
88
-
89
- function handleOpenRef(detail: { channel: string; eventId: string }) {
90
- refSheet = detail;
91
- }
92
-
93
- // Modals
94
- let createChannelOpen = $state(false);
95
- let editChannelOpen = $state(false);
96
-
97
- // Settings page routing (built-in + extension slugs)
98
- let currentView = $state<string>('channel');
99
-
100
- const seenIds = new Set<string>();
101
-
102
- // Check if user can publish to the selected channel
103
- let canPublishToChannel = $derived.by(() => {
104
- if (!claims || !selectedId) return false;
105
- const scopes = claims.scopes;
106
- if (scopes.includes('admin')) return true;
107
- return scopes.some((s) =>
108
- s === `pub:${selectedId}` || s === 'pub:*' ||
109
- (s.endsWith('*') && s.startsWith('pub:') && selectedId!.startsWith(s.slice(4, -1)))
110
- );
111
- });
112
-
113
- // Show reply button when user can publish and channel supports message replies
114
- let canReply = $derived.by(() => {
115
- if (!canPublishToChannel) return false;
116
- if (!channel) return false;
117
- const config = channel.config as { strict?: boolean; types?: Record<string, unknown> } | null;
118
- // Non-strict channels always allow reply
119
- if (!config?.strict) return true;
120
- // Strict channels: only if "message" type is configured
121
- return !!config?.types?.['message'];
122
- });
123
-
124
- function handleReply(eventId: string) {
125
- replyTo = eventId;
126
- }
127
-
128
- // --- Init ---
129
-
130
- // Parse initial route
131
- const path = window.location.pathname;
132
- const settingsMatch = path.match(/^\/_settings\/(.+)$/);
133
- if (settingsMatch) {
134
- const slug = settingsMatch[1];
135
- currentView = slug;
136
- // Set title based on known pages or extension pages
137
- if (slug === 'keys-and-tokens') document.title = 'Keys & Tokens — Zooid';
138
- else if (slug === 'server') document.title = 'Server Config — Zooid';
139
- else {
140
- const ext = extensions?.settingsPages?.find(p => p.slug === slug);
141
- if (ext) document.title = `${ext.label} — Zooid`;
142
- }
143
- } else {
144
- const routeMatch = path.match(/^\/([a-z0-9][a-z0-9-]{1,62}[a-z0-9])$/);
145
- if (routeMatch) selectedId = routeMatch[1];
146
- }
147
-
148
- async function init() {
149
- // Check if we just came back from an OIDC callback (token set in localStorage by callback page)
150
- const storedToken = localStorage.getItem('zooid_token');
151
- if (storedToken && storedToken !== token) {
152
- token = storedToken;
153
- }
154
-
155
- const [meta] = await Promise.all([
156
- fetchServerMeta(baseUrl),
157
- refreshChannels(),
158
- token ? validateToken() : Promise.resolve(),
159
- ]);
160
- serverName = meta.server_name;
161
- pollInterval = meta.poll_interval;
162
- authUrl = meta.auth_url;
163
-
164
- // Resolve channel config defaults (static or async)
165
- const cfgDefault = extensions?.defaults?.channelConfig;
166
- if (typeof cfgDefault === 'function') {
167
- cfgDefault().then((d) => { channelConfigDefaults = d; }).catch(() => {});
168
- } else if (cfgDefault) {
169
- channelConfigDefaults = cfgDefault;
170
- }
171
-
172
- // Start token refresh loop if we have a token with expiry
173
- if (claims?.exp) {
174
- startRefreshLoop();
175
- }
176
-
177
- if (selectedId) {
178
- selectChannel(selectedId);
179
- } else if (channels.length > 0) {
180
- selectChannel(channels[0].id);
181
- }
182
- }
183
-
184
- async function refreshChannels() {
185
- try {
186
- channels = await client.listChannels();
187
- } catch {
188
- channels = [];
189
- }
190
- }
191
-
192
- async function validateToken() {
193
- if (!token) { claims = null; return; }
194
- try {
195
- claims = await client.getTokenClaims();
196
- } catch {
197
- // Invalid token — clear it
198
- claims = null;
199
- token = '';
200
- localStorage.removeItem('zooid_token');
201
- }
202
- }
203
-
204
- // --- Channel selection ---
205
-
206
- function selectChannel(id: string) {
207
- if (selectedId === id && channel) return;
208
- currentView = 'channel';
209
- selectedId = id;
210
- sidebarOpen = false;
211
-
212
- // Update URL
213
- window.history.pushState({}, '', `/${id}`);
214
- document.title = `${id} — Zooid`;
215
-
216
- loadChannel();
217
- }
218
-
219
- function navigateSettings(page: string, title: string) {
220
- currentView = page as typeof currentView;
221
- selectedId = null;
222
- channel = null;
223
- cleanup();
224
- status = 'idle';
225
- sidebarOpen = false;
226
- window.history.pushState({}, '', `/_settings/${page}`);
227
- document.title = `${title} — Zooid`;
228
- }
229
-
230
- function cleanup() {
231
- if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
232
- if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
233
- if (ws) { ws.close(); ws = null; }
234
- seenIds.clear();
235
- events = [];
236
- cursor = null;
237
- replyTo = null;
238
- }
239
-
240
- async function loadChannel() {
241
- if (!selectedId) return;
242
-
243
- cleanup();
244
- status = 'loading';
245
-
246
- try {
247
- const list = await client.listChannels();
248
- const ch = list.find((c) => c.id === selectedId) ?? null;
249
- if (!ch) {
250
- channel = null;
251
- status = 'idle';
252
- return;
253
- }
254
- channel = ch;
255
- } catch {
256
- channel = null;
257
- status = 'idle';
258
- return;
259
- }
260
- document.title = `${channel!.name} — Zooid`;
261
-
262
- // Update RSS link
263
- updateRssLink(selectedId, token || undefined);
264
-
265
- await fetchEvents();
266
-
267
- const meta = await fetchServerMeta(baseUrl);
268
- const supportsWs = meta.delivery.includes('websocket');
269
-
270
- if (supportsWs && meta.poll_interval <= WS_POLL_THRESHOLD) {
271
- connectWebSocket();
272
- } else {
273
- pollInterval = meta.poll_interval;
274
- startPolling();
275
- }
276
- }
277
-
278
- // --- Events ---
279
-
280
- async function fetchEvents() {
281
- if (!selectedId) return;
282
- try {
283
- const result = await client.poll(selectedId, {
284
- cursor: cursor ?? undefined,
285
- limit: 50,
286
- });
287
-
288
- if (result.events.length > 0) {
289
- const newest = result.events.slice().reverse();
290
- const fresh = newest.filter((e) => !seenIds.has(e.id));
291
- for (const e of fresh) seenIds.add(e.id);
292
-
293
- if (cursor && fresh.length > 0) {
294
- events = [...fresh, ...events];
295
- } else if (!cursor) {
296
- events = fresh;
297
- }
298
- cursor = result.events[result.events.length - 1]?.id ?? cursor;
299
- }
300
- } catch {
301
- status = 'error';
302
- }
303
- }
304
-
305
- // --- Polling ---
306
-
307
- function startPolling() {
308
- if (pollTimer) clearInterval(pollTimer);
309
- status = 'polling';
310
- pollTimer = setInterval(async () => {
311
- if (selectedId) {
312
- try {
313
- const list = await client.listChannels();
314
- const ch = list.find((c) => c.id === selectedId);
315
- if (ch) channel = ch;
316
- } catch { /* keep going */ }
317
- }
318
- await fetchEvents();
319
- status = 'polling';
320
- }, pollInterval * 1000);
321
- }
322
-
323
- // --- WebSocket ---
324
-
325
- function connectWebSocket() {
326
- if (!selectedId) return;
327
-
328
- const channelId = selectedId;
329
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
330
- const wsUrl = new URL(`${protocol}//${window.location.host}/api/v1/channels/${selectedId}/ws`);
331
- if (token) wsUrl.searchParams.set('token', token);
332
-
333
- const socket = new WebSocket(wsUrl.toString());
334
-
335
- socket.addEventListener('open', () => {
336
- status = 'connected';
337
- reconnectAttempt = 0;
338
- fetchEvents();
339
- });
340
-
341
- socket.addEventListener('message', (e) => {
342
- if (selectedId !== channelId) return;
343
- try {
344
- const event: ZooidEvent = JSON.parse(e.data);
345
- if (seenIds.has(event.id)) return;
346
- seenIds.add(event.id);
347
- events = [event, ...events];
348
- cursor = event.id;
349
- } catch {}
350
- });
351
-
352
- socket.addEventListener('close', () => {
353
- ws = null;
354
- scheduleReconnect();
355
- });
356
-
357
- socket.addEventListener('error', () => {});
358
-
359
- ws = socket;
360
- }
361
-
362
- function scheduleReconnect() {
363
- if (!selectedId) return;
364
- if (reconnectAttempt >= RECONNECT_DELAYS.length) {
365
- startPolling();
366
- return;
367
- }
368
- status = 'reconnecting';
369
- const delay = RECONNECT_DELAYS[reconnectAttempt];
370
- reconnectAttempt++;
371
- reconnectTimer = setTimeout(() => {
372
- reconnectTimer = null;
373
- connectWebSocket();
374
- }, delay);
375
- }
376
-
377
- // --- RSS ---
378
-
379
- function updateRssLink(chId: string, tok?: string) {
380
- let link = document.querySelector('link[rel="alternate"][type="application/rss+xml"]') as HTMLLinkElement | null;
381
- if (!link) {
382
- link = document.createElement('link');
383
- link.rel = 'alternate';
384
- link.type = 'application/rss+xml';
385
- document.head.appendChild(link);
386
- }
387
- link.href = tok
388
- ? `${baseUrl}/api/v1/channels/${chId}/rss?token=${tok}`
389
- : `${baseUrl}/api/v1/channels/${chId}/rss`;
390
- link.title = `${chId} RSS Feed`;
391
- }
392
-
393
- // --- Auth ---
394
-
395
- function handleAuthClick() {
396
- // Already signed in — open modal to show token / sign out
397
- if (claims) {
398
- authModalOpen = true;
399
- return;
400
- }
401
- // OIDC available — redirect directly, skip modal
402
- if (authUrl) {
403
- window.location.href = authUrl;
404
- return;
405
- }
406
- // No OIDC — open modal for paste-token flow
407
- authModalOpen = true;
408
- }
409
-
410
- async function handleAuthSave(newToken: string): Promise<boolean> {
411
- token = newToken;
412
- localStorage.setItem('zooid_token', newToken);
413
- await validateToken();
414
- if (!claims) {
415
- return false;
416
- }
417
- authModalOpen = false;
418
- if (claims.exp) startRefreshLoop();
419
- await refreshChannels();
420
- if (selectedId) loadChannel();
421
- return true;
422
- }
423
-
424
- async function handleAuthLogout() {
425
- if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
426
- await authLogout(baseUrl);
427
- token = '';
428
- claims = null;
429
- localStorage.removeItem('zooid_token');
430
- authModalOpen = false;
431
- refreshChannels();
432
- if (selectedId) loadChannel();
433
- }
434
-
435
- /**
436
- * Refresh loop: checks token expiry and refreshes via the BFF
437
- * endpoint ~2 minutes before it expires.
438
- */
439
- function startRefreshLoop() {
440
- if (refreshTimer) clearInterval(refreshTimer);
441
- refreshTimer = setInterval(async () => {
442
- if (!claims?.exp) return;
443
- const now = Math.floor(Date.now() / 1000);
444
- const remaining = claims.exp - now;
445
-
446
- // Refresh when less than 2 minutes remain
447
- if (remaining < 120) {
448
- const result = await refreshAuth(baseUrl);
449
- if (result?.token) {
450
- token = result.token;
451
- localStorage.setItem('zooid_token', token);
452
- await validateToken();
453
- } else {
454
- // Refresh failed — sign out
455
- handleAuthLogout();
456
- }
457
- }
458
- }, 30_000); // Check every 30 seconds
459
- }
460
-
461
- // --- Publish ---
462
-
463
- async function handlePublish(payload: { type?: string; reply_to?: string; data: unknown }) {
464
- if (!selectedId || !token) return;
465
- try {
466
- await client.publish(selectedId, payload);
467
- await fetchEvents();
468
- } catch {
469
- // publish failed
470
- }
471
- }
472
-
473
- // --- Admin ---
474
-
475
- function handleServerConfigSaved() {
476
- // Refresh server name from discovery
477
- fetchServerMeta(baseUrl).then((meta) => {
478
- serverName = meta.server_name;
479
- });
480
- }
481
-
482
- function handleChannelCreated(id: string) {
483
- refreshChannels().then(() => {
484
- selectChannel(id);
485
- });
486
- }
487
-
488
- function handleChannelEdited() {
489
- refreshChannels();
490
- if (selectedId) loadChannel();
491
- }
492
-
493
- function handleChannelDeleted() {
494
- selectedId = null;
495
- channel = null;
496
- cleanup();
497
- window.history.pushState({}, '', '/');
498
- refreshChannels();
499
- }
500
-
501
- // --- Browser navigation ---
502
-
503
- window.addEventListener('popstate', () => {
504
- const p = window.location.pathname;
505
- const sm = p.match(/^\/_settings\/(.+)$/);
506
- if (sm) {
507
- currentView = sm[1];
508
- selectedId = null;
509
- channel = null;
510
- cleanup();
511
- if (sm[1] === 'keys-and-tokens') document.title = 'Keys & Tokens — Zooid';
512
- else if (sm[1] === 'server') document.title = 'Server Config — Zooid';
513
- else {
514
- const ext = extensions?.settingsPages?.find(pg => pg.slug === sm[1]);
515
- if (ext) document.title = `${ext.label} — Zooid`;
516
- }
517
- } else {
518
- currentView = 'channel';
519
- const m = p.match(/^\/([a-z0-9][a-z0-9-]{1,62}[a-z0-9])$/);
520
- if (m) {
521
- selectedId = m[1];
522
- loadChannel();
523
- } else {
524
- selectedId = null;
525
- channel = null;
526
- cleanup();
527
- }
528
- }
529
- });
530
-
531
- init();
532
- </script>
533
-
534
- <div class="flex h-dvh w-full overflow-hidden">
535
- <!-- Sidebar: always visible on md+, slide-over on mobile -->
536
- <div class="hidden md:flex w-60 shrink-0">
537
- <Sidebar
538
- {channels}
539
- {selectedId}
540
- {serverName}
541
- hasAuth={!!claims}
542
- {isAdmin}
543
- {status}
544
- {pollInterval}
545
- onSelect={selectChannel}
546
- onAuthClick={handleAuthClick}
547
- onServerConfig={() => navigateSettings('server', 'Server Config')}
548
- onKeysAndTokens={() => navigateSettings('keys-and-tokens', 'Keys & Tokens')}
549
- onCreateChannel={() => createChannelOpen = true}
550
- extensionSettingsPages={extensions?.settingsPages?.map(p => ({ slug: p.slug, label: p.label, icon: p.icon })) ?? []}
551
- onExtensionSettings={(slug) => { const ext = extensions?.settingsPages?.find(p => p.slug === slug); if (ext) navigateSettings(slug, ext.label); }}
552
- />
553
- </div>
554
-
555
- <!-- Mobile sidebar overlay -->
556
- {#if sidebarOpen}
557
- <button type="button" class="fixed inset-0 bg-background/60 z-40 md:hidden" onclick={() => sidebarOpen = false} aria-label="Close sidebar"></button>
558
- <div class="fixed inset-y-0 left-0 w-60 z-50 md:hidden">
559
- <Sidebar
560
- {channels}
561
- {selectedId}
562
- {serverName}
563
- hasAuth={!!claims}
564
- {isAdmin}
565
- {status}
566
- {pollInterval}
567
- onSelect={selectChannel}
568
- onAuthClick={() => { sidebarOpen = false; handleAuthClick(); }}
569
- onClose={() => sidebarOpen = false}
570
- onServerConfig={() => navigateSettings('server', 'Server Config')}
571
- onKeysAndTokens={() => navigateSettings('keys-and-tokens', 'Keys & Tokens')}
572
- onCreateChannel={() => { sidebarOpen = false; createChannelOpen = true; }}
573
- extensionSettingsPages={extensions?.settingsPages?.map(p => ({ slug: p.slug, label: p.label, icon: p.icon })) ?? []}
574
- onExtensionSettings={(slug) => { const ext = extensions?.settingsPages?.find(p => p.slug === slug); if (ext) navigateSettings(slug, ext.label); }}
575
- />
576
- </div>
577
- {/if}
578
-
579
- <!-- Main content -->
580
- {#if currentView === 'keys-and-tokens'}
581
- <KeysAndTokensPage {client} onMenuClick={() => sidebarOpen = true} />
582
- {:else if currentView === 'server'}
583
- <ServerConfigPage {client} onMenuClick={() => sidebarOpen = true} onSaved={handleServerConfigSaved} />
584
- {:else if extensions?.settingsPages?.some(p => p.slug === currentView)}
585
- {@const extPage = extensions.settingsPages.find(p => p.slug === currentView)!}
586
- <extPage.component {client} onMenuClick={() => sidebarOpen = true} />
587
- {:else}
588
- <div class="flex-1 flex flex-col min-w-0">
589
- {#if channel}
590
- <ChannelHeader {channel} bind:viewMode {isAdmin} onMenuClick={() => sidebarOpen = true} onEditChannel={() => editChannelOpen = true} />
591
- <EventFeed {events} {viewMode} {canReply} onReply={handleReply} onOpenRef={handleOpenRef} />
592
- {#if canPublishToChannel}
593
- <MessageBar {channel} bind:replyTo onPublish={handlePublish} />
594
- {:else if claims}
595
- <div class="border-t border-border mx-4 mb-3 mt-1 mb-[calc(0.75rem+env(safe-area-inset-bottom))] px-3 py-2 rounded-lg border bg-secondary/20 text-xs text-muted-foreground/50 text-center">
596
- You don't have publish access to this channel
597
- </div>
598
- {:else}
599
- <div class="border-t border-border mx-4 mb-3 mt-1 mb-[calc(0.75rem+env(safe-area-inset-bottom))] px-3 py-2 rounded-lg border bg-secondary/20 text-xs text-muted-foreground/50 text-center">
600
- <button onclick={handleAuthClick} class="hover:text-muted-foreground transition-colors">Sign in to chat</button>
601
- </div>
602
- {/if}
603
- {:else if selectedId}
604
- <!-- Channel loading or not found -->
605
- <div class="flex-1 flex flex-col">
606
- <div class="flex items-center gap-3 px-4 h-12 border-b border-border">
607
- <button
608
- onclick={() => sidebarOpen = true}
609
- class="p-1 rounded hover:bg-secondary transition-colors md:hidden"
610
- aria-label="Open channels"
611
- >
612
- <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>
613
- </button>
614
- <span class="text-sm text-muted-foreground">
615
- {status === 'loading' ? 'Loading...' : 'Channel not found'}
616
- </span>
617
- </div>
618
- </div>
619
- {:else}
620
- <!-- No channel selected -->
621
- <div class="flex-1 flex flex-col items-center justify-center text-muted-foreground">
622
- <button
623
- onclick={() => sidebarOpen = true}
624
- class="p-2 rounded hover:bg-secondary transition-colors md:hidden mb-4"
625
- aria-label="Open channels"
626
- >
627
- <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>
628
- </button>
629
- <p class="text-sm">Select a channel</p>
630
- {#if isAdmin}
631
- <button
632
- class="text-xs text-primary hover:text-primary/80 mt-1 transition-colors"
633
- onclick={() => createChannelOpen = true}
634
- >Create a channel</button>
635
- {:else}
636
- <p class="text-xs text-muted-foreground/60 mt-1">or create one with <code class="text-foreground/80">npx zooid channel create</code></p>
637
- {/if}
638
- </div>
639
- {/if}
640
- </div>
641
- {/if}
642
- </div>
643
-
644
- <AuthModal
645
- open={authModalOpen}
646
- currentToken={claims ? token : null}
647
- {claims}
648
- onSave={handleAuthSave}
649
- onLogout={handleAuthLogout}
650
- onClose={() => authModalOpen = false}
651
- />
652
-
653
- <CreateChannelModal
654
- open={createChannelOpen}
655
- {client}
656
- defaultConfig={channelConfigDefaults}
657
- onClose={() => createChannelOpen = false}
658
- onCreated={handleChannelCreated}
659
- />
660
-
661
- <EditChannelModal
662
- open={editChannelOpen}
663
- {channel}
664
- {client}
665
- defaultConfig={channelConfigDefaults}
666
- onClose={() => editChannelOpen = false}
667
- onSaved={handleChannelEdited}
668
- onDeleted={handleChannelDeleted}
669
- />
670
-
671
- {#if refSheet}
672
- <RefSideSheet
673
- channel={refSheet.channel}
674
- eventId={refSheet.eventId}
675
- {client}
676
- onClose={() => refSheet = null}
677
- />
678
- {/if}