add initial crates and apps

This commit is contained in:
Evan Carroll 2026-01-12 15:34:40 -06:00
parent 5c87ba3519
commit 1ca300098f
113 changed files with 28169 additions and 0 deletions

View file

@ -0,0 +1,729 @@
//! 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>
}
}