@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,201 @@
1
+ import { type EncryptedStore, SecureStoreError } from './secure-store'
2
+
3
+ /**
4
+ * Storage envelope persisted per-key in the backing KV. Versioned so the
5
+ * format can evolve without silently breaking older payloads.
6
+ */
7
+ interface Envelope {
8
+ v: 1
9
+ salt: string // base64, per-key
10
+ nonce: string // base64, per-write
11
+ ciphertext: string // base64
12
+ }
13
+
14
+ export interface WebStorageLike {
15
+ getItem(key: string): string | null
16
+ setItem(key: string, value: string): void
17
+ removeItem(key: string): void
18
+ }
19
+
20
+ export interface WebEncryptedStoreOptions {
21
+ /** Namespace prefix applied to every key before storage. */
22
+ namespace?: string
23
+ /** Storage backend. Defaults to globalThis.localStorage when present. */
24
+ storage?: WebStorageLike
25
+ /** PBKDF2 iterations. Default 200k. */
26
+ iterations?: number
27
+ /** Web Crypto SubtleCrypto override (for tests). */
28
+ subtle?: SubtleCrypto
29
+ }
30
+
31
+ const DEFAULT_ITERATIONS = 200_000
32
+ const SALT_BYTES = 16
33
+ const NONCE_BYTES = 12
34
+
35
+ function toB64(bytes: Uint8Array): string {
36
+ let s = ''
37
+ for (const b of bytes) s += String.fromCharCode(b)
38
+ return btoa(s)
39
+ }
40
+
41
+ function fromB64(s: string): Uint8Array {
42
+ const bin = atob(s)
43
+ const out = new Uint8Array(bin.length)
44
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
45
+ return out
46
+ }
47
+
48
+ // TS's DOM lib widens `Uint8Array#buffer` to `ArrayBuffer | SharedArrayBuffer`,
49
+ // which the Web Crypto signatures reject. Copy the view into a fresh
50
+ // ArrayBuffer so the cast is sound at runtime.
51
+ function bufOf(view: Uint8Array): ArrayBuffer {
52
+ const out = new ArrayBuffer(view.byteLength)
53
+ new Uint8Array(out).set(view)
54
+ return out
55
+ }
56
+
57
+ /**
58
+ * Browser-grade encrypted KV. A single instance is bound to one master
59
+ * password for its lifetime — to rotate, instantiate a fresh store.
60
+ *
61
+ * The password material itself is never persisted; only PBKDF2-derived
62
+ * AES-GCM keys are cached in memory per-salt for the lifetime of the
63
+ * instance.
64
+ */
65
+ export class WebEncryptedStore implements EncryptedStore {
66
+ private readonly storage: WebStorageLike
67
+ private readonly namespace: string
68
+ private readonly iterations: number
69
+ private readonly subtle: SubtleCrypto
70
+ private readonly password: string
71
+ private readonly keyCache = new Map<string, CryptoKey>()
72
+
73
+ constructor(password: string, opts: WebEncryptedStoreOptions = {}) {
74
+ if (typeof password !== 'string' || password.length === 0) {
75
+ throw new SecureStoreError('LOCKED', 'master password required')
76
+ }
77
+ const storage = opts.storage ?? (typeof globalThis !== 'undefined' ? (globalThis as { localStorage?: WebStorageLike }).localStorage : undefined)
78
+ if (!storage) {
79
+ throw new SecureStoreError('BACKEND_FAILURE', 'no Storage backend available')
80
+ }
81
+ const subtle = opts.subtle ?? (typeof globalThis !== 'undefined' ? (globalThis as { crypto?: Crypto }).crypto?.subtle : undefined)
82
+ if (!subtle) {
83
+ throw new SecureStoreError('BACKEND_FAILURE', 'Web Crypto SubtleCrypto unavailable')
84
+ }
85
+ this.password = password
86
+ this.storage = storage
87
+ this.namespace = opts.namespace ?? 'red-ui:secure'
88
+ this.iterations = opts.iterations ?? DEFAULT_ITERATIONS
89
+ this.subtle = subtle
90
+ }
91
+
92
+ private storageKey(key: string): string {
93
+ return `${this.namespace}:${key}`
94
+ }
95
+
96
+ private async deriveKey(salt: Uint8Array): Promise<CryptoKey> {
97
+ const saltKey = toB64(salt)
98
+ const cached = this.keyCache.get(saltKey)
99
+ if (cached) return cached
100
+ const enc = new TextEncoder().encode(this.password)
101
+ const material = await this.subtle.importKey(
102
+ 'raw',
103
+ bufOf(enc),
104
+ 'PBKDF2',
105
+ false,
106
+ ['deriveKey'],
107
+ )
108
+ const key = await this.subtle.deriveKey(
109
+ {
110
+ name: 'PBKDF2',
111
+ salt: bufOf(salt),
112
+ iterations: this.iterations,
113
+ hash: 'SHA-256',
114
+ },
115
+ material,
116
+ { name: 'AES-GCM', length: 256 },
117
+ false,
118
+ ['encrypt', 'decrypt'],
119
+ )
120
+ this.keyCache.set(saltKey, key)
121
+ return key
122
+ }
123
+
124
+ private randomBytes(n: number): Uint8Array {
125
+ const out = new Uint8Array(n)
126
+ const c = (globalThis as { crypto?: Crypto }).crypto
127
+ if (!c) throw new SecureStoreError('BACKEND_FAILURE', 'crypto.getRandomValues unavailable')
128
+ c.getRandomValues(out)
129
+ return out
130
+ }
131
+
132
+ async put(key: string, value: string): Promise<void> {
133
+ const salt = this.randomBytes(SALT_BYTES)
134
+ const nonce = this.randomBytes(NONCE_BYTES)
135
+ const cryptoKey = await this.deriveKey(salt)
136
+ const plaintext = new TextEncoder().encode(value)
137
+ let ciphertext: ArrayBuffer
138
+ try {
139
+ ciphertext = await this.subtle.encrypt(
140
+ { name: 'AES-GCM', iv: bufOf(nonce) },
141
+ cryptoKey,
142
+ bufOf(plaintext),
143
+ )
144
+ } catch (e) {
145
+ throw new SecureStoreError('BACKEND_FAILURE', `encrypt failed: ${(e as Error).message}`)
146
+ }
147
+ const env: Envelope = {
148
+ v: 1,
149
+ salt: toB64(salt),
150
+ nonce: toB64(nonce),
151
+ ciphertext: toB64(new Uint8Array(ciphertext)),
152
+ }
153
+ try {
154
+ this.storage.setItem(this.storageKey(key), JSON.stringify(env))
155
+ } catch (e) {
156
+ throw new SecureStoreError('BACKEND_FAILURE', `storage write failed: ${(e as Error).message}`)
157
+ }
158
+ }
159
+
160
+ async get(key: string): Promise<string | null> {
161
+ const raw = this.storage.getItem(this.storageKey(key))
162
+ if (!raw) return null
163
+ let env: Envelope
164
+ try {
165
+ env = JSON.parse(raw) as Envelope
166
+ } catch {
167
+ throw new SecureStoreError('CORRUPT_ENVELOPE', `key ${key}: not valid JSON`)
168
+ }
169
+ if (!env || env.v !== 1 || typeof env.salt !== 'string' || typeof env.nonce !== 'string' || typeof env.ciphertext !== 'string') {
170
+ throw new SecureStoreError('CORRUPT_ENVELOPE', `key ${key}: missing envelope fields`)
171
+ }
172
+ let salt: Uint8Array, nonce: Uint8Array, ct: Uint8Array
173
+ try {
174
+ salt = fromB64(env.salt)
175
+ nonce = fromB64(env.nonce)
176
+ ct = fromB64(env.ciphertext)
177
+ } catch {
178
+ throw new SecureStoreError('CORRUPT_ENVELOPE', `key ${key}: base64 decode failed`)
179
+ }
180
+ const cryptoKey = await this.deriveKey(salt)
181
+ let pt: ArrayBuffer
182
+ try {
183
+ pt = await this.subtle.decrypt(
184
+ { name: 'AES-GCM', iv: bufOf(nonce) },
185
+ cryptoKey,
186
+ bufOf(ct),
187
+ )
188
+ } catch {
189
+ throw new SecureStoreError('DECRYPT_FAILED', `key ${key}: decryption failed (wrong password or tampered payload)`)
190
+ }
191
+ return new TextDecoder().decode(pt)
192
+ }
193
+
194
+ async delete(key: string): Promise<void> {
195
+ try {
196
+ this.storage.removeItem(this.storageKey(key))
197
+ } catch (e) {
198
+ throw new SecureStoreError('BACKEND_FAILURE', `storage delete failed: ${(e as Error).message}`)
199
+ }
200
+ }
201
+ }
@@ -0,0 +1,184 @@
1
+ <script lang="ts">
2
+ import type { QueryResult } from '#reddb'
3
+ import { Braces, GitCompareArrows, Search, X } from 'lucide-svelte'
4
+ import { diffEntryFields, extractDiffEntries, formatDiffValue, summarizeDiff, type DiffEntry } from './diff-render'
5
+
6
+ interface Props {
7
+ result: QueryResult
8
+ collection?: string
9
+ }
10
+
11
+ let { result, collection }: Props = $props()
12
+
13
+ let mode = $state<'summary' | 'raw'>('summary')
14
+ let filter = $state('')
15
+ let selected = $state<DiffEntry | null>(null)
16
+ let showUnchanged = $state(false)
17
+
18
+ const entries = $derived(extractDiffEntries(result))
19
+ const summary = $derived(summarizeDiff(entries))
20
+ const filtered = $derived.by(() => {
21
+ const q = filter.trim().toLowerCase()
22
+ if (!q) return entries
23
+ return entries.filter((entry) =>
24
+ entry.id.toLowerCase().includes(q) ||
25
+ entry.change.toLowerCase().includes(q) ||
26
+ (entry.collection ?? '').toLowerCase().includes(q) ||
27
+ (entry.entityId ?? '').toLowerCase().includes(q) ||
28
+ formatDiffValue(entry.before).toLowerCase().includes(q) ||
29
+ formatDiffValue(entry.after).toLowerCase().includes(q),
30
+ )
31
+ })
32
+ const active = $derived(selected ?? filtered[0] ?? null)
33
+ const activeFields = $derived(active ? diffEntryFields(active) : [])
34
+ const visibleFields = $derived(showUnchanged ? activeFields : activeFields.filter((field) => field.status !== 'unchanged'))
35
+
36
+ function statusClass(status: string): string {
37
+ if (status === 'added') return 'text-ok'
38
+ if (status === 'removed') return 'text-danger'
39
+ if (status === 'modified') return 'text-accent'
40
+ return 'text-fg-3'
41
+ }
42
+ </script>
43
+
44
+ <div class="flex h-full flex-col text-fg-1">
45
+ {#if entries.length === 0}
46
+ <div class="flex-1 grid place-items-center text-fg-3 text-[12px] font-mono p-6">
47
+ No diff shape in this result.
48
+ </div>
49
+ {:else}
50
+ <div class="border-b border-line-1 px-3 py-1.5 flex items-center gap-2 text-[11px] font-mono bg-bg-1/40">
51
+ <div class="inline-flex border border-line-2 rounded overflow-hidden">
52
+ <button
53
+ type="button"
54
+ class={['inline-flex items-center gap-1 px-2 py-1 cursor-pointer transition-colors', mode === 'summary' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
55
+ onclick={() => (mode = 'summary')}
56
+ aria-pressed={mode === 'summary'}
57
+ >
58
+ <GitCompareArrows class="size-3" />
59
+ diff
60
+ </button>
61
+ <button
62
+ type="button"
63
+ class={['inline-flex items-center gap-1 px-2 py-1 cursor-pointer transition-colors border-l border-line-2', mode === 'raw' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
64
+ onclick={() => (mode = 'raw')}
65
+ aria-pressed={mode === 'raw'}
66
+ >
67
+ <Braces class="size-3" />
68
+ raw
69
+ </button>
70
+ </div>
71
+
72
+ <div class="inline-flex items-center gap-1.5 border border-line-1 rounded px-2 py-1 bg-bg-0">
73
+ <Search class="size-3 text-fg-3" />
74
+ <input type="text" bind:value={filter} placeholder="change, id, value…" class="bg-transparent text-fg-1 outline-none w-[190px] placeholder:text-fg-3" />
75
+ {#if filter}
76
+ <button type="button" onclick={() => (filter = '')} class="text-fg-3 hover:text-fg-1 cursor-pointer" aria-label="Clear diff filter">
77
+ <X class="size-3" />
78
+ </button>
79
+ {/if}
80
+ </div>
81
+
82
+ <div class="ml-auto flex items-center gap-3 text-fg-3">
83
+ {#each summary as [change, count] (change)}
84
+ <span><span class="text-fg-2">{change}</span> {count}</span>
85
+ {/each}
86
+ {#if collection}<span class="text-fg-2">{collection}</span>{/if}
87
+ </div>
88
+ </div>
89
+
90
+ {#if mode === 'raw'}
91
+ <div class="flex-1 overflow-auto p-3 text-[12px] font-mono">
92
+ <pre class="whitespace-pre-wrap text-fg-1">{JSON.stringify(filtered.map((entry) => entry.values), null, 2)}</pre>
93
+ </div>
94
+ {:else}
95
+ <div class="flex-1 min-h-0 grid grid-cols-[minmax(320px,0.9fr)_1.1fr]">
96
+ <div class="overflow-auto border-r border-line-1 text-[12px] font-mono">
97
+ {#each filtered as entry (entry.id)}
98
+ <button
99
+ type="button"
100
+ onclick={() => (selected = entry)}
101
+ class={['w-full text-left px-3 py-2 border-b border-line-1/60 hover:bg-bg-1/50 cursor-pointer', active === entry ? 'bg-bg-1 text-fg-0' : 'text-fg-2'].join(' ')}
102
+ >
103
+ <div class="flex items-center gap-2">
104
+ <span class="text-accent">{entry.change}</span>
105
+ <span class="text-fg-3 truncate">{entry.entityId ?? entry.id}</span>
106
+ {#if entry.collection}<span class="ml-auto text-fg-3">{entry.collection}</span>{/if}
107
+ </div>
108
+ {#if entry.note}
109
+ <div class="mt-1 truncate text-fg-1">{entry.note}</div>
110
+ {/if}
111
+ </button>
112
+ {/each}
113
+ </div>
114
+
115
+ <div class="overflow-auto p-3 text-[12px] font-mono">
116
+ {#if active}
117
+ <div class="mb-3 grid grid-cols-[100px_1fr] gap-x-3 gap-y-1">
118
+ <span class="text-fg-3">change</span><span class="text-accent">{active.change}</span>
119
+ <span class="text-fg-3">entity</span><span class="text-fg-0">{active.entityId ?? active.id}</span>
120
+ <span class="text-fg-3">collection</span><span class="text-fg-0">{active.collection ?? '-'}</span>
121
+ {#if active.note}
122
+ <span class="text-fg-3">note</span><span class="text-fg-1">{active.note}</span>
123
+ {/if}
124
+ </div>
125
+
126
+ {#if activeFields.length > 0}
127
+ <div class="mb-3 border border-line-1">
128
+ <div class="flex items-center gap-2 border-b border-line-1 bg-bg-1 px-2 py-1.5">
129
+ <span class="text-fg-0">field diff</span>
130
+ <span class="text-fg-3">{visibleFields.length}/{activeFields.length} fields</span>
131
+ <label class="ml-auto inline-flex items-center gap-1 text-fg-3">
132
+ <input type="checkbox" bind:checked={showUnchanged} class="size-3 accent-current" />
133
+ unchanged
134
+ </label>
135
+ </div>
136
+ {#if visibleFields.length === 0}
137
+ <div class="px-2 py-2 text-fg-3">No changed fields in this row.</div>
138
+ {:else}
139
+ <table class="w-full border-collapse">
140
+ <thead class="bg-bg-0 text-fg-3">
141
+ <tr class="border-b border-line-1">
142
+ <th class="w-[110px] px-2 py-1 text-left font-normal">status</th>
143
+ <th class="w-[150px] px-2 py-1 text-left font-normal">field</th>
144
+ <th class="px-2 py-1 text-left font-normal">before</th>
145
+ <th class="px-2 py-1 text-left font-normal">after</th>
146
+ </tr>
147
+ </thead>
148
+ <tbody>
149
+ {#each visibleFields as field (field.field)}
150
+ <tr class="border-b border-line-1/60 last:border-b-0">
151
+ <td class={['px-2 py-1 align-top', statusClass(field.status)].join(' ')}>{field.status}</td>
152
+ <td class="px-2 py-1 align-top text-fg-0">{field.field}</td>
153
+ <td class="max-w-[260px] px-2 py-1 align-top text-fg-2">
154
+ <pre class="whitespace-pre-wrap break-words">{formatDiffValue(field.before)}</pre>
155
+ </td>
156
+ <td class="max-w-[260px] px-2 py-1 align-top text-fg-2">
157
+ <pre class="whitespace-pre-wrap break-words">{formatDiffValue(field.after)}</pre>
158
+ </td>
159
+ </tr>
160
+ {/each}
161
+ </tbody>
162
+ </table>
163
+ {/if}
164
+ </div>
165
+ {/if}
166
+
167
+ <div class="grid grid-cols-2 gap-3">
168
+ <section class="min-w-0">
169
+ <div class="type-label mb-1">full before</div>
170
+ <pre class="min-h-[160px] whitespace-pre-wrap break-words rounded border border-line-1 bg-bg-1 p-2 text-fg-1">{formatDiffValue(active.before)}</pre>
171
+ </section>
172
+ <section class="min-w-0">
173
+ <div class="type-label mb-1">full after</div>
174
+ <pre class="min-h-[160px] whitespace-pre-wrap break-words rounded border border-line-1 bg-bg-1 p-2 text-fg-1">{formatDiffValue(active.after)}</pre>
175
+ </section>
176
+ </div>
177
+ {:else}
178
+ <div class="text-fg-3">No diff entry selected.</div>
179
+ {/if}
180
+ </div>
181
+ </div>
182
+ {/if}
183
+ {/if}
184
+ </div>
@@ -0,0 +1,134 @@
1
+ <script lang="ts">
2
+ import type { QueryResult } from '#reddb'
3
+ import { Braces, FileText, Search, X } from 'lucide-svelte'
4
+ import { extractDocuments, type DocumentItem } from './document-render'
5
+
6
+ interface Props {
7
+ result: QueryResult
8
+ collection?: string
9
+ }
10
+
11
+ let { result, collection }: Props = $props()
12
+
13
+ let mode = $state<'body' | 'fields' | 'raw'>('body')
14
+ let filter = $state('')
15
+ let selected = $state<DocumentItem | null>(null)
16
+
17
+ const docs = $derived(extractDocuments(result))
18
+ const filtered = $derived.by(() => {
19
+ const q = filter.trim().toLowerCase()
20
+ if (!q) return docs
21
+ return docs.filter((doc) =>
22
+ doc.title.toLowerCase().includes(q) ||
23
+ doc.subtitle.toLowerCase().includes(q) ||
24
+ JSON.stringify(doc.body).toLowerCase().includes(q),
25
+ )
26
+ })
27
+ const active = $derived(selected && filtered.includes(selected) ? selected : filtered[0] ?? null)
28
+
29
+ function pretty(v: unknown): string {
30
+ return JSON.stringify(v ?? null, null, 2)
31
+ }
32
+ </script>
33
+
34
+ <div class="flex h-full flex-col text-fg-1">
35
+ {#if docs.length === 0}
36
+ <div class="flex-1 grid place-items-center text-fg-3 text-[12px] font-mono p-6">
37
+ No document shape in this result.
38
+ </div>
39
+ {:else}
40
+ <div class="border-b border-line-1 px-3 py-1.5 flex items-center gap-2 text-[11px] font-mono bg-bg-1/40">
41
+ <div class="inline-flex border border-line-2 rounded overflow-hidden">
42
+ <button
43
+ type="button"
44
+ class={['inline-flex items-center gap-1 px-2 py-1 cursor-pointer transition-colors', mode === 'body' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
45
+ onclick={() => (mode = 'body')}
46
+ aria-pressed={mode === 'body'}
47
+ >
48
+ <Braces class="size-3" />
49
+ body
50
+ </button>
51
+ <button
52
+ type="button"
53
+ class={['inline-flex items-center gap-1 px-2 py-1 cursor-pointer transition-colors border-l border-line-2', mode === 'fields' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
54
+ onclick={() => (mode = 'fields')}
55
+ aria-pressed={mode === 'fields'}
56
+ >
57
+ <FileText class="size-3" />
58
+ fields
59
+ </button>
60
+ <button
61
+ type="button"
62
+ class={['inline-flex items-center gap-1 px-2 py-1 cursor-pointer transition-colors border-l border-line-2', mode === 'raw' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
63
+ onclick={() => (mode = 'raw')}
64
+ aria-pressed={mode === 'raw'}
65
+ >
66
+ <Braces class="size-3" />
67
+ raw
68
+ </button>
69
+ </div>
70
+
71
+ <div class="inline-flex items-center gap-1.5 border border-line-1 rounded px-2 py-1 bg-bg-0">
72
+ <Search class="size-3 text-fg-3" />
73
+ <input type="text" bind:value={filter} placeholder="document search…" class="bg-transparent text-fg-1 outline-none w-[200px] placeholder:text-fg-3" />
74
+ {#if filter}
75
+ <button type="button" onclick={() => (filter = '')} class="text-fg-3 hover:text-fg-1 cursor-pointer" aria-label="Clear document filter">
76
+ <X class="size-3" />
77
+ </button>
78
+ {/if}
79
+ </div>
80
+
81
+ <div class="ml-auto text-fg-3">
82
+ {filtered.length.toLocaleString()}{#if filter} of {docs.length.toLocaleString()}{/if} documents
83
+ {#if collection}· <span class="text-fg-2">{collection}</span>{/if}
84
+ </div>
85
+ </div>
86
+
87
+ <div class="flex-1 min-h-0 grid grid-cols-[minmax(280px,0.75fr)_1.25fr]">
88
+ <div class="overflow-auto border-r border-line-1 text-[12px] font-mono">
89
+ {#each filtered as doc (doc.rid)}
90
+ <button
91
+ type="button"
92
+ onclick={() => (selected = doc)}
93
+ class={['w-full text-left px-3 py-2 border-b border-line-1/60 hover:bg-bg-1/50 cursor-pointer', active === doc ? 'bg-bg-1 text-fg-0' : 'text-fg-2'].join(' ')}
94
+ >
95
+ <div class="flex items-center gap-2">
96
+ <span class="text-accent truncate">{doc.title}</span>
97
+ <span class="ml-auto text-fg-3">#{doc.rid}</span>
98
+ </div>
99
+ {#if doc.subtitle}
100
+ <div class="mt-1 truncate text-fg-3">{doc.subtitle}</div>
101
+ {/if}
102
+ </button>
103
+ {/each}
104
+ </div>
105
+
106
+ <div class="min-h-0 overflow-auto p-3 text-[12px] font-mono">
107
+ {#if active}
108
+ {#if mode === 'body'}
109
+ <pre class="m-0 whitespace-pre-wrap text-fg-1">{pretty(active.body)}</pre>
110
+ {:else if mode === 'fields'}
111
+ <table class="w-full border-collapse">
112
+ <tbody>
113
+ {#each Object.entries(active.fields) as [key, value] (key)}
114
+ <tr class="border-b border-line-1/60">
115
+ <td class="w-[180px] px-2 py-1 text-fg-3 align-top">{key}</td>
116
+ <td class="px-2 py-1 text-fg-1 align-top"><pre class="m-0 whitespace-pre-wrap">{pretty(value)}</pre></td>
117
+ </tr>
118
+ {/each}
119
+ </tbody>
120
+ </table>
121
+ {:else}
122
+ <pre class="m-0 whitespace-pre-wrap text-fg-1">{pretty(active.values)}</pre>
123
+ {/if}
124
+ {:else}
125
+ <div class="text-fg-3">No documents match the current filter.</div>
126
+ {/if}
127
+ </div>
128
+ </div>
129
+
130
+ <div class="border-t border-line-1 px-3 py-1.5 text-[10px] font-mono text-fg-3">
131
+ {docs.length} documents{collection ? ' · ' : ''}{collection ?? ''}
132
+ </div>
133
+ {/if}
134
+ </div>