add initial crates and apps
This commit is contained in:
parent
5c87ba3519
commit
1ca300098f
113 changed files with 28169 additions and 0 deletions
729
crates/chattyness-admin-ui/src/components.rs
Normal file
729
crates/chattyness-admin-ui/src/components.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue