From 5509a6d22274cd48e51f540595f4c9a3d0a43a4b Mon Sep 17 00:00:00 2001 From: Mitchell M Date: Tue, 6 Oct 2026 11:36:34 -0500 Subject: [PATCH] add manage page --- src/app.rs | 19 ++---- src/lib.rs | 1 + src/manage.rs | 123 ++++++++++++++++++++++++++++++++++++ style/main.scss | 162 +++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 288 insertions(+), 17 deletions(-) create mode 100644 src/manage.rs diff --git a/src/app.rs b/src/app.rs index f5d6400..993bb9e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,6 +5,8 @@ use leptos_router::{ StaticSegment, }; +use crate::manage::ManagePage; + pub fn shell(options: LeptosOptions) -> impl IntoView { view! { @@ -34,28 +36,15 @@ pub fn App() -> impl IntoView { // sets the document title - + <Title text="Manage Server"/> // content for this welcome page <Router> <main> <Routes fallback=|| "Page not found.".into_view()> - <Route path=StaticSegment("") view=HomePage/> + <Route path=StaticSegment("manage") view=ManagePage/> </Routes> </main> </Router> } } - -/// Renders the home page of your application. -#[component] -fn HomePage() -> impl IntoView { - // Creates a reactive value to update the button - let count = RwSignal::new(0); - let on_click = move |_| *count.write() += 1; - - view! { - <h1>"Welcome to Leptos!"</h1> - <button on:click=on_click>"Click Me: " {count}</button> - } -} diff --git a/src/lib.rs b/src/lib.rs index 151489d..c52c809 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod app; +pub mod manage; #[cfg(feature = "hydrate")] #[wasm_bindgen::prelude::wasm_bindgen] diff --git a/src/manage.rs b/src/manage.rs new file mode 100644 index 0000000..642e8d6 --- /dev/null +++ b/src/manage.rs @@ -0,0 +1,123 @@ +use leptos::prelude::*; + +#[cfg(feature = "ssr")] +use std::sync::atomic::{AtomicBool, Ordering}; + +#[cfg(feature = "ssr")] +static SERVER_LIVE: AtomicBool = AtomicBool::new(false); + +#[cfg(feature = "ssr")] +const STATUS_SECRET_KEY: &str = "password"; + +#[server] +async fn get_live_state() -> Result<bool, ServerFnError> { + Ok(SERVER_LIVE.load(Ordering::SeqCst)) +} + +#[server] +async fn set_live_state(live: bool, secret_key: String) -> Result<(), ServerFnError> { + if secret_key != STATUS_SECRET_KEY { + return Err(ServerFnError::ServerError("Invalid secret key".to_string())); + } + + SERVER_LIVE.store(live, Ordering::SeqCst); + Ok(()) +} + +/// Renders the server management page. +#[component] +pub fn ManagePage() -> impl IntoView { + let live_state = Resource::new(|| (), |_| get_live_state()); + + view! { + <section class="status-card"> + <p class="eyebrow">"CONTROL PANEL"</p> + <h1>"Server status"</h1> + <p class="intro">"Manage whether your server is accepting live traffic."</p> + <Suspense fallback=|| view! { <p class="loading">"Loading server status..."</p> }> + {move || Suspend::new(async move { + match live_state.await { + Ok(is_live) => view! { <LiveToggle initial=is_live/> }.into_any(), + Err(error) => view! { + <p class="error-message" role="alert"> + "Unable to load server status: " {error.to_string()} + </p> + }.into_any(), + } + })} + </Suspense> + </section> + } +} + +#[component] +fn LiveToggle(initial: bool) -> impl IntoView { + let (live, set_live) = signal(initial); + let (server_live, set_server_live) = signal(initial); + let (updating, set_updating) = signal(false); + let (secret_key, set_secret_key) = signal(String::new()); + let (error, set_error) = signal(None::<String>); + + let on_click = move |_| { + let previous = live.get_untracked(); + let checked = !previous; + let key = secret_key.get_untracked(); + set_live.set(checked); + set_updating.set(true); + set_error.set(None); + + leptos::task::spawn_local(async move { + match set_live_state(checked, key).await { + Ok(()) => set_server_live.set(checked), + Err(error) => { + set_live.set(previous); + set_error.set(Some(error.to_string())); + } + } + set_updating.set(false); + }); + }; + + view! { + <div class="status-controls"> + <label class="key-label" for="status-secret-key">"Secret key"</label> + <input + id="status-secret-key" + class="key-input" + type="password" + placeholder="Enter your secret key" + autocomplete="current-password" + prop:disabled=updating + on:input=move |event| set_secret_key.set(event_target_value(&event)) + /> + <button + class="status-button" + class:go-live=move || !live.get() + class:take-offline=move || live.get() + type="button" + prop:disabled=updating + on:click=on_click + > + {move || if updating.get() { + "Updating..." + } else if live.get() { + "Take Offline" + } else { + "Go Live" + }} + </button> + <p + class="status-indicator" + class:is-live=move || server_live.get() + aria-live="polite" + > + <span class="status-dot"></span> + "Server status: " + <strong>{move || if server_live.get() { "Live" } else { "Offline" }}</strong> + </p> + {move || error.get().map(|message| view! { + <p class="error-message" role="alert">{message}</p> + })} + </div> + } +} diff --git a/style/main.scss b/style/main.scss index e4538e1..d659011 100644 --- a/style/main.scss +++ b/style/main.scss @@ -1,4 +1,162 @@ body { - font-family: sans-serif; - text-align: center; + min-height: 100vh; + margin: 0; + display: grid; + place-items: center; + padding: 24px; + box-sizing: border-box; + background: + radial-gradient(ellipse at 15% 10%, rgba(31, 111, 235, 0.2), transparent 38%), + #0b1220; + color: #e6edf7; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +main { + width: min(100%, 480px); +} + +.status-card { + padding: clamp(28px, 7vw, 48px); + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 24px; + background: rgba(17, 27, 45, 0.92); + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); + text-align: left; +} + +.eyebrow { + margin: 0 0 12px; + color: #7cb3ff; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.16em; +} + +h1 { + margin: 0; + font-size: clamp(2rem, 8vw, 2.6rem); + letter-spacing: -0.04em; +} + +.intro { + margin: 12px 0 32px; + color: #a9b6c9; + line-height: 1.6; +} + +.status-controls { + display: grid; + gap: 12px; +} + +.key-label { + color: #d3dceb; + font-size: 0.9rem; + font-weight: 600; +} + +.key-input { + width: 100%; + box-sizing: border-box; + padding: 13px 15px; + border: 1px solid #35445c; + border-radius: 10px; + outline: none; + background: #0c1524; + color: #f5f8fc; + font: inherit; + transition: border-color 150ms ease, box-shadow 150ms ease; +} + +.key-input::placeholder { + color: #74839a; +} + +.key-input:focus { + border-color: #65a4ff; + box-shadow: 0 0 0 3px rgba(101, 164, 255, 0.18); +} + +.status-button { + margin-top: 8px; + padding: 14px 18px; + border: 0; + border-radius: 10px; + color: white; + font: inherit; + font-weight: 700; + cursor: pointer; + transition: background 150ms ease, transform 150ms ease, opacity 150ms ease; +} + +.status-button.go-live { + background: #16804a; +} + +.status-button.go-live:hover:not(:disabled) { + background: #1b9959; +} + +.status-button.take-offline { + background: #b54739; +} + +.status-button.take-offline:hover:not(:disabled) { + background: #d05242; +} + +.status-button:hover:not(:disabled) { + transform: translateY(-1px); +} + +.status-button:disabled { + opacity: 0.65; + cursor: wait; +} + +.status-indicator { + display: flex; + align-items: center; + gap: 9px; + margin: 14px 0 0; + color: #a9b6c9; + font-size: 0.95rem; +} + +.status-indicator strong { + color: #d3dceb; +} + +.status-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: #8491a5; +} + +.status-indicator.is-live .status-dot { + background: #37d488; + box-shadow: 0 0 12px rgba(55, 212, 136, 0.65); +} + +.status-indicator.is-live strong { + color: #6ee7a8; +} + +.loading { + color: #a9b6c9; +} + +.error-message { + margin: 8px 0 0; + color: #ff9387; + font-size: 0.9rem; + line-height: 1.5; +} + +@media (prefers-reduced-motion: reduce) { + * { + transition-duration: 0.01ms !important; + } } \ No newline at end of file