@reddb-io/ui 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/_app/immutable/assets/0.DQDLkSdR.css +1 -0
- package/build/_app/immutable/assets/Workspace.CvP5De7i.css +1 -0
- package/build/_app/immutable/chunks/65XjEwaF.js +367 -0
- package/build/_app/immutable/chunks/BjFADNkA.js +992 -0
- package/build/_app/immutable/chunks/{ALqaDrNQ.js → C278awGv.js} +1 -1
- package/build/_app/immutable/chunks/{Ov8N-vxt.js → C9rXzYLu.js} +1 -1
- package/build/_app/immutable/chunks/CDKIdLnm.js +187 -0
- package/build/_app/immutable/chunks/{DXY_Rxa7.js → CXNPJgv1.js} +7 -7
- package/build/_app/immutable/chunks/{Bgso8cDH.js → DqvWKbSc.js} +1 -1
- package/build/_app/immutable/chunks/pDJnDxXM.js +1 -0
- package/build/_app/immutable/entry/app.DdaMXHAv.js +2 -0
- package/build/_app/immutable/entry/start.-MLBtQXB.js +1 -0
- package/build/_app/immutable/nodes/{0.CHPiFxDo.js → 0.Ysoz3Y3h.js} +1 -1
- package/build/_app/immutable/nodes/{1.BC_4YZ0h.js → 1.C2jbGTOJ.js} +1 -1
- package/build/_app/immutable/nodes/11.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/{10.B7DMKSNc.js → 12.DQG5zpEx.js} +5 -5
- package/build/_app/immutable/nodes/7.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/{7.BZX9Iird.js → 8.CrHI4F9p.js} +1 -1
- package/build/_app/version.json +1 -1
- package/build/index.html +12 -12
- package/dist/embed/{RedUiEmbedRoot-BGoLxJf1.js → RedUiEmbedRoot-BwFUA5X-.js} +12715 -10653
- package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js.map +1 -0
- package/dist/embed/index-DGhgtnaK.js +577 -0
- package/dist/embed/{index-B6ZQhCsu.js.map → index-DGhgtnaK.js.map} +1 -1
- package/dist/embed/{index-fad77a13.esm-BqPj0saH.js → index-fad77a13.esm-BMULHdcE.js} +22 -20
- package/dist/embed/{index-fad77a13.esm-BqPj0saH.js.map → index-fad77a13.esm-BMULHdcE.js.map} +1 -1
- package/dist/embed/index.js +1 -1
- package/dist/embed/sigma-edge-curve.esm-CAT5Xrom.js +536 -0
- package/dist/embed/sigma-edge-curve.esm-CAT5Xrom.js.map +1 -0
- package/dist/embed/{sigma-node-border.esm-CGtlgqmo.js → sigma-node-border.esm-Bnk4BRbw.js} +32 -32
- package/dist/embed/{sigma-node-border.esm-CGtlgqmo.js.map → sigma-node-border.esm-Bnk4BRbw.js.map} +1 -1
- package/dist/embed/{sigma.esm-y0qgY-OC.js → sigma.esm-D5yoExsH.js} +3 -3
- package/dist/embed/{sigma.esm-y0qgY-OC.js.map → sigma.esm-D5yoExsH.js.map} +1 -1
- package/package.json +5 -3
- package/src/lib/Appearance.svelte +90 -0
- package/src/lib/CommandPalette.svelte +2 -0
- package/src/lib/SettingsView.svelte +376 -0
- package/src/lib/Topbar.svelte +15 -1
- package/src/lib/Workspace.svelte +14 -1
- package/src/lib/index.ts +2 -0
- package/src/lib/renderers/GraphRenderer.svelte +448 -44
- package/src/lib/renderers/graph-bundle.ts +401 -0
- package/src/lib/renderers/graph-communities.ts +193 -0
- package/src/lib/renderers/graph-render.ts +48 -5
- package/src/lib/renderers/graph-sigma.ts +274 -8
- package/src/lib/router.svelte.ts +84 -57
- package/src/lib/settings-sections.ts +267 -0
- package/src/lib/skins/skins.svelte.ts +64 -0
- package/src/lib/skins/skins.ts +178 -0
- package/build/_app/immutable/assets/0.C63jY5jX.css +0 -1
- package/build/_app/immutable/assets/Workspace.BCzG1DsH.css +0 -1
- package/build/_app/immutable/chunks/BMHKbvs8.js +0 -1
- package/build/_app/immutable/chunks/BQHbwf54.js +0 -916
- package/build/_app/immutable/chunks/Bmgx1mfv.js +0 -310
- package/build/_app/immutable/entry/app.D_IVtpdv.js +0 -2
- package/build/_app/immutable/entry/start.3oC0UQ_Q.js +0 -1
- package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +0 -1
- package/dist/embed/index-B6ZQhCsu.js +0 -577
- /package/build/_app/immutable/assets/{10.Dblyk0Kr.css → 12.Dblyk0Kr.css} +0 -0
- /package/build/_app/immutable/nodes/{8.CAG5_cGP.js → 10.CAG5_cGP.js} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reddb-io/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "RedDB UI components and standalone web application.",
|
|
6
6
|
"type": "module",
|
|
@@ -45,8 +45,10 @@
|
|
|
45
45
|
"access": "public"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
+
"@aflsolutions/graphology-communities-leiden": "^1.1.1",
|
|
48
49
|
"@fontsource-variable/inter": "^5.2.8",
|
|
49
50
|
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
|
51
|
+
"@sigma/edge-curve": "^3.1.0",
|
|
50
52
|
"@sigma/node-border": "^3.0.0",
|
|
51
53
|
"@tauri-apps/api": "^2.5.0",
|
|
52
54
|
"@xyflow/svelte": "^1.2.0",
|
|
@@ -59,8 +61,8 @@
|
|
|
59
61
|
"sigma": "^3.0.3",
|
|
60
62
|
"tailwind-merge": "^3.6.0",
|
|
61
63
|
"tailwind-variants": "^2.1.0",
|
|
62
|
-
"@reddb-io/ui-
|
|
63
|
-
"@reddb-io/ui-
|
|
64
|
+
"@reddb-io/ui-kit": "0.3.0",
|
|
65
|
+
"@reddb-io/ui-mcp": "0.3.0"
|
|
64
66
|
},
|
|
65
67
|
"devDependencies": {
|
|
66
68
|
"@sveltejs/adapter-static": "^3.0.8",
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Appearance section (#75). Renders each registered skin as a live preview
|
|
3
|
+
// card — a mini-mockup painted with that skin's *own* derived tokens — and
|
|
4
|
+
// applies the skin on click. Selection persists across reloads via the skin
|
|
5
|
+
// store. Dark-only: there is no light/system switch here.
|
|
6
|
+
import { Check } from 'lucide-svelte'
|
|
7
|
+
import PageHeader from '$lib/PageHeader.svelte'
|
|
8
|
+
import { SKINS, skinToCssVars, type Skin } from '$lib/skins/skins'
|
|
9
|
+
import { skins } from '$lib/skins/skins.svelte'
|
|
10
|
+
|
|
11
|
+
// Each preview is painted with the skin's own custom properties so the card
|
|
12
|
+
// is a faithful, self-contained swatch of the palette — `color-mix` ramps
|
|
13
|
+
// resolve in the browser exactly as they would once applied.
|
|
14
|
+
function previewStyle(skin: Skin): string {
|
|
15
|
+
return Object.entries(skinToCssVars(skin))
|
|
16
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
17
|
+
.join('; ')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const selected = $derived(skins.current.name)
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<div class="h-full overflow-auto bg-bg-0 p-6 text-fg-1">
|
|
24
|
+
<PageHeader
|
|
25
|
+
eyebrow="Settings"
|
|
26
|
+
title="Appearance"
|
|
27
|
+
subtitle="Pick a skin. Surfaces are derived from a single seed; the accent stays surgical. Dark only."
|
|
28
|
+
/>
|
|
29
|
+
|
|
30
|
+
<div class="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4 max-w-4xl">
|
|
31
|
+
{#each SKINS as skin (skin.name)}
|
|
32
|
+
{@const active = skin.name === selected}
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
onclick={() => skins.select(skin.name)}
|
|
36
|
+
aria-pressed={active}
|
|
37
|
+
class="group text-left rounded-lg border bg-bg-1 p-3 transition-colors {active
|
|
38
|
+
? 'border-accent'
|
|
39
|
+
: 'border-line-2 hover:border-line-3'}"
|
|
40
|
+
>
|
|
41
|
+
<!-- Mini-mockup, painted in the skin's own tokens -->
|
|
42
|
+
<div
|
|
43
|
+
class="rounded-md overflow-hidden border border-line-1"
|
|
44
|
+
style={previewStyle(skin)}
|
|
45
|
+
>
|
|
46
|
+
<div class="bg-bg-0 p-3">
|
|
47
|
+
<!-- faux topbar -->
|
|
48
|
+
<div class="flex items-center gap-1.5 mb-2.5">
|
|
49
|
+
<span class="size-2 rounded-full" style="background: var(--color-accent)"></span>
|
|
50
|
+
<span class="h-1.5 w-12 rounded-full bg-bg-3"></span>
|
|
51
|
+
<span class="ml-auto h-1.5 w-6 rounded-full bg-bg-2"></span>
|
|
52
|
+
</div>
|
|
53
|
+
<!-- faux card with strips -->
|
|
54
|
+
<div class="rounded-md bg-bg-1 border border-line-2 p-2.5">
|
|
55
|
+
<div class="h-1.5 w-2/3 rounded-full bg-fg-2 mb-2"></div>
|
|
56
|
+
<div class="h-1.5 w-full rounded-full bg-bg-3 mb-1.5"></div>
|
|
57
|
+
<div class="h-1.5 w-4/5 rounded-full bg-bg-3 mb-2.5"></div>
|
|
58
|
+
<div class="flex items-center gap-1.5">
|
|
59
|
+
<span
|
|
60
|
+
class="h-4 w-12 rounded"
|
|
61
|
+
style="background: var(--color-accent)"
|
|
62
|
+
></span>
|
|
63
|
+
<span class="h-4 w-8 rounded bg-bg-2 border border-line-2"></span>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
<!-- surface ramp strip: bg-0..bg-3 -->
|
|
68
|
+
<div class="flex h-2.5">
|
|
69
|
+
<span class="flex-1 bg-bg-0"></span>
|
|
70
|
+
<span class="flex-1 bg-bg-1"></span>
|
|
71
|
+
<span class="flex-1 bg-bg-2"></span>
|
|
72
|
+
<span class="flex-1 bg-bg-3"></span>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div class="flex items-center gap-2 mt-3">
|
|
77
|
+
<span class="type-h2 text-fg-0">{skin.label}</span>
|
|
78
|
+
{#if active}
|
|
79
|
+
<span
|
|
80
|
+
class="inline-flex items-center gap-1 rounded-full bg-accent-soft px-2 py-0.5 text-[10px] font-mono uppercase tracking-wider text-accent"
|
|
81
|
+
>
|
|
82
|
+
<Check class="size-3" /> Active
|
|
83
|
+
</span>
|
|
84
|
+
{/if}
|
|
85
|
+
</div>
|
|
86
|
+
<p class="type-caption m-0 mt-1">{skin.description}</p>
|
|
87
|
+
</button>
|
|
88
|
+
{/each}
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
{ id: 'collections', group: 'navigate', label: 'Open collections', hint: '/collections', run: () => navigate({ view: 'collections' }) },
|
|
32
32
|
{ id: 'topology', group: 'navigate', label: 'Open cluster', hint: '/cluster', run: () => navigate({ view: 'cluster' }) },
|
|
33
33
|
{ id: 'security', group: 'navigate', label: 'Open security', hint: '/security', run: () => navigate({ view: 'security' }) },
|
|
34
|
+
{ id: 'settings', group: 'navigate', label: 'Open settings', hint: '/settings', run: () => navigate({ view: 'settings' }) },
|
|
35
|
+
{ id: 'appearance', group: 'navigate', label: 'Open appearance', hint: '/appearance', run: () => navigate({ view: 'appearance' }) },
|
|
34
36
|
|
|
35
37
|
{ id: 'new-query', group: 'data', label: 'New query', shortcut: '⌘T', run: () => { fire('red:new-query'); close() } },
|
|
36
38
|
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Settings view — a data-driven, permission-aware config inspector.
|
|
3
|
+
//
|
|
4
|
+
// The sections, their curated keys, and the label/description/enum copy all
|
|
5
|
+
// live in `settings-sections` as DATA. This view does three things: reads the
|
|
6
|
+
// *real* config from `connection.client` (no mocks/fixtures), flattens it to
|
|
7
|
+
// dotted keys, and renders each curated key through `resolveControl` as a
|
|
8
|
+
// `ListRow`. When the connection is unreachable it falls back to EmptyState.
|
|
9
|
+
//
|
|
10
|
+
// Permission-aware by default: a key whose source endpoint is denied or
|
|
11
|
+
// unsupported is not greyed out — its control is absent, replaced by a small
|
|
12
|
+
// chip naming the missing grant (with the server's reason in the tooltip).
|
|
13
|
+
import {
|
|
14
|
+
SplitView,
|
|
15
|
+
NavItem,
|
|
16
|
+
SectionHeading,
|
|
17
|
+
Pill,
|
|
18
|
+
ListRow,
|
|
19
|
+
} from '@reddb-io/ui-kit'
|
|
20
|
+
import {
|
|
21
|
+
Settings2,
|
|
22
|
+
Database,
|
|
23
|
+
Network,
|
|
24
|
+
Plug,
|
|
25
|
+
Shield,
|
|
26
|
+
Search,
|
|
27
|
+
RefreshCw,
|
|
28
|
+
Download,
|
|
29
|
+
Lock,
|
|
30
|
+
Unplug,
|
|
31
|
+
type Icon,
|
|
32
|
+
} from 'lucide-svelte'
|
|
33
|
+
import PageHeader from '$lib/PageHeader.svelte'
|
|
34
|
+
import EmptyState from '$lib/EmptyState.svelte'
|
|
35
|
+
import { connection } from '$lib/connections.svelte'
|
|
36
|
+
import { activity } from '$lib/activity.svelte'
|
|
37
|
+
import {
|
|
38
|
+
SECTIONS,
|
|
39
|
+
resolveSection,
|
|
40
|
+
resolveControl,
|
|
41
|
+
sourceForKey,
|
|
42
|
+
flattenConfig,
|
|
43
|
+
filterKeys,
|
|
44
|
+
type ControlDescriptor,
|
|
45
|
+
type SettingsSource,
|
|
46
|
+
} from '$lib/settings-sections'
|
|
47
|
+
|
|
48
|
+
const icons: Record<string, typeof Icon> = {
|
|
49
|
+
settings: Settings2,
|
|
50
|
+
database: Database,
|
|
51
|
+
network: Network,
|
|
52
|
+
plug: Plug,
|
|
53
|
+
shield: Shield,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The grant a denied source is gated on — shown on the "absent" chip.
|
|
57
|
+
const GRANTS: Record<SettingsSource, string> = {
|
|
58
|
+
stats: 'stats:read',
|
|
59
|
+
cluster: 'cluster:status',
|
|
60
|
+
capabilities: 'capabilities',
|
|
61
|
+
session: 'auth:whoami',
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let activeId = $state(SECTIONS[0].id)
|
|
65
|
+
const active = $derived(resolveSection(activeId))
|
|
66
|
+
|
|
67
|
+
// Per-section search query, keyed by section id, so switching tabs preserves
|
|
68
|
+
// each section's typed query. The input is focusable via the ⌘K idiom.
|
|
69
|
+
let queries = $state<Record<string, string>>({})
|
|
70
|
+
let searchEl = $state<HTMLInputElement | null>(null)
|
|
71
|
+
const query = $derived(queries[activeId] ?? '')
|
|
72
|
+
|
|
73
|
+
function setQuery(v: string) {
|
|
74
|
+
queries = { ...queries, [activeId]: v }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function onWindowKey(e: KeyboardEvent) {
|
|
78
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
|
79
|
+
e.preventDefault()
|
|
80
|
+
searchEl?.focus()
|
|
81
|
+
searchEl?.select()
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Flattened live config and, per source, the reason its grant is absent
|
|
86
|
+
// (null ⇒ available). Both are rebuilt atomically on every refresh.
|
|
87
|
+
let values = $state<Record<string, unknown>>({})
|
|
88
|
+
let denied = $state<Record<SettingsSource, string | null>>({
|
|
89
|
+
stats: null,
|
|
90
|
+
cluster: null,
|
|
91
|
+
capabilities: null,
|
|
92
|
+
session: null,
|
|
93
|
+
})
|
|
94
|
+
let loading = $state(false)
|
|
95
|
+
|
|
96
|
+
const connected = $derived(connection.connected)
|
|
97
|
+
const activeUrl = $derived(connection.active.url)
|
|
98
|
+
|
|
99
|
+
async function refresh() {
|
|
100
|
+
const client = connection.client
|
|
101
|
+
if (!client || !connected) {
|
|
102
|
+
values = {}
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
loading = true
|
|
106
|
+
const next: Record<string, unknown> = {}
|
|
107
|
+
const nextDenied: Record<SettingsSource, string | null> = {
|
|
108
|
+
stats: null,
|
|
109
|
+
cluster: null,
|
|
110
|
+
capabilities: null,
|
|
111
|
+
session: null,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// /capabilities is the negotiation itself — always available from the store.
|
|
115
|
+
Object.assign(next, flattenConfig({ capabilities: connection.capabilities }))
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const stats = await activity.track('settings · stats', () => client.stats())
|
|
119
|
+
Object.assign(next, flattenConfig(stats as unknown as Record<string, unknown>))
|
|
120
|
+
} catch (e) {
|
|
121
|
+
nextDenied.stats = (e as Error).message
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Cluster topology is capability-gated: if the server can't honour
|
|
125
|
+
// /cluster/status, those keys are absent + explained, never faked.
|
|
126
|
+
if (!connection.capabilities.clusterStatus) {
|
|
127
|
+
nextDenied.cluster = 'Server does not expose /cluster/status.'
|
|
128
|
+
} else {
|
|
129
|
+
try {
|
|
130
|
+
const cluster = await activity.track('settings · cluster', () =>
|
|
131
|
+
client.clusterStatus(),
|
|
132
|
+
)
|
|
133
|
+
Object.assign(next, flattenConfig(cluster as unknown as Record<string, unknown>))
|
|
134
|
+
} catch (e) {
|
|
135
|
+
nextDenied.cluster = (e as Error).message
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const session = await activity.track('settings · whoami', () => client.whoami())
|
|
141
|
+
Object.assign(
|
|
142
|
+
next,
|
|
143
|
+
flattenConfig({ session: session as unknown as Record<string, unknown> }),
|
|
144
|
+
)
|
|
145
|
+
} catch (e) {
|
|
146
|
+
nextDenied.session = (e as Error).message
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
values = next
|
|
150
|
+
denied = nextDenied
|
|
151
|
+
loading = false
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
$effect(() => {
|
|
155
|
+
void activeUrl
|
|
156
|
+
void connected
|
|
157
|
+
refresh()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
type RenderRow =
|
|
161
|
+
| { type: 'value'; key: string; label: string; control: ControlDescriptor; value: unknown }
|
|
162
|
+
| { type: 'denied'; key: string; label: string; reason: string; grant: string }
|
|
163
|
+
|
|
164
|
+
// Resolve the active section's curated keys to render rows. A key whose
|
|
165
|
+
// source is denied becomes a "denied" row (absent control + explanation); a
|
|
166
|
+
// key with a live value becomes a "value" row; a key the server simply does
|
|
167
|
+
// not report is dropped (graceful degradation — no empty placeholder).
|
|
168
|
+
const rows = $derived.by<RenderRow[]>(() => {
|
|
169
|
+
const out: RenderRow[] = []
|
|
170
|
+
for (const key of filterKeys(active.keys, query)) {
|
|
171
|
+
const src = sourceForKey(key)
|
|
172
|
+
const reason = denied[src]
|
|
173
|
+
const label = resolveControl(key).label
|
|
174
|
+
if (reason) {
|
|
175
|
+
out.push({ type: 'denied', key, label, reason, grant: GRANTS[src] })
|
|
176
|
+
} else if (key in values) {
|
|
177
|
+
out.push({
|
|
178
|
+
type: 'value',
|
|
179
|
+
key,
|
|
180
|
+
label,
|
|
181
|
+
control: resolveControl(key, values[key]),
|
|
182
|
+
value: values[key],
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return out
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
function formatBytes(n: number): string {
|
|
190
|
+
if (!Number.isFinite(n)) return String(n)
|
|
191
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
192
|
+
let v = n
|
|
193
|
+
let i = 0
|
|
194
|
+
while (v >= 1024 && i < units.length - 1) {
|
|
195
|
+
v /= 1024
|
|
196
|
+
i++
|
|
197
|
+
}
|
|
198
|
+
return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function formatValue(control: ControlDescriptor, value: unknown): string {
|
|
202
|
+
if (control.kind === 'number' && control.key.endsWith('_bytes')) {
|
|
203
|
+
return formatBytes(Number(value))
|
|
204
|
+
}
|
|
205
|
+
return String(value)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Export the live config snapshot (what's currently inspected) as JSON. This
|
|
209
|
+
// is the honest, real action: the snapshot is read from connection.client, so
|
|
210
|
+
// it can be downloaded but NOT written back (the client exposes no config
|
|
211
|
+
// write/reset). Import/reset would have no real target here, so they are
|
|
212
|
+
// intentionally not offered rather than faked.
|
|
213
|
+
function exportConfig() {
|
|
214
|
+
const payload = {
|
|
215
|
+
url: activeUrl,
|
|
216
|
+
exportedAt: new Date().toISOString(),
|
|
217
|
+
config: values,
|
|
218
|
+
}
|
|
219
|
+
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
|
|
220
|
+
const url = URL.createObjectURL(blob)
|
|
221
|
+
const a = document.createElement('a')
|
|
222
|
+
a.href = url
|
|
223
|
+
a.download = `reddb-config-${new Date().toISOString().slice(0, 10)}.json`
|
|
224
|
+
a.click()
|
|
225
|
+
URL.revokeObjectURL(url)
|
|
226
|
+
}
|
|
227
|
+
</script>
|
|
228
|
+
|
|
229
|
+
<svelte:window onkeydown={onWindowKey} />
|
|
230
|
+
|
|
231
|
+
{#if !connected}
|
|
232
|
+
<div class="h-full overflow-auto bg-bg-0 p-6">
|
|
233
|
+
<PageHeader
|
|
234
|
+
eyebrow="Settings"
|
|
235
|
+
title="Settings"
|
|
236
|
+
subtitle="Live configuration for the active reddb connection."
|
|
237
|
+
/>
|
|
238
|
+
<EmptyState
|
|
239
|
+
icon={Unplug}
|
|
240
|
+
title="No active connection"
|
|
241
|
+
message="Connect to a reddb instance to inspect its live configuration."
|
|
242
|
+
hint="red serve"
|
|
243
|
+
/>
|
|
244
|
+
</div>
|
|
245
|
+
{:else}
|
|
246
|
+
<SplitView searchShortcut={null}>
|
|
247
|
+
{#snippet search()}
|
|
248
|
+
<div class="relative">
|
|
249
|
+
<Search
|
|
250
|
+
class="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-fg-3"
|
|
251
|
+
/>
|
|
252
|
+
<input
|
|
253
|
+
bind:this={searchEl}
|
|
254
|
+
type="text"
|
|
255
|
+
placeholder="Search this section ⌘K"
|
|
256
|
+
value={query}
|
|
257
|
+
oninput={(e) => setQuery(e.currentTarget.value)}
|
|
258
|
+
onkeydown={(e) => {
|
|
259
|
+
if (e.key === 'Escape') {
|
|
260
|
+
setQuery('')
|
|
261
|
+
e.currentTarget.blur()
|
|
262
|
+
}
|
|
263
|
+
}}
|
|
264
|
+
class="h-7 w-full rounded-md border border-line-2 bg-bg-2 pl-8 pr-2.5 text-xs text-fg-1 placeholder:text-fg-3 outline-none transition-colors focus:border-line-3"
|
|
265
|
+
/>
|
|
266
|
+
</div>
|
|
267
|
+
{/snippet}
|
|
268
|
+
|
|
269
|
+
{#snippet nav()}
|
|
270
|
+
<nav class="grid gap-0.5 p-2">
|
|
271
|
+
{#each SECTIONS as section (section.id)}
|
|
272
|
+
{@const SectionIcon = icons[section.icon] ?? Settings2}
|
|
273
|
+
<NavItem
|
|
274
|
+
label={section.label}
|
|
275
|
+
active={section.id === active.id}
|
|
276
|
+
onclick={() => (activeId = section.id)}
|
|
277
|
+
>
|
|
278
|
+
{#snippet icon()}<SectionIcon class="size-3.5" />{/snippet}
|
|
279
|
+
</NavItem>
|
|
280
|
+
{/each}
|
|
281
|
+
</nav>
|
|
282
|
+
{/snippet}
|
|
283
|
+
|
|
284
|
+
{#snippet footer()}
|
|
285
|
+
<div class="grid gap-2 px-3 py-2.5">
|
|
286
|
+
<button
|
|
287
|
+
type="button"
|
|
288
|
+
onclick={exportConfig}
|
|
289
|
+
disabled={loading}
|
|
290
|
+
class="inline-flex h-7 items-center justify-center gap-1.5 rounded-md border border-line-2 bg-bg-2 px-2.5 text-[11px] font-mono text-fg-1 transition-colors hover:border-line-3 hover:text-fg-0 disabled:cursor-not-allowed disabled:opacity-40"
|
|
291
|
+
>
|
|
292
|
+
<Download class="size-3.5" />
|
|
293
|
+
export config
|
|
294
|
+
</button>
|
|
295
|
+
<div class="text-[11px] text-fg-3">
|
|
296
|
+
Live from <span class="font-mono text-fg-2">{activeUrl}</span>.
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
{/snippet}
|
|
300
|
+
|
|
301
|
+
{#snippet content()}
|
|
302
|
+
{@const SectionIcon = icons[active.icon] ?? Settings2}
|
|
303
|
+
<div class="p-6">
|
|
304
|
+
<PageHeader eyebrow="Settings" title={active.label} subtitle={active.blurb}>
|
|
305
|
+
{#snippet actions()}
|
|
306
|
+
<button
|
|
307
|
+
type="button"
|
|
308
|
+
onclick={refresh}
|
|
309
|
+
disabled={loading}
|
|
310
|
+
class="inline-flex h-7 items-center gap-1.5 rounded-md border border-line-2 bg-bg-2 px-2.5 text-[11px] font-mono text-fg-1 hover:border-line-3 hover:text-fg-0 disabled:cursor-not-allowed disabled:opacity-40"
|
|
311
|
+
>
|
|
312
|
+
<RefreshCw class={loading ? 'size-3.5 animate-spin' : 'size-3.5'} />
|
|
313
|
+
refresh
|
|
314
|
+
</button>
|
|
315
|
+
{/snippet}
|
|
316
|
+
</PageHeader>
|
|
317
|
+
|
|
318
|
+
<section class="mb-6">
|
|
319
|
+
<SectionHeading title={active.label} class="mb-2">
|
|
320
|
+
{#snippet icon()}<SectionIcon class="size-3.5" />{/snippet}
|
|
321
|
+
{#snippet meta()}<Pill>{rows.length}</Pill>{/snippet}
|
|
322
|
+
</SectionHeading>
|
|
323
|
+
|
|
324
|
+
{#if rows.length === 0}
|
|
325
|
+
<div
|
|
326
|
+
class="rounded-lg border border-dashed border-line-1 bg-bg-1 px-3 py-6 text-center text-[12px] text-fg-3"
|
|
327
|
+
>
|
|
328
|
+
This server reports nothing for these keys.
|
|
329
|
+
</div>
|
|
330
|
+
{:else}
|
|
331
|
+
<div
|
|
332
|
+
class="divide-y divide-line-1 overflow-hidden rounded-lg border border-line-1 bg-bg-1"
|
|
333
|
+
>
|
|
334
|
+
{#each rows as row (row.key)}
|
|
335
|
+
{#if row.type === 'denied'}
|
|
336
|
+
<ListRow title={row.label} hint={row.key}>
|
|
337
|
+
{#snippet action()}
|
|
338
|
+
<span
|
|
339
|
+
class="inline-flex items-center gap-1.5 rounded-md border border-line-2 bg-bg-2 px-2 py-1 text-[11px] font-mono text-fg-3"
|
|
340
|
+
title={row.reason}
|
|
341
|
+
>
|
|
342
|
+
<Lock class="size-3" />
|
|
343
|
+
requires {row.grant}
|
|
344
|
+
</span>
|
|
345
|
+
{/snippet}
|
|
346
|
+
</ListRow>
|
|
347
|
+
{:else}
|
|
348
|
+
<ListRow
|
|
349
|
+
title={row.label}
|
|
350
|
+
description={row.control.description}
|
|
351
|
+
hint={row.key}
|
|
352
|
+
>
|
|
353
|
+
{#snippet action()}
|
|
354
|
+
{#if row.control.kind === 'boolean'}
|
|
355
|
+
<Pill tone={row.value ? 'accent' : 'muted'}>{row.value ? 'on' : 'off'}</Pill>
|
|
356
|
+
{:else if row.control.kind === 'enum'}
|
|
357
|
+
<span
|
|
358
|
+
class="font-mono text-[12px] text-accent"
|
|
359
|
+
title={`Options: ${row.control.options?.join(', ')}`}
|
|
360
|
+
>{row.value}</span>
|
|
361
|
+
{:else}
|
|
362
|
+
<span class="font-mono text-[12px] text-fg-1"
|
|
363
|
+
>{formatValue(row.control, row.value)}</span
|
|
364
|
+
>
|
|
365
|
+
{/if}
|
|
366
|
+
{/snippet}
|
|
367
|
+
</ListRow>
|
|
368
|
+
{/if}
|
|
369
|
+
{/each}
|
|
370
|
+
</div>
|
|
371
|
+
{/if}
|
|
372
|
+
</section>
|
|
373
|
+
</div>
|
|
374
|
+
{/snippet}
|
|
375
|
+
</SplitView>
|
|
376
|
+
{/if}
|
package/src/lib/Topbar.svelte
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Kbd } from '@reddb-io/ui-kit'
|
|
3
3
|
import ConnectDropdown from './ConnectDropdown.svelte'
|
|
4
4
|
import ActivityIndicator from './ActivityIndicator.svelte'
|
|
5
|
-
import { Search, Sun, Moon, Network, Database, FileCode2, Shield } from 'lucide-svelte'
|
|
5
|
+
import { Search, Sun, Moon, Network, Database, FileCode2, Shield, Settings2 } from 'lucide-svelte'
|
|
6
6
|
import { theme } from './theme.svelte'
|
|
7
7
|
import { useRouter } from './router.svelte'
|
|
8
8
|
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
const onCollections = $derived(router.view === 'collections')
|
|
19
19
|
const onCluster = $derived(router.view === 'cluster')
|
|
20
20
|
const onSecurity = $derived(router.view === 'security')
|
|
21
|
+
const onSettings = $derived(router.view === 'settings')
|
|
21
22
|
</script>
|
|
22
23
|
|
|
23
24
|
<header class="h-11 flex items-center justify-between px-3.5 bg-bg-1/80 backdrop-blur-md border-b border-line-1 relative z-10">
|
|
@@ -89,6 +90,19 @@
|
|
|
89
90
|
<Shield class="size-3.5" />
|
|
90
91
|
<span>Security</span>
|
|
91
92
|
</a>
|
|
93
|
+
<a
|
|
94
|
+
href={router.href({ view: 'settings' })}
|
|
95
|
+
onclick={(e) => router.go({ view: 'settings' }, e)}
|
|
96
|
+
title="Settings"
|
|
97
|
+
aria-current={onSettings ? 'page' : undefined}
|
|
98
|
+
class={[
|
|
99
|
+
'inline-flex items-center gap-1.5 h-7 px-2 rounded-md text-xs no-underline transition-colors',
|
|
100
|
+
onSettings ? 'bg-bg-2 text-fg-0' : 'text-fg-2 hover:text-fg-0 hover:bg-bg-2',
|
|
101
|
+
].join(' ')}
|
|
102
|
+
>
|
|
103
|
+
<Settings2 class="size-3.5" />
|
|
104
|
+
<span>Settings</span>
|
|
105
|
+
</a>
|
|
92
106
|
</nav>
|
|
93
107
|
</div>
|
|
94
108
|
|
package/src/lib/Workspace.svelte
CHANGED
|
@@ -15,9 +15,12 @@
|
|
|
15
15
|
import CollectionsView from './CollectionsView.svelte'
|
|
16
16
|
import ClusterView from './ClusterView.svelte'
|
|
17
17
|
import SecurityView from './SecurityView.svelte'
|
|
18
|
+
import SettingsView from './SettingsView.svelte'
|
|
19
|
+
import Appearance from './Appearance.svelte'
|
|
18
20
|
import { connection, setConnectionProvider } from './connections.svelte'
|
|
19
21
|
import { runQueryPreferStream, type ConnectionProvider } from '#reddb'
|
|
20
22
|
import { theme, type Theme } from './theme.svelte'
|
|
23
|
+
import { skins } from './skins/skins.svelte'
|
|
21
24
|
import { secureStore } from './secureStore.svelte'
|
|
22
25
|
import { pendingChanges, buildUpdateSql, type CommitOutcome } from './pending-changes.svelte'
|
|
23
26
|
import { queryTabs } from './query-tabs.svelte'
|
|
@@ -63,7 +66,7 @@
|
|
|
63
66
|
|
|
64
67
|
function navigateToBootRoute(route: string) {
|
|
65
68
|
const legacyView = route as View
|
|
66
|
-
if (legacyView === 'query' || legacyView === 'collections' || legacyView === 'cluster' || legacyView === 'security') {
|
|
69
|
+
if (legacyView === 'query' || legacyView === 'collections' || legacyView === 'cluster' || legacyView === 'security' || legacyView === 'settings' || legacyView === 'appearance') {
|
|
67
70
|
router.go({ view: legacyView }, undefined, true)
|
|
68
71
|
return
|
|
69
72
|
}
|
|
@@ -133,6 +136,12 @@
|
|
|
133
136
|
// svelte-ignore state_referenced_locally
|
|
134
137
|
theme.init({ target: themeTarget ?? null, persist: persistTheme, initial: initialTheme })
|
|
135
138
|
|
|
139
|
+
// Skin-as-data (#75): apply the persisted skin's seed-derived custom
|
|
140
|
+
// properties over the active theme. Scoped to the same target the theme
|
|
141
|
+
// uses, so an embedded host's shadow root is skinned, not the document.
|
|
142
|
+
// svelte-ignore state_referenced_locally
|
|
143
|
+
skins.init(themeTarget ?? null)
|
|
144
|
+
|
|
136
145
|
// Hard lock gate: while the secure store is locked, no automatic network
|
|
137
146
|
// traffic should fire — probing the configured URL would leak its existence
|
|
138
147
|
// and shape. Additionally (#21, acceptance #4) no traffic fires until a real
|
|
@@ -235,6 +244,10 @@
|
|
|
235
244
|
<ClusterView />
|
|
236
245
|
{:else if router.view === 'security'}
|
|
237
246
|
<SecurityView />
|
|
247
|
+
{:else if router.view === 'settings'}
|
|
248
|
+
<SettingsView />
|
|
249
|
+
{:else if router.view === 'appearance'}
|
|
250
|
+
<Appearance />
|
|
238
251
|
{:else}
|
|
239
252
|
<CollectionsView />
|
|
240
253
|
{/if}
|
package/src/lib/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ export { default as QueryEditor } from "./QueryEditor.svelte";
|
|
|
15
15
|
export { default as ResultsPane } from "./ResultsPane.svelte";
|
|
16
16
|
export { default as SchemaTree } from "./SchemaTree.svelte";
|
|
17
17
|
export { default as SecurityView } from "./SecurityView.svelte";
|
|
18
|
+
export { default as SettingsView } from "./SettingsView.svelte";
|
|
18
19
|
export { default as ShortcutOverlay } from "./ShortcutOverlay.svelte";
|
|
19
20
|
export { default as Splash } from "./Splash.svelte";
|
|
20
21
|
export { default as StatusBar } from "./StatusBar.svelte";
|
|
@@ -33,6 +34,7 @@ export type { Capability as CollectionCapability } from "./capability";
|
|
|
33
34
|
export * from "./collection-catalog";
|
|
34
35
|
export * from "./collection-pages";
|
|
35
36
|
export * from "./router.svelte";
|
|
37
|
+
export * from "./settings-sections";
|
|
36
38
|
export * from "./cn";
|
|
37
39
|
export * from "./connections.svelte";
|
|
38
40
|
export * from "./shortcuts";
|