@reddb-io/ui 0.1.1

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 (169) hide show
  1. package/LICENSE +17 -0
  2. package/bin/red-ui.js +259 -0
  3. package/build/CNAME +1 -0
  4. package/build/_app/env.js +1 -0
  5. package/build/_app/immutable/assets/0.C63jY5jX.css +1 -0
  6. package/build/_app/immutable/assets/10.Dblyk0Kr.css +1 -0
  7. package/build/_app/immutable/assets/EmptyState.RXcH7ryE.css +1 -0
  8. package/build/_app/immutable/assets/Workspace.BCzG1DsH.css +1 -0
  9. package/build/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
  10. package/build/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
  11. package/build/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
  12. package/build/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
  13. package/build/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
  14. package/build/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
  15. package/build/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
  16. package/build/_app/immutable/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
  17. package/build/_app/immutable/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
  18. package/build/_app/immutable/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
  19. package/build/_app/immutable/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
  20. package/build/_app/immutable/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
  21. package/build/_app/immutable/chunks/3j4cZ58b.js +1 -0
  22. package/build/_app/immutable/chunks/69_IOA4Y.js +1 -0
  23. package/build/_app/immutable/chunks/ALqaDrNQ.js +2 -0
  24. package/build/_app/immutable/chunks/BGcIe2ep.js +2 -0
  25. package/build/_app/immutable/chunks/BMHKbvs8.js +1 -0
  26. package/build/_app/immutable/chunks/BQHbwf54.js +916 -0
  27. package/build/_app/immutable/chunks/Bgso8cDH.js +311 -0
  28. package/build/_app/immutable/chunks/Bmgx1mfv.js +310 -0
  29. package/build/_app/immutable/chunks/BrvvOVaJ.js +1 -0
  30. package/build/_app/immutable/chunks/Bzak7iHL.js +1 -0
  31. package/build/_app/immutable/chunks/D9CTkMjK.js +1 -0
  32. package/build/_app/immutable/chunks/DIeogL5L.js +1 -0
  33. package/build/_app/immutable/chunks/DXY_Rxa7.js +113 -0
  34. package/build/_app/immutable/chunks/DhEqZVGG.js +1 -0
  35. package/build/_app/immutable/chunks/Ov8N-vxt.js +1 -0
  36. package/build/_app/immutable/chunks/oiO4RLqp.js +1 -0
  37. package/build/_app/immutable/entry/app.D_IVtpdv.js +2 -0
  38. package/build/_app/immutable/entry/start.3oC0UQ_Q.js +1 -0
  39. package/build/_app/immutable/nodes/0.CHPiFxDo.js +1 -0
  40. package/build/_app/immutable/nodes/1.BC_4YZ0h.js +1 -0
  41. package/build/_app/immutable/nodes/10.B7DMKSNc.js +60 -0
  42. package/build/_app/immutable/nodes/2.CAG5_cGP.js +1 -0
  43. package/build/_app/immutable/nodes/3.CAG5_cGP.js +1 -0
  44. package/build/_app/immutable/nodes/4.CAG5_cGP.js +1 -0
  45. package/build/_app/immutable/nodes/5.CAG5_cGP.js +1 -0
  46. package/build/_app/immutable/nodes/6.CAG5_cGP.js +1 -0
  47. package/build/_app/immutable/nodes/7.BZX9Iird.js +134 -0
  48. package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
  49. package/build/_app/immutable/nodes/9.CAG5_cGP.js +1 -0
  50. package/build/_app/version.json +1 -0
  51. package/build/favicon.png +0 -0
  52. package/build/index.html +59 -0
  53. package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js +26562 -0
  54. package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +1 -0
  55. package/dist/embed/bundle-mjs-UuVRz3O4.js +3047 -0
  56. package/dist/embed/bundle-mjs-UuVRz3O4.js.map +1 -0
  57. package/dist/embed/core-QuRYvMt8.js +118 -0
  58. package/dist/embed/core-QuRYvMt8.js.map +1 -0
  59. package/dist/embed/index-B6ZQhCsu.js +577 -0
  60. package/dist/embed/index-B6ZQhCsu.js.map +1 -0
  61. package/dist/embed/index-client-ItCEU64C.js +3087 -0
  62. package/dist/embed/index-client-ItCEU64C.js.map +1 -0
  63. package/dist/embed/index-fad77a13.esm-BqPj0saH.js +1318 -0
  64. package/dist/embed/index-fad77a13.esm-BqPj0saH.js.map +1 -0
  65. package/dist/embed/index.js +11 -0
  66. package/dist/embed/index.js.map +1 -0
  67. package/dist/embed/sigma-node-border.esm-CGtlgqmo.js +432 -0
  68. package/dist/embed/sigma-node-border.esm-CGtlgqmo.js.map +1 -0
  69. package/dist/embed/sigma.esm-y0qgY-OC.js +2355 -0
  70. package/dist/embed/sigma.esm-y0qgY-OC.js.map +1 -0
  71. package/dist/embed/ui.css +1 -0
  72. package/package.json +101 -0
  73. package/src/app.css +238 -0
  74. package/src/lib/ActivityIndicator.svelte +100 -0
  75. package/src/lib/ClusterNode.svelte +119 -0
  76. package/src/lib/ClusterView.svelte +541 -0
  77. package/src/lib/CollectionHistory.svelte +266 -0
  78. package/src/lib/CollectionsView.svelte +14 -0
  79. package/src/lib/CommandPalette.svelte +237 -0
  80. package/src/lib/ConnectDropdown.svelte +222 -0
  81. package/src/lib/EmptyState.svelte +27 -0
  82. package/src/lib/LiveChanges.svelte +195 -0
  83. package/src/lib/MasterPasswordDialog.svelte +168 -0
  84. package/src/lib/PageHeader.svelte +32 -0
  85. package/src/lib/PendingChangesPanel.svelte +153 -0
  86. package/src/lib/QueryEditor.svelte +122 -0
  87. package/src/lib/ResultsPane.svelte +747 -0
  88. package/src/lib/SchemaTree.svelte +193 -0
  89. package/src/lib/SecurityView.svelte +255 -0
  90. package/src/lib/ShortcutOverlay.svelte +104 -0
  91. package/src/lib/Splash.svelte +46 -0
  92. package/src/lib/StatusBar.svelte +163 -0
  93. package/src/lib/SubNav.svelte +62 -0
  94. package/src/lib/Topbar.svelte +119 -0
  95. package/src/lib/Workspace.svelte +247 -0
  96. package/src/lib/activity.svelte.ts +91 -0
  97. package/src/lib/build-info.ts +41 -0
  98. package/src/lib/capability.ts +221 -0
  99. package/src/lib/cn.ts +6 -0
  100. package/src/lib/collection-catalog.ts +116 -0
  101. package/src/lib/collection-pages.ts +33 -0
  102. package/src/lib/connection-history.ts +301 -0
  103. package/src/lib/connections.svelte.ts +493 -0
  104. package/src/lib/embed/RedUiEmbedRoot.svelte +31 -0
  105. package/src/lib/embed/index.ts +234 -0
  106. package/src/lib/graph-loader/contract.ts +336 -0
  107. package/src/lib/graph-loader/drilldown.ts +334 -0
  108. package/src/lib/graph-loader/index.ts +13 -0
  109. package/src/lib/index.ts +114 -0
  110. package/src/lib/pending-changes.svelte.ts +149 -0
  111. package/src/lib/query-tabs.svelte.ts +130 -0
  112. package/src/lib/reddb/boot-params.ts +254 -0
  113. package/src/lib/reddb/cdc-stream-client.ts +234 -0
  114. package/src/lib/reddb/client.ts +698 -0
  115. package/src/lib/reddb/connection-bootstrap.ts +260 -0
  116. package/src/lib/reddb/connection-provider.contract.ts +72 -0
  117. package/src/lib/reddb/connection-provider.ts +72 -0
  118. package/src/lib/reddb/index.ts +14 -0
  119. package/src/lib/reddb/injected-client-provider.ts +76 -0
  120. package/src/lib/reddb/local-url-provider.ts +245 -0
  121. package/src/lib/reddb/secure-store.ts +41 -0
  122. package/src/lib/reddb/server-capabilities.ts +71 -0
  123. package/src/lib/reddb/tauri-encrypted-store.ts +64 -0
  124. package/src/lib/reddb/transports.ts +89 -0
  125. package/src/lib/reddb/types.ts +32 -0
  126. package/src/lib/reddb/uri.ts +54 -0
  127. package/src/lib/reddb/web-encrypted-store.ts +201 -0
  128. package/src/lib/renderers/DiffRenderer.svelte +184 -0
  129. package/src/lib/renderers/DocumentRenderer.svelte +134 -0
  130. package/src/lib/renderers/GraphRenderer.svelte +1328 -0
  131. package/src/lib/renderers/HypertableRenderer.svelte +253 -0
  132. package/src/lib/renderers/JsonRenderer.svelte +30 -0
  133. package/src/lib/renderers/KvRenderer.svelte +150 -0
  134. package/src/lib/renderers/QueueRenderer.svelte +286 -0
  135. package/src/lib/renderers/StatsRenderer.svelte +123 -0
  136. package/src/lib/renderers/TableRenderer.svelte +440 -0
  137. package/src/lib/renderers/VectorRenderer.svelte +388 -0
  138. package/src/lib/renderers/diff-render.ts +122 -0
  139. package/src/lib/renderers/diff.ts +9 -0
  140. package/src/lib/renderers/document-render.ts +64 -0
  141. package/src/lib/renderers/document.ts +9 -0
  142. package/src/lib/renderers/graph-render.ts +631 -0
  143. package/src/lib/renderers/graph-sigma.ts +395 -0
  144. package/src/lib/renderers/graph.ts +9 -0
  145. package/src/lib/renderers/hypertable-render.ts +320 -0
  146. package/src/lib/renderers/hypertable.ts +9 -0
  147. package/src/lib/renderers/index.ts +42 -0
  148. package/src/lib/renderers/json-render.ts +89 -0
  149. package/src/lib/renderers/json.ts +10 -0
  150. package/src/lib/renderers/kv-render.ts +193 -0
  151. package/src/lib/renderers/kv.ts +9 -0
  152. package/src/lib/renderers/queue-render.ts +197 -0
  153. package/src/lib/renderers/queue.ts +9 -0
  154. package/src/lib/renderers/registry.ts +68 -0
  155. package/src/lib/renderers/stats-render.ts +73 -0
  156. package/src/lib/renderers/stats.ts +9 -0
  157. package/src/lib/renderers/table-render.ts +156 -0
  158. package/src/lib/renderers/table.ts +11 -0
  159. package/src/lib/renderers/types.ts +33 -0
  160. package/src/lib/renderers/vector-render.ts +154 -0
  161. package/src/lib/renderers/vector.ts +9 -0
  162. package/src/lib/router.svelte.ts +163 -0
  163. package/src/lib/secureStore.svelte.ts +148 -0
  164. package/src/lib/shadow-dom-spike/ShadowDomSpikeCore.svelte +198 -0
  165. package/src/lib/shadow-dom-spike/mount-shadow-dom-spike.ts +94 -0
  166. package/src/lib/shortcuts.ts +22 -0
  167. package/src/lib/surface.ts +80 -0
  168. package/src/lib/tabs.svelte.ts +115 -0
  169. package/src/lib/theme.svelte.ts +63 -0
@@ -0,0 +1,222 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte'
3
+ import { connection, makeCustomConnection } from './connections.svelte'
4
+ import { Zap, Loader2, AlertCircle, CheckCircle2, Clock, X, Plug, ChevronDown } from 'lucide-svelte'
5
+
6
+ const DEFAULT_URL = 'red://localhost'
7
+
8
+ function pickDefaultUrl(): string {
9
+ if (connection.history[0]?.url) return connection.history[0].url
10
+ if (connection.history[0]?.label) return connection.history[0].label
11
+ if (connection.active.url) return connection.active.url
12
+ return DEFAULT_URL
13
+ }
14
+
15
+ let open = $state(false)
16
+ let url = $state(pickDefaultUrl())
17
+ let status = $state<'idle' | 'probing' | 'ok' | 'error'>('idle')
18
+ let error = $state<string | null>(null)
19
+ let rtt = $state<number | null>(null)
20
+ let menuEl: HTMLDivElement | undefined = $state()
21
+ let inputEl: HTMLInputElement | undefined = $state()
22
+
23
+ async function attempt(targetUrl: string) {
24
+ const cleaned = targetUrl.trim().replace(/\/$/, '')
25
+ if (!cleaned) return
26
+ url = cleaned
27
+ // Transport reachability guard (#34): never attempt a transport this
28
+ // Surface can't materialize — fail with a clear message instead of an
29
+ // opaque transport-unsupported error from the client.
30
+ if (!connection.canReach(cleaned)) {
31
+ status = 'error'
32
+ error = `This app can't reach that transport here — supported: ${supportedHint}`
33
+ return
34
+ }
35
+ status = 'probing'
36
+ error = null
37
+ rtt = null
38
+ const ok = await connection.tryConnect(makeCustomConnection(cleaned))
39
+ if (ok) {
40
+ status = 'ok'
41
+ rtt = connection.probe.rtt_ms ?? null
42
+ setTimeout(() => (open = false), 350)
43
+ } else {
44
+ status = 'error'
45
+ error = connection.probe.error ?? 'unreachable'
46
+ }
47
+ }
48
+
49
+ function submit(e: Event) {
50
+ e.preventDefault()
51
+ attempt(url)
52
+ }
53
+
54
+ function fmtRel(ms: number) {
55
+ const d = Date.now() - ms
56
+ if (d < 60_000) return 'just now'
57
+ if (d < 3_600_000) return `${Math.round(d / 60_000)}m ago`
58
+ if (d < 86_400_000) return `${Math.round(d / 3_600_000)}h ago`
59
+ return `${Math.round(d / 86_400_000)}d ago`
60
+ }
61
+
62
+ function forget(e: MouseEvent, id: string) {
63
+ e.stopPropagation()
64
+ connection.forgetById(id)
65
+ }
66
+
67
+ function toggle() {
68
+ open = !open
69
+ if (open) setTimeout(() => inputEl?.focus(), 0)
70
+ }
71
+
72
+ onMount(() => {
73
+ const onOpen = () => {
74
+ open = true
75
+ setTimeout(() => inputEl?.focus(), 0)
76
+ }
77
+ window.addEventListener('red:open-connect', onOpen)
78
+ return () => window.removeEventListener('red:open-connect', onOpen)
79
+ })
80
+
81
+ const dotClass = $derived(
82
+ connection.connected ? 'bg-ok' : status === 'error' ? 'bg-danger' : 'bg-fg-3'
83
+ )
84
+
85
+ const triggerLabel = $derived(
86
+ connection.connected ? connection.active.label : 'Connect'
87
+ )
88
+
89
+ const triggerStatus = $derived(
90
+ connection.connected && connection.probe.rtt_ms !== undefined
91
+ ? `${connection.probe.rtt_ms}ms`
92
+ : connection.connected
93
+ ? 'live'
94
+ : 'no connection'
95
+ )
96
+
97
+ // Transport reachability (#34): a human hint of the schemes this Surface can
98
+ // reach, and a history list filtered to reachable entries so unreachable
99
+ // transports are absent rather than shown-but-broken.
100
+ const supportedHint = $derived.by(() => {
101
+ const t = connection.supportedTransports
102
+ const schemes: string[] = []
103
+ // http(s):// is the only browser transport; red(s):// works everywhere as an
104
+ // http(s)://host:5055 alias (normalizeUrl) so it's not advertised separately.
105
+ if (t.includes('http') || t.includes('https')) schemes.push('http(s)://')
106
+ if (t.includes('unix')) schemes.push('file://')
107
+ return schemes.join(' · ')
108
+ })
109
+
110
+ const reachableHistory = $derived(
111
+ connection.history.filter((h) => !h.url || connection.canReach(h.url)),
112
+ )
113
+ </script>
114
+
115
+ <div class="relative">
116
+ <button
117
+ type="button"
118
+ onclick={toggle}
119
+ aria-haspopup="dialog"
120
+ aria-expanded={open}
121
+ title={connection.connected ? 'Switch connection' : 'Connect to reddb'}
122
+ class="inline-flex items-center gap-2 h-7 pl-2 pr-1.5 bg-bg-2 border border-line-2 rounded-md text-fg-1 cursor-pointer text-xs transition-colors hover:border-line-3 hover:text-fg-0"
123
+ >
124
+ <span class={['w-1.5 h-1.5 rounded-full shrink-0', dotClass].join(' ')}></span>
125
+ {#if !connection.connected}
126
+ <Plug class="size-3 text-fg-3" />
127
+ {/if}
128
+ <span class="font-mono">{triggerLabel}</span>
129
+ <span class="font-mono text-[10px] text-fg-3">{triggerStatus}</span>
130
+ <ChevronDown class="size-3 text-fg-3" />
131
+ </button>
132
+
133
+ {#if open}
134
+ <div class="fixed inset-0 z-40" onclick={() => (open = false)} role="presentation"></div>
135
+ <div
136
+ bind:this={menuEl}
137
+ class="absolute top-[calc(100%+6px)] left-0 z-50 w-[380px] bg-bg-1 border border-line-2 rounded-lg shadow-2xl p-4"
138
+ role="dialog"
139
+ aria-label="Connect to reddb"
140
+ >
141
+ <form onsubmit={submit}>
142
+ <label for="connect-url" class="type-label block mb-1.5">Instance URL</label>
143
+ <input
144
+ id="connect-url"
145
+ bind:this={inputEl}
146
+ bind:value={url}
147
+ spellcheck="false"
148
+ placeholder="red://localhost"
149
+ class="w-full h-9 px-3 mb-1 bg-bg-0 border border-line-2 rounded-md font-mono text-[13px] text-fg-0 outline-none transition-colors focus:border-accent placeholder:text-fg-3"
150
+ />
151
+
152
+ <div class="mb-2 text-[10px] font-mono text-fg-3">reachable here: {supportedHint}</div>
153
+
154
+ <button
155
+ type="submit"
156
+ disabled={status === 'probing' || !url.trim()}
157
+ class="w-full h-9 bg-accent text-white font-medium rounded-md cursor-pointer transition-colors hover:bg-[#ff3868] disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2 text-[13px]"
158
+ >
159
+ {#if status === 'probing'}
160
+ <Loader2 class="size-3.5 animate-spin" />
161
+ Connecting…
162
+ {:else}
163
+ <Zap class="size-3.5" />
164
+ Connect
165
+ {/if}
166
+ </button>
167
+
168
+ <div class="h-5 mt-2 flex items-center gap-1.5 text-[11px] font-mono">
169
+ {#if status === 'ok'}
170
+ <CheckCircle2 class="size-3 text-ok" />
171
+ <span class="text-ok">Connected{rtt !== null ? ` · ${rtt}ms` : ''}</span>
172
+ {:else if status === 'error'}
173
+ <AlertCircle class="size-3 text-danger" />
174
+ <span class="text-danger truncate">{error}</span>
175
+ {/if}
176
+ </div>
177
+ </form>
178
+
179
+ {#if reachableHistory.length > 0}
180
+ <div class="mt-3 pt-3 border-t border-line-1">
181
+ <div class="flex items-center gap-1.5 mb-2">
182
+ <Clock class="size-3 text-fg-3" />
183
+ <span class="type-label">Recent</span>
184
+ </div>
185
+ <div class="grid gap-0.5 max-h-[180px] overflow-y-auto">
186
+ {#each reachableHistory as h}
187
+ <div class="group flex items-center gap-1 rounded hover:bg-bg-2 transition-colors">
188
+ <button
189
+ type="button"
190
+ onclick={() => {
191
+ if (h.url) attempt(h.url)
192
+ else {
193
+ url = h.label
194
+ setTimeout(() => inputEl?.focus(), 0)
195
+ }
196
+ }}
197
+ disabled={status === 'probing'}
198
+ title={h.url ? undefined : 'Enter credentials to reconnect'}
199
+ class="flex-1 flex items-center justify-between gap-3 px-2 py-1.5 text-left cursor-pointer bg-transparent border-0 disabled:cursor-not-allowed disabled:opacity-50 min-w-0"
200
+ >
201
+ <code class="font-mono text-[11px] text-fg-1 truncate">{h.url ?? h.label}</code>
202
+ <span class="font-mono text-[10px] text-fg-3 shrink-0 flex items-center gap-1.5">
203
+ {#if h.rtt_ms !== undefined}<span>{h.rtt_ms}ms</span>{/if}
204
+ <span>{fmtRel(h.last_used)}</span>
205
+ </span>
206
+ </button>
207
+ <button
208
+ type="button"
209
+ onclick={(e) => forget(e, h.id)}
210
+ title="Remove from history"
211
+ class="opacity-0 group-hover:opacity-100 p-1 mr-0.5 text-fg-3 hover:text-danger transition-all cursor-pointer bg-transparent border-0"
212
+ >
213
+ <X class="size-3" />
214
+ </button>
215
+ </div>
216
+ {/each}
217
+ </div>
218
+ </div>
219
+ {/if}
220
+ </div>
221
+ {/if}
222
+ </div>
@@ -0,0 +1,27 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ title: string
4
+ message?: string
5
+ /** lucide-svelte icon component */
6
+ icon?: any
7
+ hint?: string
8
+ actions?: import('svelte').Snippet
9
+ }
10
+ let { title, message, icon: Icon, hint, actions }: Props = $props()
11
+ </script>
12
+
13
+ <div class="flex flex-col items-center justify-center text-center py-16 px-6 min-h-[280px] bg-bg-1 border border-line-1 border-dashed rounded-lg">
14
+ {#if Icon}
15
+ <Icon class="size-9 text-fg-3 mb-4" strokeWidth={1.4} />
16
+ {/if}
17
+ <h2 class="type-h2 m-0 mb-2">{title}</h2>
18
+ {#if message}
19
+ <p class="text-fg-2 text-[13px] m-0 max-w-md leading-relaxed">{message}</p>
20
+ {/if}
21
+ {#if hint}
22
+ <code class="font-mono text-[11px] text-fg-3 mt-3 px-2 py-1 bg-bg-2 border border-line-1 rounded">{hint}</code>
23
+ {/if}
24
+ {#if actions}
25
+ <div class="flex items-center gap-2 mt-5">{@render actions()}</div>
26
+ {/if}
27
+ </div>
@@ -0,0 +1,195 @@
1
+ <script lang="ts">
2
+ import { onDestroy } from 'svelte'
3
+ import { CDCStreamClient, type ChangeEvent, type Subscription } from '#reddb'
4
+ import { connection } from '$lib/connections.svelte'
5
+ import { Pause, Play, Activity, Filter, X } from 'lucide-svelte'
6
+
7
+ interface Props {
8
+ collection?: string
9
+ }
10
+ let { collection }: Props = $props()
11
+
12
+ type Op = 'insert' | 'update' | 'delete'
13
+ const OPS: Op[] = ['insert', 'update', 'delete']
14
+
15
+ let events = $state<ChangeEvent[]>([])
16
+ let paused = $state(false)
17
+ let filter = $state<Record<Op, boolean>>({ insert: true, update: true, delete: true })
18
+ let error = $state<string | null>(null)
19
+ let subscription: Subscription | null = null
20
+ let reader: ReadableStreamDefaultReader<ChangeEvent> | null = null
21
+ const MAX = 500
22
+
23
+ function startStream() {
24
+ stopStream()
25
+ error = null
26
+ events = []
27
+ const client = connection.client
28
+ if (!client) {
29
+ error = 'No active connection.'
30
+ return
31
+ }
32
+ const cdc = new CDCStreamClient({
33
+ client,
34
+ // CDC is poll-only on reddb today: the server exposes `GET /changes`
35
+ // (handlers_backup.rs / routing.rs) and there is NO `/changes/stream` SSE
36
+ // route (verified against ../reddb 2026-06-01). Polling `GET /changes` is
37
+ // therefore the optimal — and only — live-changes transport for every
38
+ // Surface. The CDCStreamClient keeps full SSE plumbing dormant for a
39
+ // hypothetical future server push endpoint; do NOT enable it until reddb
40
+ // actually ships `/changes/stream`, or every subscription 404s. When it
41
+ // lands this becomes:
42
+ // sseFactory: (url) => new EventSource(url),
43
+ // sseProbe: async () => (await fetch(url, { method: 'HEAD' })).ok,
44
+ sseFactory: null,
45
+ })
46
+ subscription = cdc.subscribe({ collection })
47
+ reader = subscription.events.getReader()
48
+ void pump()
49
+ }
50
+
51
+ async function pump() {
52
+ if (!reader) return
53
+ try {
54
+ while (true) {
55
+ const { value, done } = await reader.read()
56
+ if (done) return
57
+ if (paused) continue
58
+ if (!value) continue
59
+ events = [value, ...events].slice(0, MAX)
60
+ }
61
+ } catch (e) {
62
+ error = (e as Error).message
63
+ }
64
+ }
65
+
66
+ function stopStream() {
67
+ if (reader) {
68
+ try { reader.cancel() } catch {}
69
+ reader = null
70
+ }
71
+ if (subscription) {
72
+ subscription.close()
73
+ subscription = null
74
+ }
75
+ }
76
+
77
+ $effect(() => {
78
+ // Restart whenever connection or collection changes.
79
+ void connection.connected
80
+ void collection
81
+ if (connection.connected) startStream()
82
+ else stopStream()
83
+ })
84
+
85
+ onDestroy(stopStream)
86
+
87
+ function relative(ts: number): string {
88
+ const diff = Date.now() - ts
89
+ if (diff < 1000) return 'now'
90
+ if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago`
91
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
92
+ return `${Math.floor(diff / 3_600_000)}h ago`
93
+ }
94
+
95
+ function absolute(ts: number): string {
96
+ return new Date(ts).toISOString().replace('T', ' ').replace('Z', '')
97
+ }
98
+
99
+ const visible = $derived(events.filter((e) => filter[e.operation as Op] ?? true))
100
+ const scope = $derived(collection ? `collection: ${collection}` : 'global')
101
+ </script>
102
+
103
+ <div class="h-full flex flex-col bg-bg-0">
104
+ <header class="px-3 py-2 border-b border-line-1 flex items-center gap-3 text-fg-3">
105
+ <Activity class="size-3.5" />
106
+ <span class="type-label">Live changes</span>
107
+ <span class="text-[11px] font-mono text-fg-3">· {scope}</span>
108
+
109
+ <div class="ml-auto flex items-center gap-1">
110
+ <button
111
+ type="button"
112
+ class="inline-flex items-center gap-1 px-2 py-1 text-[11px] font-mono rounded border border-line-1 hover:bg-bg-2 text-fg-2"
113
+ onclick={() => (paused = !paused)}
114
+ title={paused ? 'Resume stream' : 'Pause stream'}
115
+ >
116
+ {#if paused}
117
+ <Play class="size-3" /> resume
118
+ {:else}
119
+ <Pause class="size-3" /> pause
120
+ {/if}
121
+ </button>
122
+
123
+ <div class="inline-flex items-center gap-1 px-2 py-1 text-[11px] font-mono rounded border border-line-1 text-fg-3">
124
+ <Filter class="size-3" />
125
+ {#each OPS as op (op)}
126
+ <label class="inline-flex items-center gap-1 cursor-pointer">
127
+ <input
128
+ type="checkbox"
129
+ checked={filter[op]}
130
+ onchange={(ev) => (filter = { ...filter, [op]: (ev.currentTarget as HTMLInputElement).checked })}
131
+ class="accent-accent size-3"
132
+ />
133
+ <span class={filter[op] ? 'text-fg-1' : 'text-fg-3'}>{op}</span>
134
+ </label>
135
+ {/each}
136
+ </div>
137
+
138
+ {#if events.length > 0}
139
+ <button
140
+ type="button"
141
+ class="inline-flex items-center gap-1 px-2 py-1 text-[11px] font-mono rounded border border-line-1 hover:bg-bg-2 text-fg-3"
142
+ onclick={() => (events = [])}
143
+ title="Clear buffer"
144
+ >
145
+ <X class="size-3" /> clear
146
+ </button>
147
+ {/if}
148
+ </div>
149
+ </header>
150
+
151
+ <div class="flex-1 overflow-auto">
152
+ {#if error}
153
+ <div class="p-6 text-[12px] font-mono text-fg-3">
154
+ <span class="text-accent">error:</span> {error}
155
+ </div>
156
+ {:else if !connection.connected}
157
+ <div class="p-6 text-[12px] font-mono text-fg-3">
158
+ Not connected. Use the topbar Connect button to open a stream.
159
+ </div>
160
+ {:else if visible.length === 0}
161
+ <div class="p-6 text-[12px] font-mono text-fg-3">
162
+ {paused ? 'Paused — no events captured.' : 'Listening for changes…'}
163
+ </div>
164
+ {:else}
165
+ <ul class="divide-y divide-line-1 font-mono text-[12px]">
166
+ {#each visible as e (e.lsn)}
167
+ <li class="px-3 py-1.5 flex items-center gap-3 hover:bg-bg-1">
168
+ <span class="text-fg-3 w-16 shrink-0" title={absolute(e.timestamp)}>
169
+ {relative(e.timestamp)}
170
+ </span>
171
+ <span class="text-fg-3 w-16 shrink-0">lsn {e.lsn}</span>
172
+ <span
173
+ class={[
174
+ 'inline-block w-14 text-center text-[10px] uppercase rounded px-1 py-0.5 shrink-0 border',
175
+ e.operation === 'insert' && 'border-ok/40 text-ok',
176
+ e.operation === 'update' && 'border-warn/40 text-warn',
177
+ e.operation === 'delete' && 'border-accent/40 text-accent',
178
+ ]
179
+ .filter(Boolean)
180
+ .join(' ')}
181
+ >
182
+ {e.operation}
183
+ </span>
184
+ <span class="text-fg-2 truncate">{e.collection}</span>
185
+ <span class="text-fg-3">·</span>
186
+ <span class="text-fg-3 truncate">{e.kind}</span>
187
+ {#if e.rid !== undefined}
188
+ <span class="text-fg-3 ml-auto">rid {e.rid}</span>
189
+ {/if}
190
+ </li>
191
+ {/each}
192
+ </ul>
193
+ {/if}
194
+ </div>
195
+ </div>
@@ -0,0 +1,168 @@
1
+ <script lang="ts">
2
+ import { Lock, Loader2, AlertCircle, KeyRound, Trash2 } from 'lucide-svelte'
3
+ import { secureStore } from './secureStore.svelte'
4
+ import { connection } from './connections.svelte'
5
+
6
+ let password = $state('')
7
+ let confirm = $state('')
8
+ let busy = $state(false)
9
+ let confirmingWipe = $state(false)
10
+ let inputEl: HTMLInputElement | undefined = $state()
11
+
12
+ function startWipe() {
13
+ secureStore.error = null
14
+ confirmingWipe = true
15
+ }
16
+
17
+ function cancelWipe() {
18
+ confirmingWipe = false
19
+ }
20
+
21
+ function doWipe() {
22
+ secureStore.wipe()
23
+ password = ''
24
+ confirm = ''
25
+ confirmingWipe = false
26
+ setTimeout(() => inputEl?.focus(), 0)
27
+ }
28
+
29
+ const needsSetup = $derived(secureStore.needsSetup)
30
+ const error = $derived(secureStore.error)
31
+ const title = $derived(needsSetup ? 'Set a master password' : 'Unlock credentials')
32
+ const hint = $derived(
33
+ needsSetup
34
+ ? 'Picks a key that encrypts your connection history at rest. The password lives in this browser session only — there is no recovery.'
35
+ : 'Enter the master password used to encrypt your saved connections.'
36
+ )
37
+
38
+ $effect(() => {
39
+ if (secureStore.locked) setTimeout(() => inputEl?.focus(), 0)
40
+ })
41
+
42
+ async function submit(e: Event) {
43
+ e.preventDefault()
44
+ if (busy) return
45
+ if (!password) return
46
+ if (needsSetup && password !== confirm) {
47
+ secureStore.error = "Passwords don't match"
48
+ return
49
+ }
50
+ busy = true
51
+ const ok = await secureStore.unlock(password)
52
+ busy = false
53
+ if (ok) {
54
+ password = ''
55
+ confirm = ''
56
+ // Belt + suspenders: the layout's reactive $effect already kicks
57
+ // connection.refresh() once locked flips, but firing it here too
58
+ // means the connect dot updates within the same microtask the
59
+ // dialog disappears — no "I unlocked but nothing happened" lag.
60
+ connection.refresh()
61
+ }
62
+ }
63
+ </script>
64
+
65
+ {#if secureStore.backend === 'web' && secureStore.locked}
66
+ <div
67
+ class="fixed inset-0 z-[100] flex items-center justify-center bg-bg-0/70 backdrop-blur-sm motion-reduce:backdrop-blur-none"
68
+ role="dialog"
69
+ aria-modal="true"
70
+ aria-labelledby="mp-title"
71
+ >
72
+ <form
73
+ onsubmit={submit}
74
+ class="w-[380px] bg-bg-1 border border-line-2 rounded-lg shadow-2xl p-5"
75
+ >
76
+ <div class="flex items-center gap-2 mb-1.5 text-fg-0">
77
+ {#if needsSetup}
78
+ <KeyRound class="size-4 text-accent" />
79
+ {:else}
80
+ <Lock class="size-4 text-accent" />
81
+ {/if}
82
+ <h2 id="mp-title" class="type-h2 m-0">{title}</h2>
83
+ </div>
84
+ <p class="text-fg-3 text-[12px] leading-relaxed m-0 mb-3">{hint}</p>
85
+
86
+ <label for="mp-pwd" class="type-label block mb-1">Master password</label>
87
+ <input
88
+ id="mp-pwd"
89
+ bind:this={inputEl}
90
+ bind:value={password}
91
+ type="password"
92
+ autocomplete={needsSetup ? 'new-password' : 'current-password'}
93
+ spellcheck="false"
94
+ class="w-full h-9 px-3 mb-2 bg-bg-0 border border-line-2 rounded-md font-mono text-[13px] text-fg-0 outline-none transition-colors focus:border-accent"
95
+ />
96
+
97
+ {#if needsSetup}
98
+ <label for="mp-confirm" class="type-label block mb-1">Confirm</label>
99
+ <input
100
+ id="mp-confirm"
101
+ bind:value={confirm}
102
+ type="password"
103
+ autocomplete="new-password"
104
+ spellcheck="false"
105
+ class="w-full h-9 px-3 mb-2 bg-bg-0 border border-line-2 rounded-md font-mono text-[13px] text-fg-0 outline-none transition-colors focus:border-accent"
106
+ />
107
+ {/if}
108
+
109
+ <button
110
+ type="submit"
111
+ disabled={busy || !password || (needsSetup && !confirm)}
112
+ class="w-full h-9 mt-1 bg-accent text-white font-medium rounded-md cursor-pointer transition-colors hover:bg-[#ff3868] disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2 text-[13px]"
113
+ >
114
+ {#if busy}
115
+ <Loader2 class="size-3.5 animate-spin" />
116
+ {needsSetup ? 'Setting up…' : 'Unlocking…'}
117
+ {:else}
118
+ {needsSetup ? 'Set password' : 'Unlock'}
119
+ {/if}
120
+ </button>
121
+
122
+ <div class="h-5 mt-2 flex items-center gap-1.5 text-[11px] font-mono">
123
+ {#if error}
124
+ <AlertCircle class="size-3 text-danger" />
125
+ <span class="text-danger truncate">{error}</span>
126
+ {/if}
127
+ </div>
128
+
129
+ <!-- Start fresh: when the user can't unlock (forgot the password,
130
+ wedged state, etc.) the only path forward is to wipe the
131
+ encrypted store entirely. There's no recovery — the password
132
+ is the key. Two-step confirmation prevents accidental clicks. -->
133
+ <div class="mt-3 pt-3 border-t border-line-1 text-[11px] font-mono">
134
+ {#if !confirmingWipe}
135
+ <button
136
+ type="button"
137
+ onclick={startWipe}
138
+ class="inline-flex items-center gap-1.5 text-fg-3 hover:text-danger transition-colors cursor-pointer bg-transparent border-0 p-0"
139
+ >
140
+ <Trash2 class="size-3" />
141
+ <span>Start fresh — wipe this browser</span>
142
+ </button>
143
+ {:else}
144
+ <div class="flex items-center justify-between gap-2">
145
+ <span class="text-danger">Erase the encrypted store and all saved connections?</span>
146
+ </div>
147
+ <div class="flex items-center gap-2 mt-1.5">
148
+ <button
149
+ type="button"
150
+ onclick={doWipe}
151
+ class="inline-flex items-center gap-1.5 px-2 py-1 rounded border border-danger/40 bg-danger/10 text-danger hover:bg-danger/20 cursor-pointer"
152
+ >
153
+ <Trash2 class="size-3" />
154
+ Yes, wipe
155
+ </button>
156
+ <button
157
+ type="button"
158
+ onclick={cancelWipe}
159
+ class="px-2 py-1 rounded border border-line-2 text-fg-2 hover:text-fg-0 cursor-pointer"
160
+ >
161
+ Cancel
162
+ </button>
163
+ </div>
164
+ {/if}
165
+ </div>
166
+ </form>
167
+ </div>
168
+ {/if}
@@ -0,0 +1,32 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ title: string
4
+ /** mono uppercase eyebrow above the title — e.g. "Explore", "Admin" */
5
+ eyebrow?: string
6
+ /** subtle subtitle/description below — single line, fg-2 */
7
+ subtitle?: string
8
+ actions?: import('svelte').Snippet
9
+ children?: import('svelte').Snippet
10
+ }
11
+ let { title, eyebrow, subtitle, actions, children }: Props = $props()
12
+ </script>
13
+
14
+ <header class="flex items-end justify-between gap-4 pb-4 mb-4 border-b border-line-1">
15
+ <div class="min-w-0">
16
+ {#if eyebrow}
17
+ <div class="type-label mb-1.5">{eyebrow}</div>
18
+ {/if}
19
+ <div class="flex items-baseline gap-3 flex-wrap">
20
+ <h1 class="type-h1 m-0">{title}</h1>
21
+ {@render children?.()}
22
+ </div>
23
+ {#if subtitle}
24
+ <p class="type-caption m-0 mt-1.5">{subtitle}</p>
25
+ {/if}
26
+ </div>
27
+ {#if actions}
28
+ <div class="flex items-center gap-2 shrink-0">
29
+ {@render actions()}
30
+ </div>
31
+ {/if}
32
+ </header>