add manage page

This commit is contained in:
2026-10-06 11:36:34 -05:00
parent cf101952a0
commit 5509a6d222
4 changed files with 288 additions and 17 deletions
+4 -15
View File
@@ -5,6 +5,8 @@ use leptos_router::{
StaticSegment,
};
use crate::manage::ManagePage;
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
<!DOCTYPE html>
@@ -34,28 +36,15 @@ pub fn App() -> impl IntoView {
<Stylesheet id="leptos" href="/pkg/code_submission.css"/>
// sets the document title
<Title text="Welcome to Leptos"/>
<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>
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod app;
pub mod manage;
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
+123
View File
@@ -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>
}
}
+160 -2
View File
@@ -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;
}
}