735 lines
24 KiB
Rust
735 lines
24 KiB
Rust
//! Admin-specific Leptos components.
|
|
|
|
use leptos::prelude::*;
|
|
|
|
// =============================================================================
|
|
// Auth Context Types (for sidebar rendering)
|
|
// These are duplicated from api/auth.rs because api is SSR-only
|
|
// =============================================================================
|
|
|
|
/// Realm info for auth context.
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
pub struct ManagedRealm {
|
|
pub slug: String,
|
|
pub name: String,
|
|
}
|
|
|
|
/// Auth context response for the frontend.
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
pub struct AuthContextResponse {
|
|
pub is_server_staff: bool,
|
|
pub managed_realms: Vec<ManagedRealm>,
|
|
}
|
|
|
|
/// Admin layout with sidebar navigation.
|
|
///
|
|
/// Note: CSS must be loaded by the parent app:
|
|
/// - chattyness-owner: Loads `/static/chattyness-owner.css` in AdminApp
|
|
/// - chattyness-app: Loads `/admin.css` in lazy wrapper functions
|
|
#[component]
|
|
pub fn AdminLayout(
|
|
/// Current page identifier for nav highlighting
|
|
current_page: &'static str,
|
|
/// Base path for navigation links (e.g., "/admin")
|
|
#[prop(default = "/admin")]
|
|
base_path: &'static str,
|
|
/// Whether the user is server staff (shows all server-level options)
|
|
#[prop(default = false)]
|
|
is_server_staff: bool,
|
|
/// Realms this user can manage (slug, name pairs)
|
|
#[prop(default = vec![])]
|
|
managed_realms: Vec<(String, String)>,
|
|
/// Page content
|
|
children: Children,
|
|
) -> impl IntoView {
|
|
view! {
|
|
<div class="admin-layout">
|
|
<Sidebar
|
|
current_page=current_page
|
|
base_path=base_path
|
|
is_server_staff=is_server_staff
|
|
managed_realms=managed_realms
|
|
/>
|
|
<main class="admin-content">
|
|
{children()}
|
|
</main>
|
|
</div>
|
|
}
|
|
}
|
|
|
|
/// Login page layout (no sidebar).
|
|
#[component]
|
|
pub fn LoginLayout(children: Children) -> impl IntoView {
|
|
view! {
|
|
<div class="login-layout">
|
|
{children()}
|
|
</div>
|
|
}
|
|
}
|
|
|
|
/// Fetch auth context from API (for client-side use).
|
|
///
|
|
/// The API path is determined dynamically based on the current URL:
|
|
/// - If at `/admin/...`, uses `/api/admin/auth/context`
|
|
/// - If at root, uses `/api/auth/context`
|
|
pub fn use_auth_context() -> LocalResource<Option<AuthContextResponse>> {
|
|
LocalResource::new(move || async move {
|
|
#[cfg(feature = "hydrate")]
|
|
{
|
|
use gloo_net::http::Request;
|
|
|
|
// Determine API base path from current URL
|
|
let api_path = web_sys::window()
|
|
.and_then(|w| w.location().pathname().ok())
|
|
.map(|path| {
|
|
if path.starts_with("/admin") {
|
|
"/api/admin/auth/context".to_string()
|
|
} else {
|
|
"/api/auth/context".to_string()
|
|
}
|
|
})
|
|
.unwrap_or_else(|| "/api/auth/context".to_string());
|
|
|
|
let resp = Request::get(&api_path).send().await;
|
|
match resp {
|
|
Ok(r) if r.ok() => r.json::<AuthContextResponse>().await.ok(),
|
|
_ => None,
|
|
}
|
|
}
|
|
#[cfg(not(feature = "hydrate"))]
|
|
{
|
|
None::<AuthContextResponse>
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Authenticated admin layout that fetches auth context.
|
|
///
|
|
/// This wrapper fetches the current user's auth context and passes it to
|
|
/// AdminLayout for proper sidebar rendering.
|
|
#[component]
|
|
pub fn AuthenticatedLayout(
|
|
current_page: &'static str,
|
|
#[prop(default = "/admin")] base_path: &'static str,
|
|
children: ChildrenFn,
|
|
) -> impl IntoView {
|
|
let auth_context = use_auth_context();
|
|
|
|
view! {
|
|
<Suspense fallback=move || view! {
|
|
<AdminLayout current_page=current_page base_path=base_path>
|
|
<div class="loading-container">
|
|
<p>"Loading..."</p>
|
|
</div>
|
|
</AdminLayout>
|
|
}>
|
|
{move || {
|
|
let children = children.clone();
|
|
auth_context.get().map(move |maybe_ctx| {
|
|
let children = children.clone();
|
|
match maybe_ctx {
|
|
Some(ctx) => {
|
|
let managed_realms: Vec<(String, String)> = ctx.managed_realms
|
|
.into_iter()
|
|
.map(|r| (r.slug, r.name))
|
|
.collect();
|
|
view! {
|
|
<AdminLayout
|
|
current_page=current_page
|
|
base_path=base_path
|
|
is_server_staff=ctx.is_server_staff
|
|
managed_realms=managed_realms
|
|
>
|
|
{children()}
|
|
</AdminLayout>
|
|
}.into_any()
|
|
}
|
|
None => {
|
|
// Fallback: show layout with default props (server staff view)
|
|
view! {
|
|
<AdminLayout current_page=current_page base_path=base_path is_server_staff=true>
|
|
{children()}
|
|
</AdminLayout>
|
|
}.into_any()
|
|
}
|
|
}
|
|
})
|
|
}}
|
|
</Suspense>
|
|
}
|
|
}
|
|
|
|
/// Sidebar navigation component.
|
|
#[component]
|
|
fn Sidebar(
|
|
current_page: &'static str,
|
|
base_path: &'static str,
|
|
#[prop(default = false)] is_server_staff: bool,
|
|
#[prop(default = vec![])] managed_realms: Vec<(String, String)>,
|
|
) -> impl IntoView {
|
|
// Build hrefs with base path
|
|
let dashboard_href = base_path.to_string();
|
|
let config_href = format!("{}/config", base_path);
|
|
let users_href = format!("{}/users", base_path);
|
|
let users_new_href = format!("{}/users/new", base_path);
|
|
let staff_href = format!("{}/staff", base_path);
|
|
let realms_href = format!("{}/realms", base_path);
|
|
let realms_new_href = format!("{}/realms/new", base_path);
|
|
let props_href = format!("{}/props", base_path);
|
|
let props_new_href = format!("{}/props/new", base_path);
|
|
|
|
view! {
|
|
<nav class="sidebar">
|
|
<div class="sidebar-header">
|
|
<a href="/admin" class="sidebar-brand">"Chattyness"</a>
|
|
<span class="sidebar-badge">"Admin"</span>
|
|
</div>
|
|
|
|
<ul class="nav-list">
|
|
// Server staff: show all server-level options
|
|
{if is_server_staff {
|
|
view! {
|
|
<NavItem
|
|
href=dashboard_href.clone()
|
|
label="Dashboard"
|
|
active=current_page == "dashboard"
|
|
/>
|
|
<NavItem
|
|
href=config_href.clone()
|
|
label="Server Config"
|
|
active=current_page == "config"
|
|
/>
|
|
|
|
<li class="nav-section">
|
|
<span class="nav-section-title">"User Management"</span>
|
|
<ul class="nav-sublist">
|
|
<NavItem
|
|
href=users_href.clone()
|
|
label="All Users"
|
|
active=current_page == "users"
|
|
sub=true
|
|
/>
|
|
<NavItem
|
|
href=users_new_href.clone()
|
|
label="Create User"
|
|
active=current_page == "users_new"
|
|
sub=true
|
|
/>
|
|
<NavItem
|
|
href=staff_href.clone()
|
|
label="Staff"
|
|
active=current_page == "staff"
|
|
sub=true
|
|
/>
|
|
</ul>
|
|
</li>
|
|
|
|
<li class="nav-section">
|
|
<span class="nav-section-title">"Realm Management"</span>
|
|
<ul class="nav-sublist">
|
|
<NavItem
|
|
href=realms_href.clone()
|
|
label="All Realms"
|
|
active=current_page == "realms"
|
|
sub=true
|
|
/>
|
|
<NavItem
|
|
href=realms_new_href.clone()
|
|
label="Create Realm"
|
|
active=current_page == "realms_new"
|
|
sub=true
|
|
/>
|
|
</ul>
|
|
</li>
|
|
|
|
<li class="nav-section">
|
|
<span class="nav-section-title">"Props"</span>
|
|
<ul class="nav-sublist">
|
|
<NavItem
|
|
href=props_href.clone()
|
|
label="All Props"
|
|
active=current_page == "props"
|
|
sub=true
|
|
/>
|
|
<NavItem
|
|
href=props_new_href.clone()
|
|
label="Create Prop"
|
|
active=current_page == "props_new"
|
|
sub=true
|
|
/>
|
|
</ul>
|
|
</li>
|
|
}.into_any()
|
|
} else {
|
|
// Realm admin: show realm-specific options only
|
|
view! {
|
|
{managed_realms.into_iter().map(|(slug, name)| {
|
|
let scenes_href = format!("{}/realms/{}/scenes", base_path, slug);
|
|
let scenes_new_href = format!("{}/realms/{}/scenes/new", base_path, slug);
|
|
let realm_settings_href = format!("{}/realms/{}", base_path, slug);
|
|
|
|
view! {
|
|
<li class="nav-section">
|
|
<span class="nav-section-title">{name}</span>
|
|
<ul class="nav-sublist">
|
|
<NavItem
|
|
href=scenes_href
|
|
label="Scenes"
|
|
active=current_page == "scenes"
|
|
sub=true
|
|
/>
|
|
<NavItem
|
|
href=scenes_new_href
|
|
label="Create Scene"
|
|
active=current_page == "scenes_new"
|
|
sub=true
|
|
/>
|
|
<NavItem
|
|
href=realm_settings_href
|
|
label="Realm Settings"
|
|
active=current_page == "realms"
|
|
sub=true
|
|
/>
|
|
</ul>
|
|
</li>
|
|
}
|
|
}).collect::<Vec<_>>()}
|
|
}.into_any()
|
|
}}
|
|
</ul>
|
|
|
|
<div class="sidebar-footer">
|
|
<button type="button" class="sidebar-logout" id="logout-btn">
|
|
"Logout"
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
}
|
|
}
|
|
|
|
/// Navigation item component.
|
|
///
|
|
/// Supports both static and dynamic hrefs via `#[prop(into)]`.
|
|
#[component]
|
|
fn NavItem(
|
|
#[prop(into)] href: String,
|
|
label: &'static str,
|
|
#[prop(default = false)] active: bool,
|
|
/// Whether this is a sub-item (indented)
|
|
#[prop(default = false)]
|
|
sub: bool,
|
|
) -> impl IntoView {
|
|
let link_class = match (active, sub) {
|
|
(true, false) => {
|
|
"block w-full px-6 py-2 bg-violet-600 text-white transition-all duration-150"
|
|
}
|
|
(false, false) => {
|
|
"block w-full px-6 py-2 text-gray-400 hover:bg-slate-700 hover:text-white transition-all duration-150"
|
|
}
|
|
(true, true) => {
|
|
"block w-full pl-10 pr-6 py-2 text-sm bg-violet-600 text-white transition-all duration-150"
|
|
}
|
|
(false, true) => {
|
|
"block w-full pl-10 pr-6 py-2 text-sm text-gray-400 hover:bg-slate-700 hover:text-white transition-all duration-150"
|
|
}
|
|
};
|
|
|
|
view! {
|
|
<li class="my-0.5">
|
|
<a href=href class=link_class>{label}</a>
|
|
</li>
|
|
}
|
|
}
|
|
|
|
/// Page header component.
|
|
#[component]
|
|
pub fn PageHeader(
|
|
/// Page title
|
|
title: &'static str,
|
|
/// Optional subtitle (accepts String or &str)
|
|
#[prop(optional, into)]
|
|
subtitle: String,
|
|
/// Optional action buttons
|
|
#[prop(optional)]
|
|
children: Option<Children>,
|
|
) -> impl IntoView {
|
|
let has_subtitle = !subtitle.is_empty();
|
|
|
|
view! {
|
|
<header class="page-header">
|
|
<div class="page-header-text">
|
|
<h1 class="page-title">{title}</h1>
|
|
{if has_subtitle {
|
|
view! { <p class="page-subtitle">{subtitle}</p> }.into_any()
|
|
} else {
|
|
view! {}.into_any()
|
|
}}
|
|
</div>
|
|
{if let Some(children) = children {
|
|
view! {
|
|
<div class="page-header-actions">
|
|
{children()}
|
|
</div>
|
|
}.into_any()
|
|
} else {
|
|
view! {}.into_any()
|
|
}}
|
|
</header>
|
|
}
|
|
}
|
|
|
|
/// Card component.
|
|
#[component]
|
|
pub fn Card(
|
|
#[prop(optional)] title: &'static str,
|
|
#[prop(optional)] class: &'static str,
|
|
children: Children,
|
|
) -> impl IntoView {
|
|
let has_title = !title.is_empty();
|
|
let card_class = if class.is_empty() {
|
|
"card".to_string()
|
|
} else {
|
|
format!("card {}", class)
|
|
};
|
|
|
|
view! {
|
|
<div class=card_class>
|
|
{if has_title {
|
|
view! { <h2 class="card-title">{title}</h2> }.into_any()
|
|
} else {
|
|
view! {}.into_any()
|
|
}}
|
|
{children()}
|
|
</div>
|
|
}
|
|
}
|
|
|
|
/// Detail grid for key-value display.
|
|
#[component]
|
|
pub fn DetailGrid(children: Children) -> impl IntoView {
|
|
view! {
|
|
<div class="detail-grid">
|
|
{children()}
|
|
</div>
|
|
}
|
|
}
|
|
|
|
/// Detail item within a detail grid.
|
|
#[component]
|
|
pub fn DetailItem(label: &'static str, children: Children) -> impl IntoView {
|
|
view! {
|
|
<div class="detail-item">
|
|
<div class="detail-label">{label}</div>
|
|
<div class="detail-value">{children()}</div>
|
|
</div>
|
|
}
|
|
}
|
|
|
|
/// Status badge component.
|
|
#[component]
|
|
pub fn StatusBadge(
|
|
/// Status text
|
|
status: String,
|
|
) -> impl IntoView {
|
|
let class = format!("status-badge status-{}", status.to_lowercase());
|
|
view! {
|
|
<span class=class>{status}</span>
|
|
}
|
|
}
|
|
|
|
/// Privacy badge component.
|
|
#[component]
|
|
pub fn PrivacyBadge(
|
|
/// Privacy level
|
|
privacy: String,
|
|
) -> impl IntoView {
|
|
let class = format!("privacy-badge privacy-{}", privacy.to_lowercase());
|
|
view! {
|
|
<span class=class>{privacy}</span>
|
|
}
|
|
}
|
|
|
|
/// NSFW badge component.
|
|
#[component]
|
|
pub fn NsfwBadge() -> impl IntoView {
|
|
view! {
|
|
<span class="nsfw-badge">"NSFW"</span>
|
|
}
|
|
}
|
|
|
|
/// Empty state placeholder.
|
|
#[component]
|
|
pub fn EmptyState(
|
|
message: &'static str,
|
|
#[prop(optional)] action_href: &'static str,
|
|
#[prop(optional)] action_text: &'static str,
|
|
) -> impl IntoView {
|
|
let has_action = !action_href.is_empty() && !action_text.is_empty();
|
|
|
|
view! {
|
|
<div class="empty-state">
|
|
<p>{message}</p>
|
|
{if has_action {
|
|
view! {
|
|
<a href=action_href class="btn btn-primary">{action_text}</a>
|
|
}.into_any()
|
|
} else {
|
|
view! {}.into_any()
|
|
}}
|
|
</div>
|
|
}
|
|
}
|
|
|
|
/// Alert message component.
|
|
#[component]
|
|
pub fn Alert(
|
|
/// Alert variant: success, error, warning, info
|
|
variant: &'static str,
|
|
/// Alert message
|
|
message: String,
|
|
) -> impl IntoView {
|
|
let class = format!("alert alert-{}", variant);
|
|
view! {
|
|
<div class=class role="alert">
|
|
<p>{message}</p>
|
|
</div>
|
|
}
|
|
}
|
|
|
|
/// Message alert that shows/hides based on signal state.
|
|
///
|
|
/// This component reduces the boilerplate for showing form feedback messages.
|
|
/// The message signal contains `Option<(String, bool)>` where bool is `is_success`.
|
|
#[component]
|
|
pub fn MessageAlert(message: ReadSignal<Option<(String, bool)>>) -> impl IntoView {
|
|
view! {
|
|
<Show when=move || message.get().is_some()>
|
|
{move || {
|
|
let (msg, is_success) = message.get().unwrap_or_default();
|
|
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
|
|
view! {
|
|
<div class=class role="alert">
|
|
<p>{msg}</p>
|
|
</div>
|
|
}
|
|
}}
|
|
</Show>
|
|
}
|
|
}
|
|
|
|
/// Message alert that works with RwSignal.
|
|
#[component]
|
|
pub fn MessageAlertRw(message: RwSignal<Option<(String, bool)>>) -> impl IntoView {
|
|
view! {
|
|
<Show when=move || message.get().is_some()>
|
|
{move || {
|
|
let (msg, is_success) = message.get().unwrap_or_default();
|
|
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
|
|
view! {
|
|
<div class=class role="alert">
|
|
<p>{msg}</p>
|
|
</div>
|
|
}
|
|
}}
|
|
</Show>
|
|
}
|
|
}
|
|
|
|
/// Temporary password display component.
|
|
///
|
|
/// Shows the temporary password with a warning to copy it.
|
|
#[component]
|
|
pub fn TempPasswordDisplay(
|
|
/// The temporary password signal
|
|
password: ReadSignal<Option<String>>,
|
|
/// Optional label (default: "Temporary Password:")
|
|
#[prop(default = "Temporary Password:")]
|
|
label: &'static str,
|
|
) -> impl IntoView {
|
|
view! {
|
|
<Show when=move || password.get().is_some()>
|
|
<div class="alert alert-info">
|
|
<p><strong>{label}</strong></p>
|
|
<code class="temp-password">{move || password.get().unwrap_or_default()}</code>
|
|
<p class="text-muted">"Copy this password now - it will not be shown again!"</p>
|
|
</div>
|
|
</Show>
|
|
}
|
|
}
|
|
|
|
/// Delete confirmation component with danger zone styling.
|
|
///
|
|
/// Shows a button that reveals a confirmation dialog when clicked.
|
|
#[component]
|
|
pub fn DeleteConfirmation(
|
|
/// Warning message to show
|
|
message: &'static str,
|
|
/// Button text (default: "Delete")
|
|
#[prop(default = "Delete")]
|
|
button_text: &'static str,
|
|
/// Confirm button text (default: "Yes, Delete")
|
|
#[prop(default = "Yes, Delete")]
|
|
confirm_text: &'static str,
|
|
/// Pending state signal
|
|
pending: ReadSignal<bool>,
|
|
/// Callback when delete is confirmed
|
|
on_confirm: impl Fn() + Clone + Send + Sync + 'static,
|
|
) -> impl IntoView {
|
|
let (show_confirm, set_show_confirm) = signal(false);
|
|
let on_confirm_clone = on_confirm.clone();
|
|
|
|
view! {
|
|
<Show
|
|
when=move || !show_confirm.get()
|
|
fallback=move || {
|
|
let on_confirm = on_confirm_clone.clone();
|
|
view! {
|
|
<div class="alert alert-warning">
|
|
<p>{message}</p>
|
|
<div class="action-buttons">
|
|
<button
|
|
type="button"
|
|
class="btn btn-danger"
|
|
disabled=move || pending.get()
|
|
on:click=move |_| on_confirm()
|
|
>
|
|
{move || if pending.get() { "Deleting..." } else { confirm_text }}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-secondary"
|
|
on:click=move |_| set_show_confirm.set(false)
|
|
>
|
|
"Cancel"
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
>
|
|
<button
|
|
type="button"
|
|
class="btn btn-danger"
|
|
on:click=move |_| set_show_confirm.set(true)
|
|
>
|
|
{button_text}
|
|
</button>
|
|
</Show>
|
|
}
|
|
}
|
|
|
|
/// Submit button with loading state.
|
|
#[component]
|
|
pub fn SubmitButton(
|
|
/// Button text when not pending
|
|
text: &'static str,
|
|
/// Button text when pending (default adds "...")
|
|
#[prop(optional)]
|
|
pending_text: Option<&'static str>,
|
|
/// Whether the button is in pending state
|
|
pending: ReadSignal<bool>,
|
|
/// Additional CSS classes
|
|
#[prop(default = "btn btn-primary")]
|
|
class: &'static str,
|
|
) -> impl IntoView {
|
|
let loading_text = pending_text.unwrap_or_else(|| {
|
|
// Can't do string manipulation at compile time, so use a simple approach
|
|
text
|
|
});
|
|
|
|
view! {
|
|
<button
|
|
type="submit"
|
|
class=class
|
|
disabled=move || pending.get()
|
|
>
|
|
{move || if pending.get() { loading_text } else { text }}
|
|
</button>
|
|
}
|
|
}
|
|
|
|
/// Loading spinner.
|
|
#[component]
|
|
pub fn LoadingSpinner(#[prop(optional)] message: &'static str) -> impl IntoView {
|
|
view! {
|
|
<div class="loading-spinner">
|
|
<div class="spinner"></div>
|
|
{if !message.is_empty() {
|
|
view! { <span class="loading-message">{message}</span> }.into_any()
|
|
} else {
|
|
view! {}.into_any()
|
|
}}
|
|
</div>
|
|
}
|
|
}
|
|
|
|
/// Role badge component.
|
|
#[component]
|
|
pub fn RoleBadge(role: String) -> impl IntoView {
|
|
let class = format!("role-badge role-{}", role.to_lowercase());
|
|
view! {
|
|
<span class=class>{role}</span>
|
|
}
|
|
}
|
|
|
|
/// Pagination component.
|
|
#[component]
|
|
pub fn Pagination(current_page: i64, base_url: String, query: String) -> impl IntoView {
|
|
let prev_page = current_page - 1;
|
|
let next_page = current_page + 1;
|
|
|
|
let prev_url = if query.is_empty() {
|
|
format!("{}?page={}", base_url, prev_page)
|
|
} else {
|
|
format!("{}?q={}&page={}", base_url, query, prev_page)
|
|
};
|
|
|
|
let next_url = if query.is_empty() {
|
|
format!("{}?page={}", base_url, next_page)
|
|
} else {
|
|
format!("{}?q={}&page={}", base_url, query, next_page)
|
|
};
|
|
|
|
view! {
|
|
<nav class="pagination">
|
|
{if current_page > 1 {
|
|
view! {
|
|
<a href=prev_url class="btn btn-secondary">"Previous"</a>
|
|
}.into_any()
|
|
} else {
|
|
view! {
|
|
<span class="btn btn-secondary btn-disabled">"Previous"</span>
|
|
}.into_any()
|
|
}}
|
|
<span class="pagination-info">"Page " {current_page}</span>
|
|
<a href=next_url class="btn btn-secondary">"Next"</a>
|
|
</nav>
|
|
}
|
|
}
|
|
|
|
/// Search form component for list pages.
|
|
#[component]
|
|
pub fn SearchForm(
|
|
/// Form action URL (e.g., "/admin/users")
|
|
action: &'static str,
|
|
/// Placeholder text
|
|
placeholder: &'static str,
|
|
/// Current search value signal
|
|
search_input: RwSignal<String>,
|
|
) -> impl IntoView {
|
|
view! {
|
|
<form method="get" action=action class="search-form">
|
|
<div class="search-box">
|
|
<input
|
|
type="search"
|
|
name="q"
|
|
placeholder=placeholder
|
|
class="form-input search-input"
|
|
prop:value=move || search_input.get()
|
|
on:input=move |ev| search_input.set(event_target_value(&ev))
|
|
/>
|
|
<button type="submit" class="btn btn-primary">"Search"</button>
|
|
</div>
|
|
</form>
|
|
}
|
|
}
|