@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,216 @@
1
+ <script lang="ts">
2
+ import { Button } from '@ui/components/button/index';
3
+ import { Input } from '@ui/components/input/index';
4
+ import type { ZooidClient, TrustedKey } from '@zooid/sdk';
5
+ import { formatRelative } from '../time';
6
+
7
+ let {
8
+ client,
9
+ onMenuClick,
10
+ }: {
11
+ client: ZooidClient;
12
+ onMenuClick: () => void;
13
+ } = $props();
14
+
15
+ // --- Keys ---
16
+ let keys = $state<TrustedKey[]>([]);
17
+ let keysLoading = $state(false);
18
+ let keysError = $state('');
19
+ let revoking = $state<string | null>(null);
20
+
21
+ async function loadKeys() {
22
+ keysLoading = true;
23
+ keysError = '';
24
+ try {
25
+ keys = await client.listKeys();
26
+ } catch (err) {
27
+ keysError = err instanceof Error ? err.message : 'Failed to load keys';
28
+ } finally {
29
+ keysLoading = false;
30
+ }
31
+ }
32
+
33
+ async function handleRevoke(kid: string) {
34
+ if (!confirm(`Revoke key "${kid}"? Tokens signed by this key will stop working.`)) return;
35
+ revoking = kid;
36
+ keysError = '';
37
+ try {
38
+ await client.revokeKey(kid);
39
+ keys = keys.filter((k) => k.kid !== kid);
40
+ } catch (err) {
41
+ keysError = err instanceof Error ? err.message : 'Failed to revoke key';
42
+ } finally {
43
+ revoking = null;
44
+ }
45
+ }
46
+
47
+ // --- Mint token ---
48
+ let scopes = $state('pub:*, sub:*');
49
+ let sub = $state('');
50
+ let name = $state('');
51
+ let expiresIn = $state('');
52
+ let minting = $state(false);
53
+ let mintError = $state('');
54
+ let mintedToken = $state('');
55
+ let copied = $state(false);
56
+
57
+ async function handleMint(e: Event) {
58
+ e.preventDefault();
59
+ minting = true;
60
+ mintError = '';
61
+ mintedToken = '';
62
+ copied = false;
63
+
64
+ const scopeList = scopes.split(',').map((s) => s.trim()).filter(Boolean);
65
+ if (scopeList.length === 0) {
66
+ mintError = 'At least one scope is required';
67
+ minting = false;
68
+ return;
69
+ }
70
+
71
+ try {
72
+ const result = await client.mintToken({
73
+ scopes: scopeList,
74
+ sub: sub || undefined,
75
+ name: name || undefined,
76
+ expires_in: expiresIn || undefined,
77
+ });
78
+ mintedToken = result.token;
79
+ } catch (err) {
80
+ mintError = err instanceof Error ? err.message : 'Failed to mint token';
81
+ } finally {
82
+ minting = false;
83
+ }
84
+ }
85
+
86
+ async function copyToken() {
87
+ await navigator.clipboard.writeText(mintedToken);
88
+ copied = true;
89
+ setTimeout(() => { copied = false; }, 2000);
90
+ }
91
+
92
+ function resetMint() {
93
+ mintedToken = '';
94
+ mintError = '';
95
+ copied = false;
96
+ }
97
+
98
+ // Load keys on mount
99
+ loadKeys();
100
+ </script>
101
+
102
+ <div class="flex-1 flex flex-col min-w-0">
103
+ <!-- Header -->
104
+ <div class="flex items-center gap-3 px-4 h-12 border-b border-border shrink-0">
105
+ <button
106
+ onclick={onMenuClick}
107
+ class="p-1 rounded hover:bg-secondary transition-colors md:hidden"
108
+ aria-label="Open channels"
109
+ >
110
+ <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>
111
+ </button>
112
+ <h1 class="text-sm font-semibold">Keys & Tokens</h1>
113
+ </div>
114
+
115
+ <!-- Content -->
116
+ <div class="flex-1 overflow-y-auto p-4 md:p-6">
117
+ <div class="max-w-lg mx-auto flex flex-col gap-8">
118
+
119
+ <!-- Mint Token section -->
120
+ <section>
121
+ <h2 class="font-semibold text-sm mb-1">Mint Token</h2>
122
+ <p class="text-xs text-muted-foreground mb-4">Create a new JWT with custom scopes.</p>
123
+
124
+ {#if mintedToken}
125
+ <div class="flex flex-col gap-3">
126
+ <div class="bg-secondary rounded-md p-3">
127
+ <div class="text-[10px] text-muted-foreground mb-1 uppercase tracking-wider">Token (shown once)</div>
128
+ <div class="text-xs font-mono break-all select-all max-h-24 overflow-y-auto">{mintedToken}</div>
129
+ </div>
130
+ <div class="flex gap-2">
131
+ <Button size="sm" class="flex-1" onclick={copyToken}>
132
+ {copied ? 'Copied!' : 'Copy'}
133
+ </Button>
134
+ <Button variant="outline" size="sm" class="flex-1" onclick={resetMint}>Mint another</Button>
135
+ </div>
136
+ </div>
137
+ {:else}
138
+ <form onsubmit={handleMint} class="flex flex-col gap-3">
139
+ <label class="flex flex-col gap-1">
140
+ <span class="text-xs text-muted-foreground">Scopes (comma-separated)</span>
141
+ <Input bind:value={scopes} placeholder="admin, pub:my-channel, sub:*" />
142
+ <span class="text-[10px] text-muted-foreground/60">admin, pub:channel, sub:channel, pub:*, sub:*</span>
143
+ </label>
144
+ <label class="flex flex-col gap-1">
145
+ <span class="text-xs text-muted-foreground">Subject (optional)</span>
146
+ <Input bind:value={sub} placeholder="my-bot" />
147
+ </label>
148
+ <label class="flex flex-col gap-1">
149
+ <span class="text-xs text-muted-foreground">Display name (optional)</span>
150
+ <Input bind:value={name} placeholder="My Bot" />
151
+ </label>
152
+ <label class="flex flex-col gap-1">
153
+ <span class="text-xs text-muted-foreground">Expires in (optional)</span>
154
+ <Input bind:value={expiresIn} placeholder="7d, 1h, 30m" />
155
+ <span class="text-[10px] text-muted-foreground/60">Leave empty for no expiry</span>
156
+ </label>
157
+
158
+ {#if mintError}
159
+ <p class="text-xs text-destructive">{mintError}</p>
160
+ {/if}
161
+
162
+ <div class="flex gap-2 mt-1">
163
+ <Button type="submit" size="sm" disabled={minting || !scopes.trim()}>
164
+ {minting ? 'Minting...' : 'Mint'}
165
+ </Button>
166
+ </div>
167
+ </form>
168
+ {/if}
169
+ </section>
170
+
171
+ <!-- Signing Keys section -->
172
+ <section>
173
+ <h2 class="font-semibold text-sm mb-1">Signing Keys</h2>
174
+ <p class="text-xs text-muted-foreground mb-4">Trusted keys used to verify JWT tokens.</p>
175
+
176
+ {#if keysLoading}
177
+ <p class="text-xs text-muted-foreground">Loading...</p>
178
+ {:else if keys.length === 0}
179
+ <p class="text-xs text-muted-foreground/60 text-center py-4">No keys found</p>
180
+ {:else}
181
+ <div class="flex flex-col gap-2">
182
+ {#each keys as key (key.kid)}
183
+ <div class="bg-secondary rounded-md px-3 py-2 flex items-start justify-between gap-2">
184
+ <div class="min-w-0 flex-1">
185
+ <div class="text-xs font-mono font-medium truncate">{key.kid}</div>
186
+ <div class="text-[10px] text-muted-foreground mt-0.5">
187
+ {key.crv} · {key.issuer ?? 'unknown issuer'} · {formatRelative(key.created_at)}
188
+ </div>
189
+ {#if key.max_scopes}
190
+ <div class="text-[10px] text-muted-foreground/60 mt-0.5 font-mono truncate">
191
+ {key.max_scopes.join(', ')}
192
+ </div>
193
+ {/if}
194
+ </div>
195
+ <Button
196
+ variant="destructive"
197
+ size="sm"
198
+ class="shrink-0 text-[10px] h-6 px-2"
199
+ disabled={revoking === key.kid}
200
+ onclick={() => handleRevoke(key.kid)}
201
+ >
202
+ {revoking === key.kid ? '...' : 'Revoke'}
203
+ </Button>
204
+ </div>
205
+ {/each}
206
+ </div>
207
+ {/if}
208
+
209
+ {#if keysError}
210
+ <p class="text-xs text-destructive mt-3">{keysError}</p>
211
+ {/if}
212
+ </section>
213
+
214
+ </div>
215
+ </div>
216
+ </div>
@@ -0,0 +1,120 @@
1
+ <script lang="ts">
2
+ import { Button } from '@ui/components/button/index';
3
+ import type { ZooidClient, TrustedKey } from '@zooid/sdk';
4
+ import { formatRelative } from '../time';
5
+
6
+ let {
7
+ open,
8
+ client,
9
+ onClose,
10
+ }: {
11
+ open: boolean;
12
+ client: ZooidClient;
13
+ onClose: () => void;
14
+ } = $props();
15
+
16
+ let keys = $state<TrustedKey[]>([]);
17
+ let loading = $state(false);
18
+ let error = $state('');
19
+ let revoking = $state<string | null>(null);
20
+
21
+ $effect(() => {
22
+ if (open) {
23
+ error = '';
24
+ loadKeys();
25
+ }
26
+ });
27
+
28
+ async function loadKeys() {
29
+ loading = true;
30
+ try {
31
+ keys = await client.listKeys();
32
+ } catch (err) {
33
+ error = err instanceof Error ? err.message : 'Failed to load keys';
34
+ } finally {
35
+ loading = false;
36
+ }
37
+ }
38
+
39
+ async function handleRevoke(kid: string) {
40
+ if (!confirm(`Revoke key "${kid}"? Tokens signed by this key will stop working.`)) return;
41
+ revoking = kid;
42
+ error = '';
43
+ try {
44
+ await client.revokeKey(kid);
45
+ keys = keys.filter((k) => k.kid !== kid);
46
+ } catch (err) {
47
+ error = err instanceof Error ? err.message : 'Failed to revoke key';
48
+ } finally {
49
+ revoking = null;
50
+ }
51
+ }
52
+
53
+ function handleBackdrop(e: MouseEvent) {
54
+ if (e.target === e.currentTarget) onClose();
55
+ }
56
+
57
+ function handleKeydown(e: KeyboardEvent) {
58
+ if (e.key === 'Escape') onClose();
59
+ }
60
+ </script>
61
+
62
+ {#if open}
63
+ <div
64
+ class="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4"
65
+ role="dialog"
66
+ aria-modal="true"
67
+ aria-label="Signing keys"
68
+ tabindex="-1"
69
+ onclick={handleBackdrop}
70
+ onkeydown={handleKeydown}
71
+ >
72
+ <div class="bg-card border border-border rounded-lg shadow-lg w-full max-w-md p-6">
73
+ <h2 class="font-semibold text-sm mb-1">Signing Keys</h2>
74
+ <p class="text-xs text-muted-foreground mb-4">
75
+ Trusted keys used to verify JWT tokens.
76
+ </p>
77
+
78
+ {#if loading}
79
+ <p class="text-xs text-muted-foreground">Loading...</p>
80
+ {:else if keys.length === 0}
81
+ <p class="text-xs text-muted-foreground/60 text-center py-4">No keys found</p>
82
+ {:else}
83
+ <div class="flex flex-col gap-2 max-h-64 overflow-y-auto">
84
+ {#each keys as key (key.kid)}
85
+ <div class="bg-secondary rounded-md px-3 py-2 flex items-start justify-between gap-2">
86
+ <div class="min-w-0 flex-1">
87
+ <div class="text-xs font-mono font-medium truncate">{key.kid}</div>
88
+ <div class="text-[10px] text-muted-foreground mt-0.5">
89
+ {key.crv} · {key.issuer ?? 'unknown issuer'} · {formatRelative(key.created_at)}
90
+ </div>
91
+ {#if key.max_scopes}
92
+ <div class="text-[10px] text-muted-foreground/60 mt-0.5 font-mono truncate">
93
+ {key.max_scopes.join(', ')}
94
+ </div>
95
+ {/if}
96
+ </div>
97
+ <Button
98
+ variant="destructive"
99
+ size="sm"
100
+ class="shrink-0 text-[10px] h-6 px-2"
101
+ disabled={revoking === key.kid}
102
+ onclick={() => handleRevoke(key.kid)}
103
+ >
104
+ {revoking === key.kid ? '...' : 'Revoke'}
105
+ </Button>
106
+ </div>
107
+ {/each}
108
+ </div>
109
+ {/if}
110
+
111
+ {#if error}
112
+ <p class="text-xs text-destructive mt-3">{error}</p>
113
+ {/if}
114
+
115
+ <div class="flex justify-end mt-4">
116
+ <Button variant="outline" size="sm" onclick={onClose}>Close</Button>
117
+ </div>
118
+ </div>
119
+ </div>
120
+ {/if}
@@ -0,0 +1,290 @@
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}