@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
@@ -1,290 +0,0 @@
1
- <script lang="ts">
2
- import type { Content, JSONContent } from 'svelte-jsoneditor';
3
- import type { ChannelInfo } from '../api';
4
-
5
- let {
6
- channel,
7
- replyTo = $bindable(null),
8
- onPublish,
9
- }: {
10
- channel: ChannelInfo;
11
- replyTo?: string | null;
12
- onPublish: (payload: { type?: string; reply_to?: string; data: unknown }) => void;
13
- } = $props();
14
-
15
- let textInput = $state('');
16
- let editorContent = $state<Content>({ json: {} });
17
- let selectedType = $state<string>('message');
18
- let customType = $state('');
19
- let sending = $state(false);
20
- let typeDropdownOpen = $state(false);
21
- let JsonEditor = $state<typeof import('./json-editor.svelte').default | null>(null);
22
-
23
- // Lazy-load the JSON editor component
24
- async function loadEditor() {
25
- if (!JsonEditor) {
26
- const mod = await import('./json-editor.svelte');
27
- JsonEditor = mod.default;
28
- }
29
- }
30
-
31
- // Extract types from channel config
32
- let eventTypes = $derived.by(() => {
33
- const config = channel.config as { strict?: boolean; types?: Record<string, { schema?: Record<string, unknown> }> } | null;
34
- if (!config?.types) return [];
35
- const types = Object.keys(config.types);
36
- // Non-strict channels: ensure "message" is available for replies
37
- if (!config.strict && types.length > 0 && !types.includes('message')) {
38
- return [...types, 'message'];
39
- }
40
- return types;
41
- });
42
-
43
- // The active type name (from dropdown or custom input)
44
- let activeType = $derived(eventTypes.length > 0 ? selectedType : (customType.trim() || 'message'));
45
-
46
- // Get schema for selected type
47
- let selectedSchema = $derived.by(() => {
48
- if (!activeType) return null;
49
- const config = channel.config as { types?: Record<string, { schema?: Record<string, unknown> }> } | null;
50
- return config?.types?.[activeType]?.schema ?? null;
51
- });
52
-
53
- // Check if we should use the JSON editor
54
- let useJsonEditor = $derived.by(() => {
55
- if (!selectedSchema) return false;
56
- const props = (selectedSchema as { properties?: Record<string, { type?: string }> }).properties;
57
- if (!props) return false;
58
- const keys = Object.keys(props);
59
- // Single-key string schema = free text
60
- if (keys.length === 1 && props[keys[0]]?.type === 'string') return false;
61
- return true;
62
- });
63
-
64
- let freeTextKey = $derived.by(() => {
65
- if (useJsonEditor || !selectedSchema) return null;
66
- const props = (selectedSchema as { properties?: Record<string, unknown> }).properties;
67
- return props ? Object.keys(props)[0] : null;
68
- });
69
-
70
- // Generate a template object from schema properties
71
- function templateFromSchema(schema: Record<string, unknown> | null): Record<string, unknown> {
72
- if (!schema) return {};
73
- const props = (schema as { properties?: Record<string, { type?: string; enum?: unknown[]; default?: unknown }> }).properties;
74
- if (!props) return {};
75
- const obj: Record<string, unknown> = {};
76
- for (const [key, def] of Object.entries(props)) {
77
- if (def.default !== undefined) {
78
- obj[key] = def.default;
79
- } else if (def.enum && def.enum.length > 0) {
80
- obj[key] = def.enum[0];
81
- } else {
82
- const defaults: Record<string, unknown> = {
83
- string: '',
84
- number: 0,
85
- boolean: false,
86
- array: [],
87
- object: {},
88
- };
89
- obj[key] = defaults[def.type ?? 'string'] ?? '';
90
- }
91
- }
92
- return obj;
93
- }
94
-
95
- function getSchemaForType(typeName: string): Record<string, unknown> | null {
96
- const config = channel.config as { types?: Record<string, { schema?: Record<string, unknown> }> } | null;
97
- return config?.types?.[typeName]?.schema ?? null;
98
- }
99
-
100
- // When replying, switch type to "message"
101
- let lastReplyTo: string | null = null;
102
- $effect(() => {
103
- if (replyTo && replyTo !== lastReplyTo) {
104
- selectedType = 'message';
105
- }
106
- lastReplyTo = replyTo;
107
- });
108
-
109
- // Reset state when channel or type changes
110
- let lastChannelId = '';
111
- let lastType = '';
112
- $effect(() => {
113
- const channelChanged = channel.id !== lastChannelId;
114
- const typeChanged = activeType !== lastType;
115
-
116
- if (channelChanged) {
117
- lastChannelId = channel.id;
118
- textInput = '';
119
- customType = '';
120
- const config = channel.config as { types?: Record<string, unknown> } | null;
121
- const types = config?.types ? Object.keys(config.types) : [];
122
- selectedType = types[0] ?? 'message';
123
- }
124
-
125
- if (channelChanged || typeChanged) {
126
- lastType = activeType;
127
- const schema = getSchemaForType(activeType);
128
- editorContent = { json: templateFromSchema(schema) };
129
- if (useJsonEditor) loadEditor();
130
- }
131
- });
132
-
133
- async function handleSubmit(e?: Event) {
134
- e?.preventDefault();
135
- if (sending) return;
136
-
137
- sending = true;
138
- try {
139
- if (!useJsonEditor) {
140
- const trimmed = textInput.trim();
141
- if (!trimmed) return;
142
-
143
- let data: unknown;
144
- if (freeTextKey) {
145
- data = { [freeTextKey]: trimmed };
146
- } else {
147
- // Try to parse as JSON, fall back to { body: ... }
148
- try {
149
- data = JSON.parse(trimmed);
150
- } catch {
151
- data = { body: trimmed };
152
- }
153
- }
154
-
155
- onPublish({ type: activeType, reply_to: replyTo ?? undefined, data });
156
- textInput = '';
157
- replyTo = null;
158
- } else {
159
- // JSON editor mode
160
- let data: unknown;
161
- if ('json' in editorContent) {
162
- data = (editorContent as JSONContent).json;
163
- } else if ('text' in editorContent) {
164
- data = JSON.parse((editorContent as { text: string }).text);
165
- }
166
-
167
- onPublish({ type: activeType, data });
168
- editorContent = { json: templateFromSchema(selectedSchema) };
169
- }
170
- } catch {
171
- // Invalid JSON — don't clear
172
- } finally {
173
- sending = false;
174
- }
175
- }
176
-
177
- function handleKeydown(e: KeyboardEvent) {
178
- if (e.key === 'Enter' && !e.shiftKey) {
179
- e.preventDefault();
180
- handleSubmit();
181
- }
182
- }
183
-
184
- function selectType(t: string) {
185
- selectedType = t;
186
- typeDropdownOpen = false;
187
- }
188
- </script>
189
-
190
- <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
191
- <div class="border-t border-border mx-4 mb-3 mt-1 mb-[calc(0.75rem+env(safe-area-inset-bottom))] rounded-lg border bg-secondary/30 max-h-[250px] flex flex-col">
192
- <!-- Top row: type selector -->
193
- <div class="flex items-center gap-2 px-3 py-1.5 border-b border-border/50">
194
- {#if eventTypes.length > 0}
195
- <!-- Dropdown for configured types -->
196
- <div class="relative">
197
- <button
198
- class="flex items-center gap-1 px-2 py-0.5 rounded text-[11px] bg-secondary text-muted-foreground hover:text-foreground transition-colors"
199
- onclick={() => typeDropdownOpen = !typeDropdownOpen}
200
- >
201
- <span class="font-mono">{activeType}</span>
202
- <svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
203
- </button>
204
- {#if typeDropdownOpen}
205
- <div class="absolute bottom-full left-0 mb-1 bg-popover border border-border rounded-md shadow-lg py-1 z-10 min-w-[120px]">
206
- {#each eventTypes as t (t)}
207
- <button
208
- class="w-full text-left px-3 py-1 text-[11px] font-mono transition-colors
209
- {activeType === t ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'}"
210
- onclick={() => selectType(t)}
211
- >
212
- {t}
213
- </button>
214
- {/each}
215
- </div>
216
- {/if}
217
- </div>
218
- {:else}
219
- <!-- Free text type input -->
220
- <div class="flex items-center gap-1">
221
- <span class="text-[10px] text-muted-foreground/50">type:</span>
222
- <input
223
- type="text"
224
- class="bg-transparent border-none outline-none text-[11px] font-mono text-muted-foreground w-20 placeholder:text-muted-foreground/30"
225
- placeholder="message"
226
- bind:value={customType}
227
- />
228
- </div>
229
- {/if}
230
- </div>
231
-
232
- <!-- Reply indicator -->
233
- {#if replyTo}
234
- <div class="flex items-center gap-2 px-3 py-1 border-b border-border/50 text-[11px] text-muted-foreground">
235
- <svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" 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>
236
- <span class="font-mono truncate">Replying to {replyTo.slice(0, 12)}...</span>
237
- <button
238
- class="ml-auto text-muted-foreground/60 hover:text-foreground transition-colors"
239
- onclick={() => replyTo = null}
240
- aria-label="Cancel reply"
241
- >
242
- <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"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
243
- </button>
244
- </div>
245
- {/if}
246
-
247
- <!-- Middle: input area -->
248
- <div class="px-3 py-2 flex-1 overflow-auto min-h-0">
249
- {#if !useJsonEditor}
250
- <input
251
- type="text"
252
- class="w-full bg-transparent outline-none text-sm text-foreground placeholder:text-muted-foreground/40"
253
- placeholder="Message #{channel.id}"
254
- bind:value={textInput}
255
- onkeydown={handleKeydown}
256
- autocomplete="off"
257
- />
258
- {:else if JsonEditor}
259
- <JsonEditor
260
- bind:content={editorContent}
261
- schema={selectedSchema}
262
- />
263
- {:else}
264
- <div class="text-sm text-muted-foreground/40 py-1">Loading editor...</div>
265
- {/if}
266
- </div>
267
-
268
- <!-- Bottom row: toolbar + send -->
269
- <div class="flex items-center justify-between px-3 py-1.5 border-t border-border/50">
270
- <div class="flex items-center gap-1">
271
- <!-- Placeholder slots for future toolbar buttons (markdown, emoji, etc.) -->
272
- </div>
273
- <button
274
- class="flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors
275
- {(useJsonEditor || textInput.trim()) && !sending
276
- ? 'text-foreground hover:bg-secondary'
277
- : 'text-muted-foreground/30 cursor-default'}"
278
- disabled={!useJsonEditor && !textInput.trim() || sending}
279
- onclick={() => handleSubmit()}
280
- aria-label="Send"
281
- >
282
- <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="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/></svg>
283
- </button>
284
- </div>
285
- </div>
286
-
287
- {#if typeDropdownOpen}
288
- <!-- Backdrop to close dropdown -->
289
- <button type="button" class="fixed inset-0 z-5" onclick={() => typeDropdownOpen = false} aria-label="Close dropdown"></button>
290
- {/if}
@@ -1,141 +0,0 @@
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
-
6
- let {
7
- open,
8
- client,
9
- onClose,
10
- }: {
11
- open: boolean;
12
- client: ZooidClient;
13
- onClose: () => void;
14
- } = $props();
15
-
16
- let scopes = $state('pub:*, sub:*');
17
- let sub = $state('');
18
- let name = $state('');
19
- let expiresIn = $state('');
20
- let minting = $state(false);
21
- let error = $state('');
22
- let mintedToken = $state('');
23
- let copied = $state(false);
24
-
25
- $effect(() => {
26
- if (open) {
27
- error = '';
28
- mintedToken = '';
29
- copied = false;
30
- }
31
- });
32
-
33
- async function handleMint(e: Event) {
34
- e.preventDefault();
35
- minting = true;
36
- error = '';
37
- mintedToken = '';
38
- copied = false;
39
-
40
- const scopeList = scopes.split(',').map((s) => s.trim()).filter(Boolean);
41
- if (scopeList.length === 0) {
42
- error = 'At least one scope is required';
43
- minting = false;
44
- return;
45
- }
46
-
47
- try {
48
- const result = await client.mintToken({
49
- scopes: scopeList,
50
- sub: sub || undefined,
51
- name: name || undefined,
52
- expires_in: expiresIn || undefined,
53
- });
54
- mintedToken = result.token;
55
- } catch (err) {
56
- error = err instanceof Error ? err.message : 'Failed to mint token';
57
- } finally {
58
- minting = false;
59
- }
60
- }
61
-
62
- async function copyToken() {
63
- await navigator.clipboard.writeText(mintedToken);
64
- copied = true;
65
- setTimeout(() => { copied = false; }, 2000);
66
- }
67
-
68
- function handleBackdrop(e: MouseEvent) {
69
- if (e.target === e.currentTarget) onClose();
70
- }
71
-
72
- function handleKeydown(e: KeyboardEvent) {
73
- if (e.key === 'Escape') onClose();
74
- }
75
- </script>
76
-
77
- {#if open}
78
- <div
79
- class="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4"
80
- role="dialog"
81
- aria-modal="true"
82
- aria-label="Mint token"
83
- tabindex="-1"
84
- onclick={handleBackdrop}
85
- onkeydown={handleKeydown}
86
- >
87
- <div class="bg-card border border-border rounded-lg shadow-lg w-full max-w-sm p-6">
88
- <h2 class="font-semibold text-sm mb-1">Mint Token</h2>
89
- <p class="text-xs text-muted-foreground mb-4">
90
- Create a new JWT with custom scopes.
91
- </p>
92
-
93
- {#if mintedToken}
94
- <div class="flex flex-col gap-3">
95
- <div class="bg-secondary rounded-md p-3">
96
- <div class="text-[10px] text-muted-foreground mb-1 uppercase tracking-wider">Token (shown once)</div>
97
- <div class="text-xs font-mono break-all select-all max-h-24 overflow-y-auto">{mintedToken}</div>
98
- </div>
99
- <div class="flex gap-2">
100
- <Button size="sm" class="flex-1" onclick={copyToken}>
101
- {copied ? 'Copied!' : 'Copy'}
102
- </Button>
103
- <Button variant="outline" size="sm" class="flex-1" onclick={onClose}>Done</Button>
104
- </div>
105
- </div>
106
- {:else}
107
- <form onsubmit={handleMint} class="flex flex-col gap-3">
108
- <label class="flex flex-col gap-1">
109
- <span class="text-xs text-muted-foreground">Scopes (comma-separated)</span>
110
- <Input bind:value={scopes} placeholder="admin, pub:my-channel, sub:*" />
111
- <span class="text-[10px] text-muted-foreground/60">admin, pub:channel, sub:channel, pub:*, sub:*</span>
112
- </label>
113
- <label class="flex flex-col gap-1">
114
- <span class="text-xs text-muted-foreground">Subject (optional)</span>
115
- <Input bind:value={sub} placeholder="my-bot" />
116
- </label>
117
- <label class="flex flex-col gap-1">
118
- <span class="text-xs text-muted-foreground">Display name (optional)</span>
119
- <Input bind:value={name} placeholder="My Bot" />
120
- </label>
121
- <label class="flex flex-col gap-1">
122
- <span class="text-xs text-muted-foreground">Expires in (optional)</span>
123
- <Input bind:value={expiresIn} placeholder="7d, 1h, 30m" />
124
- <span class="text-[10px] text-muted-foreground/60">Leave empty for no expiry</span>
125
- </label>
126
-
127
- {#if error}
128
- <p class="text-xs text-destructive">{error}</p>
129
- {/if}
130
-
131
- <div class="flex gap-2 mt-1">
132
- <Button type="submit" size="sm" class="flex-1" disabled={minting || !scopes.trim()}>
133
- {minting ? 'Minting...' : 'Mint'}
134
- </Button>
135
- <Button variant="outline" size="sm" class="flex-1" onclick={onClose}>Cancel</Button>
136
- </div>
137
- </form>
138
- {/if}
139
- </div>
140
- </div>
141
- {/if}
@@ -1,33 +0,0 @@
1
- <script lang="ts">
2
- import { resolveRef } from '../zooid-uri';
3
-
4
- let {
5
- ref,
6
- serverUrl = '',
7
- onOpenRef,
8
- }: {
9
- ref: string;
10
- serverUrl?: string;
11
- onOpenRef?: (detail: { channel: string; eventId: string }) => void;
12
- } = $props();
13
-
14
- let resolved = $derived(resolveRef(ref, serverUrl));
15
-
16
- function handleClick() {
17
- if (resolved.type === 'zooid') {
18
- onOpenRef?.({ channel: resolved.channel, eventId: resolved.eventId });
19
- }
20
- }
21
- </script>
22
-
23
- {#if resolved.type === 'zooid'}
24
- <button class="text-xs font-mono text-primary/70 hover:text-primary bg-primary/10 hover:bg-primary/20 px-1.5 py-0 rounded cursor-pointer transition-colors" onclick={handleClick}>
25
- {resolved.label}
26
- </button>
27
- {:else if resolved.type === 'zooid-external' || resolved.type === 'external'}
28
- <a href={resolved.href} target="_blank" rel="noopener" class="text-xs font-mono text-primary/70 hover:text-primary bg-primary/10 hover:bg-primary/20 px-1.5 py-0 rounded transition-colors">
29
- {resolved.label}
30
- </a>
31
- {:else}
32
- <span class="text-xs font-mono text-muted-foreground">{resolved.label}</span>
33
- {/if}
@@ -1,105 +0,0 @@
1
- <script lang="ts">
2
- import type { ZooidEvent } from '../api';
3
- import EventCard from './event-card.svelte';
4
-
5
- let {
6
- channel,
7
- eventId,
8
- client,
9
- onClose,
10
- }: {
11
- channel: string;
12
- eventId: string;
13
- client: { poll: (channelId: string, options?: { cursor?: string; limit?: number }) => Promise<{ events: ZooidEvent[] }> };
14
- onClose?: () => void;
15
- } = $props();
16
-
17
- let event = $state<ZooidEvent | null>(null);
18
- let thread = $state<ZooidEvent[] | null>(null);
19
- let loading = $state(true);
20
- let threadExpanded = $state(false);
21
-
22
- async function fetchEvent() {
23
- loading = true;
24
- try {
25
- const res = await fetch(`/api/v1/channels/${channel}/events/${eventId}`);
26
- if (res.ok) {
27
- event = await res.json();
28
- }
29
- } catch {
30
- // fetch failed
31
- }
32
- loading = false;
33
- }
34
-
35
- async function expandThread() {
36
- try {
37
- const res = await fetch(`/api/v1/channels/${channel}/events/${eventId}/thread`);
38
- if (res.ok) {
39
- const body = await res.json();
40
- thread = body.events ?? [];
41
- }
42
- } catch {
43
- // fetch failed
44
- }
45
- threadExpanded = true;
46
- }
47
-
48
- function handleKeydown(e: KeyboardEvent) {
49
- if (e.key === 'Escape') onClose?.();
50
- }
51
-
52
- $effect(() => {
53
- if (channel && eventId) fetchEvent();
54
- });
55
- </script>
56
-
57
- <svelte:window onkeydown={handleKeydown} />
58
-
59
- <button
60
- type="button"
61
- class="fixed inset-0 bg-background/40 z-40"
62
- onclick={() => onClose?.()}
63
- aria-label="Close ref panel"
64
- ></button>
65
-
66
- <div class="fixed inset-y-0 right-0 w-80 max-w-[85vw] bg-card border-l border-border shadow-lg z-50 p-4 flex flex-col gap-3 animate-slide-in-right overflow-y-auto">
67
- <div class="flex items-center justify-between">
68
- <h3 class="text-sm font-semibold">#{channel} / {eventId.slice(0, 8)}&hellip;</h3>
69
- <button class="text-muted-foreground hover:text-foreground transition-colors" onclick={() => onClose?.()} aria-label="Close">
70
- <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>
71
- </button>
72
- </div>
73
-
74
- {#if loading}
75
- <div class="text-sm text-muted-foreground">Loading&hellip;</div>
76
- {:else if event}
77
- <EventCard {event} />
78
-
79
- {#if !threadExpanded}
80
- <button class="text-xs text-primary hover:text-primary/80 transition-colors" onclick={expandThread}>
81
- Expand thread
82
- </button>
83
- {:else if thread && thread.length > 0}
84
- <div class="flex flex-col gap-1 border-t border-border pt-2">
85
- {#each thread as threadEvent}
86
- <EventCard event={threadEvent} />
87
- {/each}
88
- </div>
89
- {:else if thread}
90
- <div class="text-xs text-muted-foreground">No replies</div>
91
- {/if}
92
- {:else}
93
- <div class="text-sm text-muted-foreground">Event not found</div>
94
- {/if}
95
- </div>
96
-
97
- <style>
98
- @keyframes slide-in-right {
99
- from { transform: translateX(100%); }
100
- to { transform: translateX(0); }
101
- }
102
- .animate-slide-in-right {
103
- animation: slide-in-right 0.15s ease-out;
104
- }
105
- </style>