//! 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, } /// 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! {
{children()}
} } /// Login page layout (no sidebar). #[component] pub fn LoginLayout(children: Children) -> impl IntoView { view! {
{children()}
} } /// 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> { 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::().await.ok(), _ => None, } } #[cfg(not(feature = "hydrate"))] { None:: } }) } /// 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! {

"Loading..."

}> {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! { {children()} }.into_any() } None => { // Fallback: show layout with default props (server staff view) view! { {children()} }.into_any() } } }) }}
} } /// 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! { } } /// 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! {
  • {label}
  • } } /// 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, ) -> impl IntoView { let has_subtitle = !subtitle.is_empty(); view! { } } /// 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! {
    {if has_title { view! {

    {title}

    }.into_any() } else { view! {}.into_any() }} {children()}
    } } /// Detail grid for key-value display. #[component] pub fn DetailGrid(children: Children) -> impl IntoView { view! {
    {children()}
    } } /// Detail item within a detail grid. #[component] pub fn DetailItem(label: &'static str, children: Children) -> impl IntoView { view! {
    {label}
    {children()}
    } } /// Status badge component. #[component] pub fn StatusBadge( /// Status text status: String, ) -> impl IntoView { let class = format!("status-badge status-{}", status.to_lowercase()); view! { {status} } } /// Privacy badge component. #[component] pub fn PrivacyBadge( /// Privacy level privacy: String, ) -> impl IntoView { let class = format!("privacy-badge privacy-{}", privacy.to_lowercase()); view! { {privacy} } } /// NSFW badge component. #[component] pub fn NsfwBadge() -> impl IntoView { view! { "NSFW" } } /// 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! {

    {message}

    {if has_action { view! { {action_text} }.into_any() } else { view! {}.into_any() }}
    } } /// 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! { } } /// 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>) -> impl IntoView { view! { {move || { let (msg, is_success) = message.get().unwrap_or_default(); let class = if is_success { "alert alert-success" } else { "alert alert-error" }; view! { } }} } } /// Message alert that works with RwSignal. #[component] pub fn MessageAlertRw(message: RwSignal>) -> impl IntoView { view! { {move || { let (msg, is_success) = message.get().unwrap_or_default(); let class = if is_success { "alert alert-success" } else { "alert alert-error" }; view! { } }} } } /// Temporary password display component. /// /// Shows the temporary password with a warning to copy it. #[component] pub fn TempPasswordDisplay( /// The temporary password signal password: ReadSignal>, /// Optional label (default: "Temporary Password:") #[prop(default = "Temporary Password:")] label: &'static str, ) -> impl IntoView { view! {

    {label}

    {move || password.get().unwrap_or_default()}

    "Copy this password now - it will not be shown again!"

    } } /// 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, /// 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! {

    {message}

    } } >
    } } /// 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, /// 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! { } } /// Loading spinner. #[component] pub fn LoadingSpinner(#[prop(optional)] message: &'static str) -> impl IntoView { view! {
    {if !message.is_empty() { view! { {message} }.into_any() } else { view! {}.into_any() }}
    } } /// Role badge component. #[component] pub fn RoleBadge(role: String) -> impl IntoView { let class = format!("role-badge role-{}", role.to_lowercase()); view! { {role} } } /// 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! { } } /// 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, ) -> impl IntoView { view! {
    } }