Skip to content

Commit 475eced

Browse files
feat: Phase 5 — Connection Manager, new-connection + preferences modals
Faithful Connection Manager: status-pill cards (online/read-only/offline) with DB/ping/server stats from the registry, recent-activity list, and a Preferences link. New-connection modal with Engine picker AND Protocol field omitted (single-engine; transport undecided per CLAUDE.md). Preferences modal with six panes; Theme control wired to the Preferences signal. Modal primitive gains a `wide` variant; ModalHost + bodies moved to src/modals/.
1 parent a785732 commit 475eced

7 files changed

Lines changed: 373 additions & 58 deletions

File tree

nodedb-studio/src/app.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use std::rc::Rc;
1212

1313
use dioxus::prelude::*;
1414

15-
use crate::components::modal::ModalHost;
15+
use crate::modals::ModalHost;
1616
use crate::services::connection_service::{ConnectionService, MockConnectionService};
1717
use crate::state::connection::ActiveConnection;
1818
use crate::state::preferences::Preferences;
Lines changed: 7 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,24 @@
1-
//! Generic modal primitive + the modal host.
2-
//!
3-
//! `Modal` is the reusable overlay shell (backdrop, header, close). `ModalHost`
4-
//! decides which modal is open from the shared `ModalKind` signal. The bodies
5-
//! here are Phase-3 placeholders; Phase 5 replaces them with the real
6-
//! `modals::new_connection` and `modals::preferences` content.
1+
//! Generic modal primitive: the reusable overlay shell (backdrop, panel,
2+
//! header, close). Callers pass the body and footer as children. The dispatch
3+
//! that decides *which* modal is open lives in `crate::modals`.
74
85
use dioxus::prelude::*;
96

107
use crate::state::ui::ModalKind;
118

129
/// Reusable modal shell. Clicking the backdrop or the close button dismisses;
1310
/// clicks inside the panel do not (matches the mockup's `closeModal`).
11+
/// `wide` selects the `.modal.wide` variant used by Preferences.
1412
#[component]
15-
pub fn Modal(title: String, children: Element) -> Element {
13+
pub fn Modal(title: String, #[props(default = false)] wide: bool, children: Element) -> Element {
1614
let mut modal = use_context::<Signal<Option<ModalKind>>>();
15+
let panel_class = if wide { "modal wide" } else { "modal" };
1716
rsx! {
1817
div {
1918
class: "modal-overlay open",
2019
onclick: move |_| modal.set(None),
2120
div {
22-
class: "modal",
21+
class: "{panel_class}",
2322
onclick: move |e| e.stop_propagation(),
2423
div { class: "modal-header",
2524
h3 { "{title}" }
@@ -30,30 +29,3 @@ pub fn Modal(title: String, children: Element) -> Element {
3029
}
3130
}
3231
}
33-
34-
/// Renders the currently-open modal, or nothing.
35-
#[component]
36-
pub fn ModalHost() -> Element {
37-
let modal = use_context::<Signal<Option<ModalKind>>>();
38-
let kind = *modal.read();
39-
40-
match kind {
41-
None => rsx! {},
42-
Some(ModalKind::NewConnection) => rsx! {
43-
Modal { title: "New connection",
44-
div { class: "modal-body",
45-
// Phase 5 ports the real form here. Per CLAUDE.md the form
46-
// must NOT include an Engine picker (single-engine client).
47-
p { "Connection form — ported in Phase 5." }
48-
}
49-
}
50-
},
51-
Some(ModalKind::Preferences) => rsx! {
52-
Modal { title: "Preferences",
53-
div { class: "modal-body",
54-
p { "Preferences panes (theme, fonts, keyboard, telemetry, about) — ported in Phase 5." }
55-
}
56-
}
57-
},
58-
}
59-
}

nodedb-studio/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
mod app;
66
mod components;
77
mod data;
8+
mod modals;
89
mod models;
910
mod routes;
1011
mod services;

nodedb-studio/src/modals/mod.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//! Modal content + the host that decides which modal is open.
2+
3+
pub mod new_connection;
4+
pub mod preferences;
5+
6+
use dioxus::prelude::*;
7+
8+
use crate::components::modal::Modal;
9+
use crate::modals::new_connection::NewConnectionForm;
10+
use crate::modals::preferences::PreferencesPanes;
11+
use crate::state::ui::ModalKind;
12+
13+
/// Renders the currently-open modal (from the shared `ModalKind` signal), or
14+
/// nothing. Overlays everything; provided once near the app root.
15+
#[component]
16+
pub fn ModalHost() -> Element {
17+
let modal = use_context::<Signal<Option<ModalKind>>>();
18+
match *modal.read() {
19+
None => rsx! {},
20+
Some(ModalKind::NewConnection) => rsx! {
21+
Modal { title: "New connection", NewConnectionForm {} }
22+
},
23+
Some(ModalKind::Preferences) => rsx! {
24+
Modal { title: "Preferences", wide: true, PreferencesPanes {} }
25+
},
26+
}
27+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//! New-connection modal body.
2+
//!
3+
//! Per CLAUDE.md §1 this is a single-engine client: there is NO Engine picker.
4+
//! The Protocol field is also omitted — NodeDB's transport is undecided
5+
//! (CLAUDE.md §2), so the form doesn't assert one. Static in the skeleton.
6+
7+
use dioxus::prelude::*;
8+
9+
use crate::state::ui::ModalKind;
10+
11+
#[component]
12+
pub fn NewConnectionForm() -> Element {
13+
let mut modal = use_context::<Signal<Option<ModalKind>>>();
14+
let mut close = move |_| modal.set(None);
15+
rsx! {
16+
div { class: "modal-body",
17+
div { class: "form-field",
18+
label { "Name" }
19+
input { value: "local-nodedb-dev-2" }
20+
}
21+
div { class: "form-row",
22+
div { class: "form-field", label { "Host" } input { value: "localhost" } }
23+
div { class: "form-field", label { "Port" } input { value: "2480" } }
24+
}
25+
div { class: "form-field",
26+
label { "Auth method" }
27+
select { option { "Username + password" } option { "Token" } option { "mTLS" } }
28+
}
29+
div { class: "form-row",
30+
div { class: "form-field", label { "Username" } input { value: "root" } }
31+
div { class: "form-field", label { "Password" } input { r#type: "password", value: "••••••••" } }
32+
}
33+
div { style: "display: flex; gap: 8px; align-items: center; padding-top: 6px;",
34+
span { class: "pill info", "i" }
35+
span { style: "font-size: 11px; color: var(--text-secondary);", "Credentials are stored in your OS keychain." }
36+
}
37+
}
38+
div { class: "modal-footer",
39+
button { class: "btn ghost", onclick: move |e| close(e), "Cancel" }
40+
button { class: "btn", "Test" }
41+
button { class: "btn", "Save" }
42+
button { class: "btn primary", onclick: move |e| close(e), "Save & connect" }
43+
}
44+
}
45+
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
//! Preferences modal body: a sidebar of categories + the active pane.
2+
//!
3+
//! App-level only (theme, editor, keyboard, security, telemetry, about) — never
4+
//! per-connection. The Theme control is wired to the `Preferences` signal; the
5+
//! remaining controls are visual placeholders, as in the mockup. Note the CSS
6+
//! port only defines OS-driven theming, so changing Theme updates state without
7+
//! a manual override repaint (faithful to the mockup, which is also static).
8+
9+
use dioxus::prelude::*;
10+
11+
use crate::state::preferences::{Preferences, Theme};
12+
use crate::state::ui::ModalKind;
13+
14+
const CATS: [(&str, &str); 6] = [
15+
("appearance", "Appearance"),
16+
("editor", "Editor"),
17+
("keyboard", "Keyboard"),
18+
("security", "Security"),
19+
("telemetry", "Telemetry"),
20+
("about", "About"),
21+
];
22+
23+
#[component]
24+
pub fn PreferencesPanes() -> Element {
25+
let mut modal = use_context::<Signal<Option<ModalKind>>>();
26+
let mut pane = use_signal(|| "appearance".to_string());
27+
let current = pane.read().clone();
28+
29+
rsx! {
30+
div { class: "prefs-layout",
31+
div { class: "prefs-sidebar",
32+
for (key, label) in CATS {
33+
{
34+
let is_active = current == key;
35+
let k = key.to_string();
36+
rsx! {
37+
div {
38+
class: if is_active { "prefs-cat active" } else { "prefs-cat" },
39+
onclick: move |_| pane.set(k.clone()),
40+
"{label}"
41+
}
42+
}
43+
}
44+
}
45+
}
46+
div { class: "prefs-content",
47+
match current.as_str() {
48+
"editor" => rsx! { EditorPane {} },
49+
"keyboard" => rsx! { KeyboardPane {} },
50+
"security" => rsx! { SecurityPane {} },
51+
"telemetry" => rsx! { TelemetryPane {} },
52+
"about" => rsx! { AboutPane {} },
53+
_ => rsx! { AppearancePane {} },
54+
}
55+
}
56+
}
57+
div { class: "modal-footer",
58+
button { class: "btn primary", onclick: move |_| modal.set(None), "Done" }
59+
}
60+
}
61+
}
62+
63+
#[component]
64+
fn AppearancePane() -> Element {
65+
let mut prefs = use_context::<Signal<Preferences>>();
66+
let theme = prefs.read().theme;
67+
let cls = |t: Theme| if theme == t { "active" } else { "" };
68+
rsx! {
69+
div { class: "prefs-pane active",
70+
h2 { "Appearance" }
71+
p { "How the studio looks. Follows your OS theme by default." }
72+
div { class: "form-field",
73+
label { "Theme" }
74+
div { class: "segmented", style: "display:flex;",
75+
button { class: cls(Theme::Light), onclick: move |_| prefs.write().theme = Theme::Light, "Light" }
76+
button { class: cls(Theme::Dark), onclick: move |_| prefs.write().theme = Theme::Dark, "Dark" }
77+
button { class: cls(Theme::System), onclick: move |_| prefs.write().theme = Theme::System, "Follow OS" }
78+
}
79+
}
80+
div { class: "form-field",
81+
label { "Density" }
82+
div { class: "segmented", style: "display:flex;",
83+
button { "Compact" } button { class: "active", "Comfortable" } button { "Spacious" }
84+
}
85+
}
86+
div { class: "form-row",
87+
div { class: "form-field", label { "UI font size" } select { option { "12px" } option { "13px (default)" } option { "14px" } } }
88+
div { class: "form-field", label { "Editor font" } select { option { "JetBrains Mono" } option { "SF Mono" } option { "Menlo" } } }
89+
}
90+
div { class: "form-field",
91+
label { "Accent color" }
92+
div { style: "display:flex; gap:8px;",
93+
div { style: "width:24px; height:24px; background:#1a1a18; border-radius:4px; outline: 2px solid var(--text-primary); outline-offset: 2px;" }
94+
div { style: "width:24px; height:24px; background:#185fa5; border-radius:4px;" }
95+
div { style: "width:24px; height:24px; background:#3b6d11; border-radius:4px;" }
96+
div { style: "width:24px; height:24px; background:#854f0b; border-radius:4px;" }
97+
}
98+
}
99+
}
100+
}
101+
}
102+
103+
#[component]
104+
fn EditorPane() -> Element {
105+
rsx! {
106+
div { class: "prefs-pane active",
107+
h2 { "Editor" }
108+
p { "Code editor behavior across SQL, Cypher, and the REPL." }
109+
div { class: "form-row",
110+
div { class: "form-field", label { "Tab size" } select { option { "2 spaces" } option { "4 spaces (default)" } option { "Tab" } } }
111+
div { class: "form-field", label { "Word wrap" } select { option { "Off" } option { "On" } } }
112+
}
113+
div { class: "form-field", label { "Auto-format on save" } div { class: "segmented", style: "display:flex;", button { "Off" } button { class: "active", "On" } } }
114+
div { class: "form-field", label { "Show whitespace" } div { class: "segmented", style: "display:flex;", button { class: "active", "Off" } button { "On" } } }
115+
div { class: "form-field", label { "Bracket pair colorization" } div { class: "segmented", style: "display:flex;", button { "Off" } button { class: "active", "On" } } }
116+
}
117+
}
118+
}
119+
120+
#[component]
121+
fn KeyboardPane() -> Element {
122+
let rows = [
123+
("Command palette", "⌘K"),
124+
("Disconnect", "⌘D"),
125+
("Run query", "⌘↵"),
126+
("Open preferences", "⌘,"),
127+
("New query tab", "⌘T"),
128+
("Toggle theme", "⌘⇧L"),
129+
("Focus Explorer sidebar", "⌘B"),
130+
];
131+
rsx! {
132+
div { class: "prefs-pane active",
133+
h2 { "Keyboard shortcuts" }
134+
p { "Customize bindings. Defaults shown." }
135+
div { style: "font-family: var(--font-mono); font-size: 12px;",
136+
for (cmd, key) in rows {
137+
div { style: "display: grid; grid-template-columns: 1fr auto; padding: 8px 0; border-bottom: 0.5px solid var(--border-soft);",
138+
span { "{cmd}" }
139+
span { "{key}" }
140+
}
141+
}
142+
}
143+
}
144+
}
145+
}
146+
147+
#[component]
148+
fn SecurityPane() -> Element {
149+
rsx! {
150+
div { class: "prefs-pane active",
151+
h2 { "Security" }
152+
p { "Credential storage and session protections. These are app-level — per-connection auth settings live with each connection." }
153+
div { class: "form-field", label { "Credential storage" } select { option { "OS keychain (default)" } option { "Encrypted file" } option { "In-memory only" } } }
154+
div { class: "form-field", label { "Lock app after idle" } select { option { "Never" } option { "5 minutes" } option { "15 minutes" } option { "1 hour" } } }
155+
div { class: "form-field", label { "Confirm destructive queries" } div { class: "segmented", style: "display:flex;", button { "Off" } button { class: "active", "On" } } }
156+
div { class: "form-field", label { "Mask sensitive query results" } div { class: "segmented", style: "display:flex;", button { class: "active", "Off" } button { "On" } } }
157+
}
158+
}
159+
}
160+
161+
#[component]
162+
fn TelemetryPane() -> Element {
163+
rsx! {
164+
div { class: "prefs-pane active",
165+
h2 { "Telemetry" }
166+
p { "Anonymous usage data helps improve NodeDB-Studio. Off by default." }
167+
div { class: "form-field", label { "Send anonymous usage data" } div { class: "segmented", style: "display:flex;", button { class: "active", "Off" } button { "On" } } }
168+
div { class: "form-field", label { "Send crash reports" } div { class: "segmented", style: "display:flex;", button { "Off" } button { class: "active", "On" } } }
169+
div { style: "font-family: var(--font-mono); font-size: 11px; color: var(--text-tertiary); margin-top: 10px; line-height: 1.6;", "No connection data, credentials, or query content is ever transmitted. Only anonymized UI interactions and error stack traces." }
170+
}
171+
}
172+
}
173+
174+
#[component]
175+
fn AboutPane() -> Element {
176+
rsx! {
177+
div { class: "prefs-pane active",
178+
h2 { "About NodeDB-Studio" }
179+
p { "Database studio built on Dioxus + Rust." }
180+
div { style: "font-family: var(--font-mono); font-size: 11px; line-height: 1.8; margin-top: 14px;",
181+
AboutRow { k: "version", v: "dev" }
182+
AboutRow { k: "build", v: "2026.06.13" }
183+
AboutRow { k: "runtime", v: "dioxus 0.6" }
184+
AboutRow { k: "platform", v: "darwin-arm64" }
185+
AboutRow { k: "license", v: "Apache 2.0" }
186+
}
187+
div { style: "margin-top: 18px; display: flex; gap: 8px;",
188+
button { class: "btn small", "Check for updates" }
189+
button { class: "btn small", "Release notes" }
190+
button { class: "btn small", "View license" }
191+
}
192+
}
193+
}
194+
}
195+
196+
#[component]
197+
fn AboutRow(k: String, v: String) -> Element {
198+
rsx! {
199+
div {
200+
span { style: "color:var(--text-tertiary); display:inline-block; width:80px;", "{k}" }
201+
"{v}"
202+
}
203+
}
204+
}

0 commit comments

Comments
 (0)