@zooid/web 0.5.1 → 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.
- package/dist/assets/index-7P1i28a8.js +66 -0
- package/dist/assets/index-BeqmFlCW.css +1 -0
- package/dist/assets/json-editor-Cvlnnf1Q.css +1 -0
- package/dist/assets/json-editor-iJIPDKxB.js +84 -0
- package/dist/index.html +2 -2
- package/package.json +11 -5
- package/src/App.svelte +678 -0
- package/src/app.css +178 -0
- package/src/lib/api.ts +78 -0
- package/src/lib/components/admin-dropdown.svelte +89 -0
- package/src/lib/components/auth-modal.svelte +114 -0
- package/src/lib/components/avatar.svelte +29 -0
- package/src/lib/components/channel-header.svelte +73 -0
- package/src/lib/components/create-channel-modal.svelte +137 -0
- package/src/lib/components/edit-channel-modal.svelte +234 -0
- package/src/lib/components/event-card.svelte +221 -0
- package/src/lib/components/event-feed.svelte +50 -0
- package/src/lib/components/homepage.svelte +86 -0
- package/src/lib/components/json-editor.svelte +57 -0
- package/src/lib/components/keys-and-tokens-page.svelte +216 -0
- package/src/lib/components/keys-modal.svelte +120 -0
- package/src/lib/components/message-bar.svelte +290 -0
- package/src/lib/components/mint-token-modal.svelte +141 -0
- package/src/lib/components/ref-link.svelte +33 -0
- package/src/lib/components/ref-side-sheet.svelte +105 -0
- package/src/lib/components/server-config-modal.svelte +141 -0
- package/src/lib/components/server-config-page.svelte +130 -0
- package/src/lib/components/sidebar.svelte +144 -0
- package/src/lib/components/status-bar.svelte +40 -0
- package/src/lib/pretty-json.test.ts +200 -0
- package/src/lib/pretty-json.ts +79 -0
- package/src/lib/time.ts +38 -0
- package/src/lib/zooid-uri.test.ts +102 -0
- package/src/lib/zooid-uri.ts +74 -0
- package/src/main.ts +7 -0
- package/src/vite-env.d.ts +2 -0
- package/dist/assets/index-BNtiKsjP.js +0 -66
- package/dist/assets/index-C_9lWQjz.css +0 -1
- package/dist/assets/json-editor-De2WPcl1.js +0 -84
- package/dist/assets/json-editor-DfH04Znl.css +0 -1
package/src/App.svelte
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
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}
|