add initial crates and apps
This commit is contained in:
parent
5c87ba3519
commit
1ca300098f
113 changed files with 28169 additions and 0 deletions
252
crates/chattyness-admin-ui/src/pages/config.rs
Normal file
252
crates/chattyness-admin-ui/src/pages/config.rs
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
//! Server config page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
use crate::components::{Card, MessageAlert, PageHeader};
|
||||
use crate::hooks::use_fetch;
|
||||
use crate::models::ServerConfig;
|
||||
|
||||
/// Config page component.
|
||||
#[component]
|
||||
pub fn ConfigPage() -> impl IntoView {
|
||||
let (message, set_message) = signal(Option::<(String, bool)>::None);
|
||||
let (pending, set_pending) = signal(false);
|
||||
|
||||
let config = use_fetch::<ServerConfig>(|| "/api/admin/config".to_string());
|
||||
|
||||
view! {
|
||||
<PageHeader
|
||||
title="Server Configuration"
|
||||
subtitle="Manage global server settings"
|
||||
/>
|
||||
|
||||
<Suspense fallback=|| view! { <p>"Loading configuration..."</p> }>
|
||||
{move || {
|
||||
config.get().map(|maybe_config| {
|
||||
match maybe_config {
|
||||
Some(cfg) => view! {
|
||||
<ConfigForm config=cfg message=message set_message=set_message pending=pending set_pending=set_pending />
|
||||
}.into_any(),
|
||||
None => view! {
|
||||
<Card>
|
||||
<p class="text-error">"Failed to load configuration. You may not have permission to access this page."</p>
|
||||
</Card>
|
||||
}.into_any()
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
/// Config form component.
|
||||
#[component]
|
||||
#[allow(unused_variables)]
|
||||
fn ConfigForm(
|
||||
config: ServerConfig,
|
||||
message: ReadSignal<Option<(String, bool)>>,
|
||||
set_message: WriteSignal<Option<(String, bool)>>,
|
||||
pending: ReadSignal<bool>,
|
||||
set_pending: WriteSignal<bool>,
|
||||
) -> impl IntoView {
|
||||
let (name, set_name) = signal(config.name.clone());
|
||||
let (description, set_description) = signal(config.description.clone().unwrap_or_default());
|
||||
let (welcome_message, set_welcome_message) =
|
||||
signal(config.welcome_message.clone().unwrap_or_default());
|
||||
let (max_users_per_channel, set_max_users_per_channel) = signal(config.max_users_per_channel);
|
||||
let (message_rate_limit, set_message_rate_limit) = signal(config.message_rate_limit);
|
||||
let (message_rate_window_seconds, set_message_rate_window_seconds) =
|
||||
signal(config.message_rate_window_seconds);
|
||||
let (allow_guest_access, set_allow_guest_access) = signal(config.allow_guest_access);
|
||||
let (allow_user_uploads, set_allow_user_uploads) = signal(config.allow_user_uploads);
|
||||
let (require_email_verification, set_require_email_verification) =
|
||||
signal(config.require_email_verification);
|
||||
|
||||
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
set_pending.set(true);
|
||||
set_message.set(None);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
use leptos::task::spawn_local;
|
||||
|
||||
let data = serde_json::json!({
|
||||
"name": name.get(),
|
||||
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
|
||||
"welcome_message": if welcome_message.get().is_empty() { None::<String> } else { Some(welcome_message.get()) },
|
||||
"max_users_per_channel": max_users_per_channel.get(),
|
||||
"message_rate_limit": message_rate_limit.get(),
|
||||
"message_rate_window_seconds": message_rate_window_seconds.get(),
|
||||
"allow_guest_access": allow_guest_access.get(),
|
||||
"allow_user_uploads": allow_user_uploads.get(),
|
||||
"require_email_verification": require_email_verification.get()
|
||||
});
|
||||
|
||||
spawn_local(async move {
|
||||
let response = Request::put("/api/admin/config")
|
||||
.json(&data)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
set_message.set(Some((
|
||||
"Configuration saved successfully!".to_string(),
|
||||
true,
|
||||
)));
|
||||
}
|
||||
Ok(_) => {
|
||||
set_message.set(Some(("Failed to save configuration".to_string(), false)));
|
||||
}
|
||||
Err(_) => {
|
||||
set_message.set(Some(("Network error".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<Card>
|
||||
<form on:submit=on_submit class="config-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="name" class="form-label">"Server Name"</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
required=true
|
||||
class="form-input"
|
||||
prop:value=move || name.get()
|
||||
on:input=move |ev| set_name.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="form-label">"Server Description"</label>
|
||||
<textarea
|
||||
id="description"
|
||||
class="form-textarea"
|
||||
prop:value=move || description.get()
|
||||
on:input=move |ev| set_description.set(event_target_value(&ev))
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="welcome_message" class="form-label">"Welcome Message"</label>
|
||||
<textarea
|
||||
id="welcome_message"
|
||||
class="form-textarea"
|
||||
prop:value=move || welcome_message.get()
|
||||
on:input=move |ev| set_welcome_message.set(event_target_value(&ev))
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="max_users_per_channel" class="form-label">"Max Users per Channel"</label>
|
||||
<input
|
||||
type="number"
|
||||
id="max_users_per_channel"
|
||||
min="1"
|
||||
max="1000"
|
||||
class="form-input"
|
||||
prop:value=move || max_users_per_channel.get()
|
||||
on:input=move |ev| {
|
||||
if let Ok(v) = event_target_value(&ev).parse() {
|
||||
set_max_users_per_channel.set(v);
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="message_rate_limit" class="form-label">"Message Rate Limit"</label>
|
||||
<input
|
||||
type="number"
|
||||
id="message_rate_limit"
|
||||
min="1"
|
||||
max="100"
|
||||
class="form-input"
|
||||
prop:value=move || message_rate_limit.get()
|
||||
on:input=move |ev| {
|
||||
if let Ok(v) = event_target_value(&ev).parse() {
|
||||
set_message_rate_limit.set(v);
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="message_rate_window_seconds" class="form-label">"Rate Window (seconds)"</label>
|
||||
<input
|
||||
type="number"
|
||||
id="message_rate_window_seconds"
|
||||
min="1"
|
||||
max="300"
|
||||
class="form-input"
|
||||
prop:value=move || message_rate_window_seconds.get()
|
||||
on:input=move |ev| {
|
||||
if let Ok(v) = event_target_value(&ev).parse() {
|
||||
set_message_rate_window_seconds.set(v);
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || allow_guest_access.get()
|
||||
on:change=move |ev| set_allow_guest_access.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Allow Guest Access"
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || allow_user_uploads.get()
|
||||
on:change=move |ev| set_allow_user_uploads.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Allow User Uploads"
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || require_email_verification.get()
|
||||
on:change=move |ev| set_require_email_verification.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Require Email Verification"
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<MessageAlert message=message />
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=move || pending.get()
|
||||
>
|
||||
{move || if pending.get() { "Saving..." } else { "Save Configuration" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
71
crates/chattyness-admin-ui/src/pages/dashboard.rs
Normal file
71
crates/chattyness-admin-ui/src/pages/dashboard.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
//! Dashboard page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
use crate::components::{Card, PageHeader};
|
||||
use crate::hooks::use_fetch;
|
||||
use crate::models::DashboardStats;
|
||||
|
||||
/// Dashboard page component.
|
||||
#[component]
|
||||
pub fn DashboardPage() -> impl IntoView {
|
||||
let stats = use_fetch::<DashboardStats>(|| "/api/admin/dashboard/stats".to_string());
|
||||
|
||||
view! {
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
subtitle="Server overview and quick stats"
|
||||
/>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<Suspense fallback=|| view! { <p>"Loading stats..."</p> }>
|
||||
{move || {
|
||||
stats.get().map(|maybe_stats| {
|
||||
match maybe_stats {
|
||||
Some(s) => view! {
|
||||
<StatCard title="Total Users" value=s.total_users.to_string() />
|
||||
<StatCard title="Active Users" value=s.active_users.to_string() />
|
||||
<StatCard title="Total Realms" value=s.total_realms.to_string() />
|
||||
<StatCard title="Online Now" value=s.online_users.to_string() />
|
||||
<StatCard title="Staff Members" value=s.staff_count.to_string() />
|
||||
}.into_any(),
|
||||
None => view! {
|
||||
<StatCard title="Total Users" value="-".to_string() />
|
||||
<StatCard title="Active Users" value="-".to_string() />
|
||||
<StatCard title="Total Realms" value="-".to_string() />
|
||||
<StatCard title="Online Now" value="-".to_string() />
|
||||
<StatCard title="Staff Members" value="-".to_string() />
|
||||
}.into_any()
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-sections">
|
||||
<Card title="Quick Actions">
|
||||
<div class="quick-actions">
|
||||
<a href="/admin/users/new" class="btn btn-primary">"Create User"</a>
|
||||
<a href="/admin/realms/new" class="btn btn-primary">"Create Realm"</a>
|
||||
<a href="/admin/staff" class="btn btn-secondary">"Manage Staff"</a>
|
||||
<a href="/admin/config" class="btn btn-secondary">"Server Config"</a>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Recent Activity">
|
||||
<p class="text-muted">"Activity feed coming soon..."</p>
|
||||
</Card>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Stat card component.
|
||||
#[component]
|
||||
fn StatCard(title: &'static str, value: String) -> impl IntoView {
|
||||
view! {
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{value}</div>
|
||||
<div class="stat-title">{title}</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
137
crates/chattyness-admin-ui/src/pages/login.rs
Normal file
137
crates/chattyness-admin-ui/src/pages/login.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
//! Login page component.
|
||||
|
||||
use leptos::ev::SubmitEvent;
|
||||
use leptos::prelude::*;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
|
||||
use crate::components::Card;
|
||||
|
||||
/// Login page component.
|
||||
#[component]
|
||||
pub fn LoginPage() -> impl IntoView {
|
||||
let (username, set_username) = signal(String::new());
|
||||
let (password, set_password) = signal(String::new());
|
||||
let (error, set_error) = signal(Option::<String>::None);
|
||||
let (pending, set_pending) = signal(false);
|
||||
|
||||
let on_submit = move |ev: SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
set_error.set(None);
|
||||
|
||||
let uname = username.get();
|
||||
let pwd = password.get();
|
||||
|
||||
if uname.is_empty() || pwd.is_empty() {
|
||||
set_error.set(Some("Username and password are required".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
set_pending.set(true);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
|
||||
spawn_local(async move {
|
||||
let response = Request::post("/api/admin/auth/login")
|
||||
.json(&serde_json::json!({
|
||||
"username": uname,
|
||||
"password": pwd
|
||||
}))
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
// Redirect to dashboard
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.location().set_href("/admin");
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ErrorResp {
|
||||
error: String,
|
||||
}
|
||||
if let Ok(err) = resp.json::<ErrorResp>().await {
|
||||
set_error.set(Some(err.error));
|
||||
} else {
|
||||
set_error.set(Some("Invalid username or password".to_string()));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
set_error.set(Some("Network error. Please try again.".to_string()));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="login-container">
|
||||
<div class="login-header">
|
||||
<h1>"Chattyness"</h1>
|
||||
<span class="login-badge">"Admin Panel"</span>
|
||||
<p>"Administration interface"</p>
|
||||
</div>
|
||||
|
||||
<Card class="login-card">
|
||||
<form on:submit=on_submit class="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username" class="form-label">
|
||||
"Username"
|
||||
<span class="required">"*"</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
required=true
|
||||
autocomplete="username"
|
||||
placeholder="Enter your username"
|
||||
class="form-input"
|
||||
prop:value=move || username.get()
|
||||
on:input=move |ev| set_username.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password" class="form-label">
|
||||
"Password"
|
||||
<span class="required">"*"</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required=true
|
||||
autocomplete="current-password"
|
||||
placeholder="Enter your password"
|
||||
class="form-input"
|
||||
prop:value=move || password.get()
|
||||
on:input=move |ev| set_password.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when=move || error.get().is_some()>
|
||||
<div class="alert alert-error" role="alert">
|
||||
<p>{move || error.get().unwrap_or_default()}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary btn-full"
|
||||
disabled=move || pending.get()
|
||||
>
|
||||
{move || if pending.get() { "Signing in..." } else { "Sign In" }}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
165
crates/chattyness-admin-ui/src/pages/props.rs
Normal file
165
crates/chattyness-admin-ui/src/pages/props.rs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
//! Props list page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
use crate::components::{Card, EmptyState, PageHeader};
|
||||
use crate::hooks::use_fetch;
|
||||
use crate::models::PropSummary;
|
||||
|
||||
/// View mode for props listing.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ViewMode {
|
||||
Table,
|
||||
Grid,
|
||||
}
|
||||
|
||||
/// Props page component with table and grid views.
|
||||
#[component]
|
||||
pub fn PropsPage() -> impl IntoView {
|
||||
let (view_mode, set_view_mode) = signal(ViewMode::Table);
|
||||
|
||||
let props = use_fetch::<Vec<PropSummary>>(|| "/api/admin/props".to_string());
|
||||
|
||||
view! {
|
||||
<PageHeader title="All Props" subtitle="Manage server props and avatar items">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class=move || if view_mode.get() == ViewMode::Table {
|
||||
"btn btn-primary"
|
||||
} else {
|
||||
"btn btn-secondary"
|
||||
}
|
||||
on:click=move |_| set_view_mode.set(ViewMode::Table)
|
||||
>
|
||||
"Table"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class=move || if view_mode.get() == ViewMode::Grid {
|
||||
"btn btn-primary"
|
||||
} else {
|
||||
"btn btn-secondary"
|
||||
}
|
||||
on:click=move |_| set_view_mode.set(ViewMode::Grid)
|
||||
>
|
||||
"Grid"
|
||||
</button>
|
||||
<a href="/admin/props/new" class="btn btn-primary">"Create Prop"</a>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<Suspense fallback=|| view! { <p>"Loading props..."</p> }>
|
||||
{move || {
|
||||
props.get().map(|maybe_props: Option<Vec<PropSummary>>| {
|
||||
match maybe_props {
|
||||
Some(prop_list) if !prop_list.is_empty() => {
|
||||
if view_mode.get() == ViewMode::Table {
|
||||
view! { <PropsTable props=prop_list.clone() /> }.into_any()
|
||||
} else {
|
||||
view! { <PropsGrid props=prop_list.clone() /> }.into_any()
|
||||
}
|
||||
}
|
||||
_ => view! {
|
||||
<EmptyState
|
||||
message="No props found."
|
||||
action_href="/admin/props/new"
|
||||
action_text="Create Prop"
|
||||
/>
|
||||
}.into_any()
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
|
||||
/// Table view for props.
|
||||
#[component]
|
||||
fn PropsTable(props: Vec<PropSummary>) -> impl IntoView {
|
||||
view! {
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>"Preview"</th>
|
||||
<th>"Name"</th>
|
||||
<th>"Slug"</th>
|
||||
<th>"Layer"</th>
|
||||
<th>"Active"</th>
|
||||
<th>"Created"</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.into_iter().map(|prop| {
|
||||
let asset_url = format!("/assets/{}", prop.asset_path);
|
||||
view! {
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
src=asset_url
|
||||
alt=prop.name.clone()
|
||||
class="prop-thumbnail"
|
||||
style="width: 32px; height: 32px; object-fit: contain;"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<a href=format!("/admin/props/{}", prop.id) class="table-link">
|
||||
{prop.name}
|
||||
</a>
|
||||
</td>
|
||||
<td><code>{prop.slug}</code></td>
|
||||
<td>
|
||||
{prop.default_layer.map(|l| l.to_string()).unwrap_or_else(|| "-".to_string())}
|
||||
</td>
|
||||
<td>
|
||||
{if prop.is_active {
|
||||
view! { <span class="status-badge status-active">"Active"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="status-badge status-inactive">"Inactive"</span> }.into_any()
|
||||
}}
|
||||
</td>
|
||||
<td>{prop.created_at}</td>
|
||||
</tr>
|
||||
}
|
||||
}).collect_view()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Grid view for props with 64x64 thumbnails.
|
||||
#[component]
|
||||
fn PropsGrid(props: Vec<PropSummary>) -> impl IntoView {
|
||||
view! {
|
||||
<div class="props-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 16px; padding: 16px;">
|
||||
{props.into_iter().map(|prop| {
|
||||
let asset_url = format!("/assets/{}", prop.asset_path);
|
||||
let prop_url = format!("/admin/props/{}", prop.id);
|
||||
let prop_name_for_title = prop.name.clone();
|
||||
let prop_name_for_alt = prop.name.clone();
|
||||
let prop_name_for_label = prop.name;
|
||||
view! {
|
||||
<a
|
||||
href=prop_url
|
||||
class="props-grid-item"
|
||||
style="display: flex; flex-direction: column; align-items: center; text-decoration: none; color: inherit; padding: 8px; border-radius: 8px; background: var(--bg-secondary, #1e293b); transition: background 0.2s;"
|
||||
title=prop_name_for_title
|
||||
>
|
||||
<img
|
||||
src=asset_url
|
||||
alt=prop_name_for_alt
|
||||
style="width: 64px; height: 64px; object-fit: contain;"
|
||||
/>
|
||||
<span style="font-size: 0.75rem; margin-top: 4px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;">
|
||||
{prop_name_for_label}
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
}).collect_view()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
139
crates/chattyness-admin-ui/src/pages/props_detail.rs
Normal file
139
crates/chattyness-admin-ui/src/pages/props_detail.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
//! Prop detail page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::use_params_map;
|
||||
|
||||
use crate::components::{Card, DetailGrid, DetailItem, PageHeader};
|
||||
use crate::hooks::use_fetch_if;
|
||||
use crate::models::PropDetail;
|
||||
|
||||
/// Prop detail page component.
|
||||
#[component]
|
||||
pub fn PropsDetailPage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let prop_id = move || params.get().get("prop_id").unwrap_or_default();
|
||||
let initial_prop_id = params.get_untracked().get("prop_id").unwrap_or_default();
|
||||
|
||||
let prop = use_fetch_if::<PropDetail>(
|
||||
move || !prop_id().is_empty(),
|
||||
move || format!("/api/admin/props/{}", prop_id()),
|
||||
);
|
||||
|
||||
view! {
|
||||
<PageHeader title="Prop Details" subtitle=initial_prop_id>
|
||||
<a href="/admin/props" class="btn btn-secondary">"Back to Props"</a>
|
||||
</PageHeader>
|
||||
|
||||
<Suspense fallback=|| view! { <p>"Loading prop..."</p> }>
|
||||
{move || {
|
||||
prop.get().map(|maybe_prop| {
|
||||
match maybe_prop {
|
||||
Some(p) => view! {
|
||||
<PropDetailView prop=p />
|
||||
}.into_any(),
|
||||
None => view! {
|
||||
<Card>
|
||||
<p class="text-error">"Prop not found or you don't have permission to view."</p>
|
||||
</Card>
|
||||
}.into_any()
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn PropDetailView(prop: PropDetail) -> impl IntoView {
|
||||
let asset_url = format!("/assets/{}", prop.asset_path);
|
||||
let tags_display = if prop.tags.is_empty() {
|
||||
"None".to_string()
|
||||
} else {
|
||||
prop.tags.join(", ")
|
||||
};
|
||||
|
||||
view! {
|
||||
<Card>
|
||||
<div class="prop-header" style="display: flex; gap: 24px; align-items: flex-start;">
|
||||
<div class="prop-preview" style="flex-shrink: 0;">
|
||||
<img
|
||||
src=asset_url
|
||||
alt=prop.name.clone()
|
||||
style="width: 128px; height: 128px; object-fit: contain; border: 1px solid var(--color-border, #334155); border-radius: 8px; background: var(--color-bg-tertiary, #0f172a);"
|
||||
/>
|
||||
</div>
|
||||
<div class="prop-info" style="flex: 1;">
|
||||
<h2 style="margin: 0 0 8px 0;">{prop.name.clone()}</h2>
|
||||
<p class="text-muted" style="margin: 0;"><code>{prop.slug.clone()}</code></p>
|
||||
{prop.description.clone().map(|desc| view! {
|
||||
<p style="margin-top: 12px; color: var(--color-text-secondary, #94a3b8);">{desc}</p>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Details">
|
||||
<DetailGrid>
|
||||
<DetailItem label="Prop ID">
|
||||
<code>{prop.id.clone()}</code>
|
||||
</DetailItem>
|
||||
<DetailItem label="Tags">
|
||||
{tags_display}
|
||||
</DetailItem>
|
||||
<DetailItem label="Default Layer">
|
||||
{prop.default_layer.clone().unwrap_or_else(|| "Not set".to_string())}
|
||||
</DetailItem>
|
||||
<DetailItem label="Default Position">
|
||||
{match prop.default_position {
|
||||
Some(pos) => {
|
||||
let labels = ["Top-Left", "Top-Center", "Top-Right",
|
||||
"Middle-Left", "Center", "Middle-Right",
|
||||
"Bottom-Left", "Bottom-Center", "Bottom-Right"];
|
||||
labels.get(pos as usize).map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("{}", pos))
|
||||
},
|
||||
None => "Not set".to_string(),
|
||||
}}
|
||||
</DetailItem>
|
||||
<DetailItem label="Status">
|
||||
{if prop.is_active {
|
||||
view! { <span class="status-badge status-active">"Active"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="status-badge status-inactive">"Inactive"</span> }.into_any()
|
||||
}}
|
||||
</DetailItem>
|
||||
</DetailGrid>
|
||||
</Card>
|
||||
|
||||
<Card title="Properties">
|
||||
<DetailGrid>
|
||||
<DetailItem label="Unique">
|
||||
{if prop.is_unique { "Yes" } else { "No" }}
|
||||
</DetailItem>
|
||||
<DetailItem label="Transferable">
|
||||
{if prop.is_transferable { "Yes" } else { "No" }}
|
||||
</DetailItem>
|
||||
<DetailItem label="Portable">
|
||||
{if prop.is_portable { "Yes" } else { "No" }}
|
||||
</DetailItem>
|
||||
</DetailGrid>
|
||||
</Card>
|
||||
|
||||
<Card title="Availability">
|
||||
<DetailGrid>
|
||||
<DetailItem label="Available From">
|
||||
{prop.available_from.clone().unwrap_or_else(|| "Always".to_string())}
|
||||
</DetailItem>
|
||||
<DetailItem label="Available Until">
|
||||
{prop.available_until.clone().unwrap_or_else(|| "No end date".to_string())}
|
||||
</DetailItem>
|
||||
<DetailItem label="Created">
|
||||
{prop.created_at.clone()}
|
||||
</DetailItem>
|
||||
<DetailItem label="Updated">
|
||||
{prop.updated_at.clone()}
|
||||
</DetailItem>
|
||||
</DetailGrid>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
332
crates/chattyness-admin-ui/src/pages/props_new.rs
Normal file
332
crates/chattyness-admin-ui/src/pages/props_new.rs
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
//! Create new prop page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
|
||||
use crate::components::{Card, PageHeader};
|
||||
|
||||
/// Prop new page component with file upload.
|
||||
#[component]
|
||||
pub fn PropsNewPage() -> impl IntoView {
|
||||
// Form state
|
||||
let (name, set_name) = signal(String::new());
|
||||
let (slug, set_slug) = signal(String::new());
|
||||
let (description, set_description) = signal(String::new());
|
||||
let (tags, set_tags) = signal(String::new());
|
||||
let (default_layer, set_default_layer) = signal("clothes".to_string());
|
||||
let (default_position, set_default_position) = signal(4i16); // Center position
|
||||
|
||||
// UI state
|
||||
let (message, set_message) = signal(Option::<(String, bool)>::None);
|
||||
let (pending, set_pending) = signal(false);
|
||||
let (created_id, _set_created_id) = signal(Option::<String>::None);
|
||||
#[cfg(feature = "hydrate")]
|
||||
let set_created_id = _set_created_id;
|
||||
let (slug_auto, set_slug_auto) = signal(true);
|
||||
let (file_name, _set_file_name) = signal(Option::<String>::None);
|
||||
#[cfg(feature = "hydrate")]
|
||||
let set_file_name = _set_file_name;
|
||||
|
||||
let update_name = move |ev: leptos::ev::Event| {
|
||||
let new_name = event_target_value(&ev);
|
||||
set_name.set(new_name.clone());
|
||||
if slug_auto.get() {
|
||||
let new_slug = new_name
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
||||
.collect::<String>()
|
||||
.trim_matches('-')
|
||||
.to_string();
|
||||
set_slug.set(new_slug);
|
||||
}
|
||||
};
|
||||
|
||||
let on_file_change = move |ev: leptos::ev::Event| {
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use wasm_bindgen::JsCast;
|
||||
let target = ev.target().unwrap();
|
||||
let input: web_sys::HtmlInputElement = target.dyn_into().unwrap();
|
||||
if let Some(files) = input.files() {
|
||||
if files.length() > 0 {
|
||||
if let Some(file) = files.get(0) {
|
||||
set_file_name.set(Some(file.name()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
{
|
||||
let _ = ev;
|
||||
}
|
||||
};
|
||||
|
||||
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
set_pending.set(true);
|
||||
set_message.set(None);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
// Get the form element
|
||||
let target = ev.target().unwrap();
|
||||
let form: web_sys::HtmlFormElement = target.dyn_into().unwrap();
|
||||
|
||||
// Get the file input
|
||||
let file_input = form
|
||||
.query_selector("input[type='file']")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.dyn_into::<web_sys::HtmlInputElement>()
|
||||
.unwrap();
|
||||
|
||||
let files = file_input.files();
|
||||
if files.is_none() || files.as_ref().unwrap().length() == 0 {
|
||||
set_message.set(Some(("Please select a file".to_string(), false)));
|
||||
set_pending.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let file = files.unwrap().get(0).unwrap();
|
||||
|
||||
// Build tags array from comma-separated string
|
||||
let tags_vec: Vec<String> = tags
|
||||
.get()
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
// Create metadata JSON
|
||||
let metadata = serde_json::json!({
|
||||
"name": name.get(),
|
||||
"slug": if slug.get().is_empty() { None::<String> } else { Some(slug.get()) },
|
||||
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
|
||||
"tags": tags_vec,
|
||||
"default_layer": default_layer.get(),
|
||||
"default_position": default_position.get()
|
||||
});
|
||||
|
||||
// Create FormData
|
||||
let form_data = web_sys::FormData::new().unwrap();
|
||||
form_data
|
||||
.append_with_str("metadata", &metadata.to_string())
|
||||
.unwrap();
|
||||
form_data.append_with_blob("file", &file).unwrap();
|
||||
|
||||
spawn_local(async move {
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let response = Request::post("/api/admin/props")
|
||||
.body(form_data)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CreateResponse {
|
||||
id: String,
|
||||
#[allow(dead_code)]
|
||||
name: String,
|
||||
#[allow(dead_code)]
|
||||
slug: String,
|
||||
}
|
||||
if let Ok(result) = resp.json::<CreateResponse>().await {
|
||||
set_created_id.set(Some(result.id));
|
||||
set_message.set(Some(("Prop created successfully!".to_string(), true)));
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ErrorResp {
|
||||
error: String,
|
||||
}
|
||||
if let Ok(err) = resp.json::<ErrorResp>().await {
|
||||
set_message.set(Some((err.error, false)));
|
||||
} else {
|
||||
set_message.set(Some(("Failed to create prop".to_string(), false)));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
set_message.set(Some(("Network error".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<PageHeader title="Create New Prop" subtitle="Upload a new server prop image">
|
||||
<a href="/admin/props" class="btn btn-secondary">"Back to Props"</a>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<form on:submit=on_submit>
|
||||
<h3 class="section-title">"Prop Details"</h3>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="name" class="form-label">
|
||||
"Name" <span class="required">"*"</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
required=true
|
||||
class="form-input"
|
||||
placeholder="Smile Expression"
|
||||
prop:value=move || name.get()
|
||||
on:input=update_name
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="slug" class="form-label">"Slug (URL)"</label>
|
||||
<input
|
||||
type="text"
|
||||
id="slug"
|
||||
pattern="[a-z0-9][a-z0-9\\-]*[a-z0-9]|[a-z0-9]"
|
||||
class="form-input"
|
||||
placeholder="smile-expression"
|
||||
prop:value=move || slug.get()
|
||||
on:input=move |ev| {
|
||||
set_slug_auto.set(false);
|
||||
set_slug.set(event_target_value(&ev));
|
||||
}
|
||||
/>
|
||||
<small class="form-help">"Optional. Auto-generated from name if not provided."</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="form-label">"Description"</label>
|
||||
<textarea
|
||||
id="description"
|
||||
class="form-textarea"
|
||||
placeholder="A happy smile expression for avatars"
|
||||
prop:value=move || description.get()
|
||||
on:input=move |ev| set_description.set(event_target_value(&ev))
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="tags" class="form-label">"Tags"</label>
|
||||
<input
|
||||
type="text"
|
||||
id="tags"
|
||||
class="form-input"
|
||||
placeholder="expression, face, happy"
|
||||
prop:value=move || tags.get()
|
||||
on:input=move |ev| set_tags.set(event_target_value(&ev))
|
||||
/>
|
||||
<small class="form-help">"Comma-separated list of tags"</small>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title">"Image File"</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="file" class="form-label">
|
||||
"Image File" <span class="required">"*"</span>
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
id="file"
|
||||
required=true
|
||||
accept=".svg,.png,image/svg+xml,image/png"
|
||||
class="form-input"
|
||||
on:change=on_file_change
|
||||
/>
|
||||
<small class="form-help">"SVG or PNG image file (64x64 recommended)"</small>
|
||||
<Show when=move || file_name.get().is_some()>
|
||||
<p class="text-muted">"Selected: " {move || file_name.get().unwrap_or_default()}</p>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title">"Default Positioning"</h3>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="default_layer" class="form-label">"Layer"</label>
|
||||
<select
|
||||
id="default_layer"
|
||||
class="form-select"
|
||||
on:change=move |ev| set_default_layer.set(event_target_value(&ev))
|
||||
>
|
||||
<option value="skin" selected=move || default_layer.get() == "skin">"Skin (behind)"</option>
|
||||
<option value="clothes" selected=move || default_layer.get() == "clothes">"Clothes (with)"</option>
|
||||
<option value="accessories" selected=move || default_layer.get() == "accessories">"Accessories (front)"</option>
|
||||
</select>
|
||||
<small class="form-help">"Z-depth layer for prop placement"</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="default_position" class="form-label">"Position"</label>
|
||||
<select
|
||||
id="default_position"
|
||||
class="form-select"
|
||||
on:change=move |ev| {
|
||||
if let Ok(v) = event_target_value(&ev).parse() {
|
||||
set_default_position.set(v);
|
||||
}
|
||||
}
|
||||
>
|
||||
<option value="0" selected=move || default_position.get() == 0>"Top-Left (0)"</option>
|
||||
<option value="1" selected=move || default_position.get() == 1>"Top-Center (1)"</option>
|
||||
<option value="2" selected=move || default_position.get() == 2>"Top-Right (2)"</option>
|
||||
<option value="3" selected=move || default_position.get() == 3>"Middle-Left (3)"</option>
|
||||
<option value="4" selected=move || default_position.get() == 4>"Center (4)"</option>
|
||||
<option value="5" selected=move || default_position.get() == 5>"Middle-Right (5)"</option>
|
||||
<option value="6" selected=move || default_position.get() == 6>"Bottom-Left (6)"</option>
|
||||
<option value="7" selected=move || default_position.get() == 7>"Bottom-Center (7)"</option>
|
||||
<option value="8" selected=move || default_position.get() == 8>"Bottom-Right (8)"</option>
|
||||
</select>
|
||||
<small class="form-help">"Grid position (3x3 grid)"</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<Show when=move || created_id.get().is_some()>
|
||||
{move || {
|
||||
let id = created_id.get().unwrap_or_default();
|
||||
view! {
|
||||
<div class="alert alert-info">
|
||||
<p>
|
||||
<a href=format!("/admin/props/{}", id)>
|
||||
"View prop"
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=move || pending.get()
|
||||
>
|
||||
{move || if pending.get() { "Uploading..." } else { "Create Prop" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
295
crates/chattyness-admin-ui/src/pages/realm_detail.rs
Normal file
295
crates/chattyness-admin-ui/src/pages/realm_detail.rs
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
//! Realm detail/edit page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::use_params_map;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
|
||||
use crate::components::{
|
||||
Card, DetailGrid, DetailItem, MessageAlert, NsfwBadge, PageHeader, PrivacyBadge,
|
||||
};
|
||||
use crate::hooks::use_fetch_if;
|
||||
use crate::models::RealmDetail;
|
||||
use crate::utils::get_api_base;
|
||||
|
||||
/// Realm detail page component.
|
||||
#[component]
|
||||
pub fn RealmDetailPage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let slug = move || params.get().get("slug").unwrap_or_default();
|
||||
let initial_slug = params.get_untracked().get("slug").unwrap_or_default();
|
||||
|
||||
let (message, set_message) = signal(Option::<(String, bool)>::None);
|
||||
|
||||
let realm = use_fetch_if::<RealmDetail>(
|
||||
move || !slug().is_empty(),
|
||||
move || format!("{}/realms/{}", get_api_base(), slug()),
|
||||
);
|
||||
|
||||
let slug_for_scenes = initial_slug.clone();
|
||||
|
||||
view! {
|
||||
<PageHeader title="Realm Details" subtitle=format!("/{}", initial_slug)>
|
||||
<a href=format!("/admin/realms/{}/scenes", slug_for_scenes) class="btn btn-primary">"Manage Scenes"</a>
|
||||
<a href="/admin/realms" class="btn btn-secondary">"Back to Realms"</a>
|
||||
</PageHeader>
|
||||
|
||||
<Suspense fallback=|| view! { <p>"Loading realm..."</p> }>
|
||||
{move || {
|
||||
realm.get().map(|maybe_realm| {
|
||||
match maybe_realm {
|
||||
Some(r) => view! {
|
||||
<RealmDetailView realm=r message=message set_message=set_message />
|
||||
}.into_any(),
|
||||
None => view! {
|
||||
<Card>
|
||||
<p class="text-error">"Realm not found or you don't have permission to view."</p>
|
||||
</Card>
|
||||
}.into_any()
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[allow(unused_variables)]
|
||||
fn RealmDetailView(
|
||||
realm: RealmDetail,
|
||||
message: ReadSignal<Option<(String, bool)>>,
|
||||
set_message: WriteSignal<Option<(String, bool)>>,
|
||||
) -> impl IntoView {
|
||||
#[cfg(feature = "hydrate")]
|
||||
let slug = realm.slug.clone();
|
||||
let slug_display = realm.slug.clone();
|
||||
let (pending, set_pending) = signal(false);
|
||||
|
||||
// Form state
|
||||
let (name, set_name) = signal(realm.name.clone());
|
||||
let (tagline, set_tagline) = signal(realm.tagline.clone().unwrap_or_default());
|
||||
let (description, set_description) = signal(realm.description.clone().unwrap_or_default());
|
||||
let (privacy, set_privacy) = signal(realm.privacy.clone());
|
||||
let (max_users, set_max_users) = signal(realm.max_users);
|
||||
let (is_nsfw, set_is_nsfw) = signal(realm.is_nsfw);
|
||||
let (allow_guest_access, set_allow_guest_access) = signal(realm.allow_guest_access);
|
||||
let (theme_color, set_theme_color) =
|
||||
signal(realm.theme_color.clone().unwrap_or_else(|| "#7c3aed".to_string()));
|
||||
|
||||
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
set_pending.set(true);
|
||||
set_message.set(None);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let api_base = get_api_base();
|
||||
let slug = slug.clone();
|
||||
let data = serde_json::json!({
|
||||
"name": name.get(),
|
||||
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
|
||||
"tagline": if tagline.get().is_empty() { None::<String> } else { Some(tagline.get()) },
|
||||
"privacy": privacy.get(),
|
||||
"is_nsfw": is_nsfw.get(),
|
||||
"max_users": max_users.get(),
|
||||
"allow_guest_access": allow_guest_access.get(),
|
||||
"theme_color": theme_color.get()
|
||||
});
|
||||
|
||||
spawn_local(async move {
|
||||
let response = Request::put(&format!("{}/realms/{}", api_base, slug))
|
||||
.json(&data)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
set_message.set(Some(("Realm updated successfully!".to_string(), true)));
|
||||
}
|
||||
Ok(_) => {
|
||||
set_message.set(Some(("Failed to update realm".to_string(), false)));
|
||||
}
|
||||
Err(_) => {
|
||||
set_message.set(Some(("Network error".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<Card>
|
||||
<div class="realm-header">
|
||||
<div class="realm-info">
|
||||
<h2>{realm.name.clone()}</h2>
|
||||
<p class="text-muted">{realm.tagline.clone().unwrap_or_default()}</p>
|
||||
</div>
|
||||
<div class="realm-badges">
|
||||
<PrivacyBadge privacy=realm.privacy.clone() />
|
||||
{if realm.is_nsfw {
|
||||
view! { <NsfwBadge /> }.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DetailGrid>
|
||||
<DetailItem label="Owner">
|
||||
<a href=format!("/admin/users/{}", realm.owner_id) class="table-link">
|
||||
{realm.owner_display_name.clone()} " (@" {realm.owner_username.clone()} ")"
|
||||
</a>
|
||||
</DetailItem>
|
||||
<DetailItem label="Members">
|
||||
{realm.member_count.to_string()}
|
||||
</DetailItem>
|
||||
<DetailItem label="Current Users">
|
||||
{realm.current_user_count.to_string()}
|
||||
</DetailItem>
|
||||
<DetailItem label="Max Users">
|
||||
{realm.max_users.to_string()}
|
||||
</DetailItem>
|
||||
<DetailItem label="Created">
|
||||
{realm.created_at.clone()}
|
||||
</DetailItem>
|
||||
<DetailItem label="Updated">
|
||||
{realm.updated_at.clone()}
|
||||
</DetailItem>
|
||||
</DetailGrid>
|
||||
</Card>
|
||||
|
||||
<Card title="Edit Realm Settings">
|
||||
<form on:submit=on_submit>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="name" class="form-label">"Realm Name"</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
required=true
|
||||
class="form-input"
|
||||
prop:value=move || name.get()
|
||||
on:input=move |ev| set_name.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">"Slug (URL)"</label>
|
||||
<input
|
||||
type="text"
|
||||
value=slug_display
|
||||
class="form-input"
|
||||
disabled=true
|
||||
/>
|
||||
<small class="form-help">"Slug cannot be changed"</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="tagline" class="form-label">"Tagline"</label>
|
||||
<input
|
||||
type="text"
|
||||
id="tagline"
|
||||
class="form-input"
|
||||
placeholder="A short description"
|
||||
prop:value=move || tagline.get()
|
||||
on:input=move |ev| set_tagline.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="form-label">"Description"</label>
|
||||
<textarea
|
||||
id="description"
|
||||
class="form-textarea"
|
||||
prop:value=move || description.get()
|
||||
on:input=move |ev| set_description.set(event_target_value(&ev))
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="privacy" class="form-label">"Privacy"</label>
|
||||
<select
|
||||
id="privacy"
|
||||
class="form-select"
|
||||
on:change=move |ev| set_privacy.set(event_target_value(&ev))
|
||||
>
|
||||
<option value="public" selected=move || privacy.get() == "public">"Public"</option>
|
||||
<option value="unlisted" selected=move || privacy.get() == "unlisted">"Unlisted"</option>
|
||||
<option value="private" selected=move || privacy.get() == "private">"Private"</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="max_users" class="form-label">"Max Users"</label>
|
||||
<input
|
||||
type="number"
|
||||
id="max_users"
|
||||
min=1
|
||||
max=10000
|
||||
class="form-input"
|
||||
prop:value=move || max_users.get()
|
||||
on:input=move |ev| {
|
||||
if let Ok(v) = event_target_value(&ev).parse() {
|
||||
set_max_users.set(v);
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || is_nsfw.get()
|
||||
on:change=move |ev| set_is_nsfw.set(event_target_checked(&ev))
|
||||
/>
|
||||
"NSFW Content"
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || allow_guest_access.get()
|
||||
on:change=move |ev| set_allow_guest_access.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Allow Guest Access"
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="theme_color" class="form-label">"Theme Color"</label>
|
||||
<input
|
||||
type="color"
|
||||
id="theme_color"
|
||||
class="form-color"
|
||||
prop:value=move || theme_color.get()
|
||||
on:input=move |ev| set_theme_color.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MessageAlert message=message />
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=move || pending.get()
|
||||
>
|
||||
{move || if pending.get() { "Saving..." } else { "Save Changes" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
388
crates/chattyness-admin-ui/src/pages/realm_new.rs
Normal file
388
crates/chattyness-admin-ui/src/pages/realm_new.rs
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
//! Create new realm page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
|
||||
use crate::components::{Card, PageHeader};
|
||||
|
||||
/// Realm new page component.
|
||||
#[component]
|
||||
pub fn RealmNewPage() -> impl IntoView {
|
||||
// Form state
|
||||
let (name, set_name) = signal(String::new());
|
||||
let (slug, set_slug) = signal(String::new());
|
||||
let (tagline, set_tagline) = signal(String::new());
|
||||
let (description, set_description) = signal(String::new());
|
||||
let (privacy, set_privacy) = signal("public".to_string());
|
||||
let (max_users, set_max_users) = signal(100i32);
|
||||
let (is_nsfw, set_is_nsfw) = signal(false);
|
||||
let (allow_guest_access, set_allow_guest_access) = signal(false);
|
||||
let (theme_color, set_theme_color) = signal("#7c3aed".to_string());
|
||||
|
||||
// Owner selection
|
||||
let (owner_mode, set_owner_mode) = signal("existing".to_string());
|
||||
let (owner_id, set_owner_id) = signal(String::new());
|
||||
let (new_username, set_new_username) = signal(String::new());
|
||||
let (new_email, set_new_email) = signal(String::new());
|
||||
let (new_display_name, set_new_display_name) = signal(String::new());
|
||||
|
||||
// UI state
|
||||
let (message, set_message) = signal(Option::<(String, bool)>::None);
|
||||
let (pending, set_pending) = signal(false);
|
||||
let (created_slug, _set_created_slug) = signal(Option::<String>::None);
|
||||
let (temp_password, _set_temp_password) = signal(Option::<String>::None);
|
||||
#[cfg(feature = "hydrate")]
|
||||
let (set_created_slug, set_temp_password) = (_set_created_slug, _set_temp_password);
|
||||
let (slug_auto, set_slug_auto) = signal(true);
|
||||
|
||||
let update_name = move |ev: leptos::ev::Event| {
|
||||
let new_name = event_target_value(&ev);
|
||||
set_name.set(new_name.clone());
|
||||
if slug_auto.get() {
|
||||
let new_slug = new_name
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
||||
.collect::<String>()
|
||||
.trim_matches('-')
|
||||
.to_string();
|
||||
set_slug.set(new_slug);
|
||||
}
|
||||
};
|
||||
|
||||
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
set_pending.set(true);
|
||||
set_message.set(None);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let mut data = serde_json::json!({
|
||||
"name": name.get(),
|
||||
"slug": slug.get(),
|
||||
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
|
||||
"tagline": if tagline.get().is_empty() { None::<String> } else { Some(tagline.get()) },
|
||||
"privacy": privacy.get(),
|
||||
"is_nsfw": is_nsfw.get(),
|
||||
"max_users": max_users.get(),
|
||||
"allow_guest_access": allow_guest_access.get(),
|
||||
"theme_color": theme_color.get()
|
||||
});
|
||||
|
||||
if owner_mode.get() == "existing" {
|
||||
if owner_id.get().is_empty() {
|
||||
set_message.set(Some(("Please enter an owner User ID".to_string(), false)));
|
||||
set_pending.set(false);
|
||||
return;
|
||||
}
|
||||
data["owner_id"] = serde_json::json!(owner_id.get());
|
||||
} else {
|
||||
if new_username.get().is_empty() || new_email.get().is_empty() || new_display_name.get().is_empty() {
|
||||
set_message.set(Some(("Please fill in all new owner fields".to_string(), false)));
|
||||
set_pending.set(false);
|
||||
return;
|
||||
}
|
||||
data["new_owner"] = serde_json::json!({
|
||||
"username": new_username.get(),
|
||||
"email": new_email.get(),
|
||||
"display_name": new_display_name.get()
|
||||
});
|
||||
}
|
||||
|
||||
spawn_local(async move {
|
||||
let response = Request::post("/api/admin/realms")
|
||||
.json(&data)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CreateResponse {
|
||||
slug: String,
|
||||
owner_temporary_password: Option<String>,
|
||||
}
|
||||
if let Ok(result) = resp.json::<CreateResponse>().await {
|
||||
set_created_slug.set(Some(result.slug));
|
||||
set_temp_password.set(result.owner_temporary_password);
|
||||
set_message.set(Some(("Realm created successfully!".to_string(), true)));
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ErrorResp {
|
||||
error: String,
|
||||
}
|
||||
if let Ok(err) = resp.json::<ErrorResp>().await {
|
||||
set_message.set(Some((err.error, false)));
|
||||
} else {
|
||||
set_message.set(Some(("Failed to create realm".to_string(), false)));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
set_message.set(Some(("Network error".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<PageHeader title="Create New Realm" subtitle="Create a new realm space">
|
||||
<a href="/admin/realms" class="btn btn-secondary">"Back to Realms"</a>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<form on:submit=on_submit>
|
||||
<h3 class="section-title">"Realm Details"</h3>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="name" class="form-label">
|
||||
"Realm Name" <span class="required">"*"</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
required=true
|
||||
class="form-input"
|
||||
placeholder="My Awesome Realm"
|
||||
prop:value=move || name.get()
|
||||
on:input=update_name
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="slug" class="form-label">
|
||||
"Slug (URL)" <span class="required">"*"</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="slug"
|
||||
required=true
|
||||
pattern="[a-z0-9][a-z0-9\\-]*[a-z0-9]|[a-z0-9]"
|
||||
class="form-input"
|
||||
placeholder="my-realm"
|
||||
prop:value=move || slug.get()
|
||||
on:input=move |ev| {
|
||||
set_slug_auto.set(false);
|
||||
set_slug.set(event_target_value(&ev));
|
||||
}
|
||||
/>
|
||||
<small class="form-help">"Lowercase letters, numbers, hyphens only"</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="tagline" class="form-label">"Tagline"</label>
|
||||
<input
|
||||
type="text"
|
||||
id="tagline"
|
||||
class="form-input"
|
||||
placeholder="A short description"
|
||||
prop:value=move || tagline.get()
|
||||
on:input=move |ev| set_tagline.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="form-label">"Description"</label>
|
||||
<textarea
|
||||
id="description"
|
||||
class="form-textarea"
|
||||
placeholder="Detailed description of the realm"
|
||||
prop:value=move || description.get()
|
||||
on:input=move |ev| set_description.set(event_target_value(&ev))
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="privacy" class="form-label">"Privacy"</label>
|
||||
<select
|
||||
id="privacy"
|
||||
class="form-select"
|
||||
on:change=move |ev| set_privacy.set(event_target_value(&ev))
|
||||
>
|
||||
<option value="public" selected=move || privacy.get() == "public">"Public"</option>
|
||||
<option value="unlisted" selected=move || privacy.get() == "unlisted">"Unlisted"</option>
|
||||
<option value="private" selected=move || privacy.get() == "private">"Private"</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="max_users" class="form-label">"Max Users"</label>
|
||||
<input
|
||||
type="number"
|
||||
id="max_users"
|
||||
min=1
|
||||
max=10000
|
||||
class="form-input"
|
||||
prop:value=move || max_users.get()
|
||||
on:input=move |ev| {
|
||||
if let Ok(v) = event_target_value(&ev).parse() {
|
||||
set_max_users.set(v);
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || is_nsfw.get()
|
||||
on:change=move |ev| set_is_nsfw.set(event_target_checked(&ev))
|
||||
/>
|
||||
"NSFW Content"
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || allow_guest_access.get()
|
||||
on:change=move |ev| set_allow_guest_access.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Allow Guest Access"
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="theme_color" class="form-label">"Theme Color"</label>
|
||||
<input
|
||||
type="color"
|
||||
id="theme_color"
|
||||
class="form-color"
|
||||
prop:value=move || theme_color.get()
|
||||
on:input=move |ev| set_theme_color.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title">"Realm Owner"</h3>
|
||||
|
||||
<div class="tab-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class=move || if owner_mode.get() == "existing" { "btn btn-primary" } else { "btn btn-secondary" }
|
||||
on:click=move |_| set_owner_mode.set("existing".to_string())
|
||||
>
|
||||
"Existing User"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class=move || if owner_mode.get() == "new" { "btn btn-primary" } else { "btn btn-secondary" }
|
||||
on:click=move |_| set_owner_mode.set("new".to_string())
|
||||
>
|
||||
"Create New User"
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when=move || owner_mode.get() == "existing">
|
||||
<div class="form-group">
|
||||
<label for="owner_id" class="form-label">"Owner User ID"</label>
|
||||
<input
|
||||
type="text"
|
||||
id="owner_id"
|
||||
class="form-input"
|
||||
placeholder="UUID of existing user"
|
||||
prop:value=move || owner_id.get()
|
||||
on:input=move |ev| set_owner_id.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when=move || owner_mode.get() == "new">
|
||||
<p class="text-muted">"A random temporary password will be generated for the new owner."</p>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="new_username" class="form-label">"Username"</label>
|
||||
<input
|
||||
type="text"
|
||||
id="new_username"
|
||||
minlength=3
|
||||
maxlength=32
|
||||
class="form-input"
|
||||
placeholder="username"
|
||||
prop:value=move || new_username.get()
|
||||
on:input=move |ev| set_new_username.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new_email" class="form-label">"Email"</label>
|
||||
<input
|
||||
type="email"
|
||||
id="new_email"
|
||||
class="form-input"
|
||||
placeholder="user@example.com"
|
||||
prop:value=move || new_email.get()
|
||||
on:input=move |ev| set_new_email.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new_display_name" class="form-label">"Display Name"</label>
|
||||
<input
|
||||
type="text"
|
||||
id="new_display_name"
|
||||
class="form-input"
|
||||
placeholder="Display Name"
|
||||
prop:value=move || new_display_name.get()
|
||||
on:input=move |ev| set_new_display_name.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<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>
|
||||
|
||||
<Show when=move || created_slug.get().is_some()>
|
||||
<div class="alert alert-info">
|
||||
<p>
|
||||
<a href=format!("/admin/realms/{}", created_slug.get().unwrap_or_default())>
|
||||
"View realm"
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when=move || temp_password.get().is_some()>
|
||||
<div class="alert alert-warning">
|
||||
<p><strong>"New Owner Temporary Password:"</strong></p>
|
||||
<code class="temp-password">{move || temp_password.get().unwrap_or_default()}</code>
|
||||
<p class="text-muted">"Copy this password now - it will not be shown again!"</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=move || pending.get()
|
||||
>
|
||||
{move || if pending.get() { "Creating..." } else { "Create Realm" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
111
crates/chattyness-admin-ui/src/pages/realms.rs
Normal file
111
crates/chattyness-admin-ui/src/pages/realms.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
//! Realms list page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
use crate::components::{
|
||||
Card, EmptyState, NsfwBadge, PageHeader, Pagination, PrivacyBadge, SearchForm,
|
||||
};
|
||||
use crate::hooks::{use_fetch, use_pagination};
|
||||
use crate::models::RealmSummary;
|
||||
use crate::utils::build_paginated_url;
|
||||
|
||||
/// Realms page component.
|
||||
#[component]
|
||||
pub fn RealmsPage() -> impl IntoView {
|
||||
let pagination = use_pagination();
|
||||
|
||||
let realms = use_fetch::<Vec<RealmSummary>>(move || {
|
||||
build_paginated_url(
|
||||
"/api/admin/realms",
|
||||
pagination.page.get(),
|
||||
&pagination.search_query.get(),
|
||||
25,
|
||||
)
|
||||
});
|
||||
|
||||
view! {
|
||||
<PageHeader title="All Realms" subtitle="Manage realm spaces">
|
||||
<a href="/admin/realms/new" class="btn btn-primary">"Create Realm"</a>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<SearchForm
|
||||
action="/admin/realms"
|
||||
placeholder="Search by name or slug..."
|
||||
search_input=pagination.search_input
|
||||
/>
|
||||
|
||||
<Suspense fallback=|| view! { <p>"Loading realms..."</p> }>
|
||||
{move || {
|
||||
realms.get().map(|maybe_realms: Option<Vec<RealmSummary>>| {
|
||||
match maybe_realms {
|
||||
Some(realm_list) if !realm_list.is_empty() => {
|
||||
view! {
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>"Name"</th>
|
||||
<th>"Tagline"</th>
|
||||
<th>"Privacy"</th>
|
||||
<th>"NSFW"</th>
|
||||
<th>"Owner"</th>
|
||||
<th>"Members"</th>
|
||||
<th>"Online"</th>
|
||||
<th>"Created"</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{realm_list.into_iter().map(|realm| {
|
||||
view! {
|
||||
<tr>
|
||||
<td>
|
||||
<a href=format!("/admin/realms/{}", realm.slug) class="table-link">
|
||||
{realm.name}
|
||||
</a>
|
||||
</td>
|
||||
<td>{realm.tagline.unwrap_or_default()}</td>
|
||||
<td><PrivacyBadge privacy=realm.privacy /></td>
|
||||
<td>
|
||||
{if realm.is_nsfw {
|
||||
view! { <NsfwBadge /> }.into_any()
|
||||
} else {
|
||||
view! { <span>"-"</span> }.into_any()
|
||||
}}
|
||||
</td>
|
||||
<td>
|
||||
<a href=format!("/admin/users/{}", realm.owner_id) class="table-link">
|
||||
{realm.owner_username}
|
||||
</a>
|
||||
</td>
|
||||
<td>{realm.member_count}</td>
|
||||
<td>{realm.current_user_count}</td>
|
||||
<td>{realm.created_at}</td>
|
||||
</tr>
|
||||
}
|
||||
}).collect_view()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
current_page=pagination.page.get()
|
||||
base_url="/admin/realms".to_string()
|
||||
query=pagination.search_query.get()
|
||||
/>
|
||||
}.into_any()
|
||||
}
|
||||
_ => view! {
|
||||
<EmptyState
|
||||
message="No realms found."
|
||||
action_href="/admin/realms/new"
|
||||
action_text="Create Realm"
|
||||
/>
|
||||
}.into_any()
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
783
crates/chattyness-admin-ui/src/pages/scene_detail.rs
Normal file
783
crates/chattyness-admin-ui/src/pages/scene_detail.rs
Normal file
|
|
@ -0,0 +1,783 @@
|
|||
//! Scene detail/edit page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::use_params_map;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::components::{Card, DetailGrid, DetailItem, PageHeader};
|
||||
#[cfg(feature = "hydrate")]
|
||||
use crate::utils::fetch_image_dimensions_client;
|
||||
|
||||
/// Scene detail from API.
|
||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
||||
pub struct SceneDetail {
|
||||
pub id: Uuid,
|
||||
pub realm_id: Uuid,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub description: Option<String>,
|
||||
pub background_image_path: Option<String>,
|
||||
pub background_color: Option<String>,
|
||||
pub bounds_wkt: String,
|
||||
pub dimension_mode: String,
|
||||
pub sort_order: i32,
|
||||
pub is_entry_point: bool,
|
||||
pub is_hidden: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Parse width and height from WKT bounds string.
|
||||
/// Example: "POLYGON((0 0, 800 0, 800 600, 0 600, 0 0))" -> (800, 600)
|
||||
/// Handles both "0 0, 800 0" (with space) and "0 0,800 0" (without space) formats.
|
||||
fn parse_bounds_wkt(wkt: &str) -> (i32, i32) {
|
||||
// Extract coordinates from POLYGON((x1 y1, x2 y2, x3 y3, x4 y4, x5 y5))
|
||||
// The second point has (width, 0) and third point has (width, height)
|
||||
if let Some(start) = wkt.find("((") {
|
||||
if let Some(end) = wkt.find("))") {
|
||||
let coords_str = &wkt[start + 2..end];
|
||||
let points: Vec<&str> = coords_str.split(',').map(|s| s.trim()).collect();
|
||||
if points.len() >= 3 {
|
||||
// Second point: "width 0"
|
||||
let second: Vec<&str> = points[1].split_whitespace().collect();
|
||||
// Third point: "width height"
|
||||
let third: Vec<&str> = points[2].split_whitespace().collect();
|
||||
if !second.is_empty() && third.len() >= 2 {
|
||||
let width = second[0].parse().unwrap_or(800);
|
||||
let height = third[1].parse().unwrap_or(600);
|
||||
return (width, height);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(800, 600)
|
||||
}
|
||||
|
||||
/// Scene detail page component.
|
||||
#[component]
|
||||
pub fn SceneDetailPage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let realm_slug = move || params.get().get("slug").unwrap_or_default();
|
||||
let scene_id = move || params.get().get("scene_id").unwrap_or_default();
|
||||
let initial_realm_slug = params.get_untracked().get("slug").unwrap_or_default();
|
||||
|
||||
let (message, set_message) = signal(Option::<(String, bool)>::None);
|
||||
|
||||
let scene = LocalResource::new(move || {
|
||||
let id = scene_id();
|
||||
async move {
|
||||
if id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
let resp = Request::get(&format!("/api/admin/scenes/{}", id)).send().await;
|
||||
match resp {
|
||||
Ok(r) if r.ok() => r.json::<SceneDetail>().await.ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
{
|
||||
let _ = id;
|
||||
None::<SceneDetail>
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let slug_for_back = initial_realm_slug.clone();
|
||||
|
||||
view! {
|
||||
<PageHeader title="Scene Details" subtitle="View and edit scene">
|
||||
<a href=format!("/admin/realms/{}/scenes", slug_for_back) class="btn btn-secondary">"Back to Scenes"</a>
|
||||
</PageHeader>
|
||||
|
||||
<Suspense fallback=|| view! { <p>"Loading scene..."</p> }>
|
||||
{move || {
|
||||
let realm_slug_val = realm_slug();
|
||||
scene.get().map(|maybe_scene| {
|
||||
match maybe_scene {
|
||||
Some(s) => view! {
|
||||
<SceneDetailView scene=s realm_slug=realm_slug_val message=message set_message=set_message />
|
||||
}.into_any(),
|
||||
None => view! {
|
||||
<Card>
|
||||
<p class="text-error">"Scene not found or you don't have permission to view."</p>
|
||||
</Card>
|
||||
}.into_any()
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[allow(unused_variables)]
|
||||
fn SceneDetailView(
|
||||
scene: SceneDetail,
|
||||
realm_slug: String,
|
||||
message: ReadSignal<Option<(String, bool)>>,
|
||||
set_message: WriteSignal<Option<(String, bool)>>,
|
||||
) -> impl IntoView {
|
||||
#[cfg(feature = "hydrate")]
|
||||
let scene_id = scene.id.to_string();
|
||||
#[cfg(feature = "hydrate")]
|
||||
let scene_id_for_delete = scene.id.to_string();
|
||||
#[cfg(feature = "hydrate")]
|
||||
let realm_slug_for_delete = realm_slug.clone();
|
||||
let (pending, set_pending) = signal(false);
|
||||
let (delete_pending, set_delete_pending) = signal(false);
|
||||
let (show_delete_confirm, set_show_delete_confirm) = signal(false);
|
||||
let (show_image_modal, set_show_image_modal) = signal(false);
|
||||
|
||||
// Parse dimensions from bounds_wkt
|
||||
let (initial_width, initial_height) = parse_bounds_wkt(&scene.bounds_wkt);
|
||||
|
||||
// Clone scene data for view (to avoid move issues)
|
||||
let scene_name_display = scene.name.clone();
|
||||
let scene_slug_display = scene.slug.clone();
|
||||
let scene_slug_disabled = scene.slug.clone();
|
||||
let scene_description_display = scene.description.clone();
|
||||
let scene_background_image_path = scene.background_image_path.clone();
|
||||
let scene_background_image_path_for_modal = scene.background_image_path.clone();
|
||||
let scene_background_image_path_for_check = scene.background_image_path.clone();
|
||||
let scene_background_image_path_for_dimensions = scene.background_image_path.clone();
|
||||
let scene_background_color_display = scene.background_color.clone();
|
||||
let scene_created_at = scene.created_at.clone();
|
||||
let scene_updated_at = scene.updated_at.clone();
|
||||
|
||||
// Form state
|
||||
let (name, set_name) = signal(scene.name.clone());
|
||||
let (description, set_description) = signal(scene.description.clone().unwrap_or_default());
|
||||
let (background_color, set_background_color) = signal(
|
||||
scene.background_color.clone().unwrap_or_else(|| "#1a1a2e".to_string()),
|
||||
);
|
||||
let (background_image_url, set_background_image_url) = signal(String::new());
|
||||
let (clear_background_image, set_clear_background_image) = signal(false);
|
||||
let (infer_dimensions, set_infer_dimensions) = signal(false);
|
||||
let (width, set_width) = signal(initial_width);
|
||||
let (height, set_height) = signal(initial_height);
|
||||
let (dimension_mode, set_dimension_mode) = signal(scene.dimension_mode.clone());
|
||||
let (sort_order, set_sort_order) = signal(scene.sort_order);
|
||||
let (is_entry_point, set_is_entry_point) = signal(scene.is_entry_point);
|
||||
let (is_hidden, set_is_hidden) = signal(scene.is_hidden);
|
||||
|
||||
// UI state for dimension fetching
|
||||
let (fetching_dimensions, set_fetching_dimensions) = signal(false);
|
||||
let (dimension_message, set_dimension_message) = signal(Option::<(String, bool)>::None);
|
||||
|
||||
let fetch_dimensions = move |_: leptos::ev::MouseEvent| {
|
||||
let url = background_image_url.get();
|
||||
if url.is_empty() {
|
||||
set_dimension_message.set(Some(("Please enter an image URL first".to_string(), false)));
|
||||
return;
|
||||
}
|
||||
|
||||
set_fetching_dimensions.set(true);
|
||||
set_dimension_message.set(None);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
fetch_image_dimensions_client(
|
||||
url,
|
||||
move |w, h| {
|
||||
set_width.set(w as i32);
|
||||
set_height.set(h as i32);
|
||||
set_dimension_message.set(Some((
|
||||
format!("Dimensions: {}x{}", w, h),
|
||||
true,
|
||||
)));
|
||||
},
|
||||
move |err| {
|
||||
set_dimension_message.set(Some((err, false)));
|
||||
},
|
||||
set_fetching_dimensions,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
set_pending.set(true);
|
||||
set_message.set(None);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let id = scene_id.clone();
|
||||
// Build bounds WKT from width/height
|
||||
let w = width.get();
|
||||
let h = height.get();
|
||||
let bounds_wkt = format!("POLYGON((0 0, {} 0, {} {}, 0 {}, 0 0))", w, w, h, h);
|
||||
|
||||
let mut data = serde_json::json!({
|
||||
"name": name.get(),
|
||||
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
|
||||
"background_color": if background_color.get().is_empty() { None::<String> } else { Some(background_color.get()) },
|
||||
"bounds_wkt": bounds_wkt,
|
||||
"dimension_mode": dimension_mode.get(),
|
||||
"sort_order": sort_order.get(),
|
||||
"is_entry_point": is_entry_point.get(),
|
||||
"is_hidden": is_hidden.get()
|
||||
});
|
||||
|
||||
// Only include background_image_url if provided
|
||||
let bg_url = background_image_url.get();
|
||||
if !bg_url.is_empty() {
|
||||
data["background_image_url"] = serde_json::json!(bg_url);
|
||||
// Include infer dimensions flag when uploading new image
|
||||
if infer_dimensions.get() {
|
||||
data["infer_dimensions_from_image"] = serde_json::json!(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Include clear flag if set
|
||||
if clear_background_image.get() {
|
||||
data["clear_background_image"] = serde_json::json!(true);
|
||||
}
|
||||
|
||||
spawn_local(async move {
|
||||
let response = Request::put(&format!("/api/admin/scenes/{}", id))
|
||||
.json(&data)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
set_message.set(Some(("Scene updated successfully!".to_string(), true)));
|
||||
// Clear the background image URL field after success
|
||||
set_background_image_url.set(String::new());
|
||||
set_clear_background_image.set(false);
|
||||
}
|
||||
Ok(resp) => {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ErrorResp {
|
||||
error: String,
|
||||
}
|
||||
if let Ok(err) = resp.json::<ErrorResp>().await {
|
||||
set_message.set(Some((err.error, false)));
|
||||
} else {
|
||||
set_message.set(Some(("Failed to update scene".to_string(), false)));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
set_message.set(Some(("Network error".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
view! {
|
||||
<Card>
|
||||
<div class="realm-header">
|
||||
<div class="realm-info">
|
||||
<h2>{scene_name_display}</h2>
|
||||
<p class="text-muted">"/" {scene_slug_display}</p>
|
||||
</div>
|
||||
<div class="realm-badges">
|
||||
{if scene.is_entry_point {
|
||||
view! { <span class="badge badge-success">"Entry Point"</span> }.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
{if scene.is_hidden {
|
||||
view! { <span class="badge badge-warning">"Hidden"</span> }.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DetailGrid>
|
||||
<DetailItem label="Scene ID">
|
||||
<code>{scene.id.to_string()}</code>
|
||||
</DetailItem>
|
||||
<DetailItem label="Realm ID">
|
||||
<code>{scene.realm_id.to_string()}</code>
|
||||
</DetailItem>
|
||||
<DetailItem label="Dimensions">
|
||||
{format!("{}x{}", initial_width, initial_height)}
|
||||
</DetailItem>
|
||||
<DetailItem label="Sort Order">
|
||||
{scene.sort_order.to_string()}
|
||||
</DetailItem>
|
||||
<DetailItem label="Background">
|
||||
{if let Some(ref path) = scene_background_image_path {
|
||||
let path_clone = path.clone();
|
||||
view! {
|
||||
<div style="display:inline-flex;align-items:center;gap:0.75rem">
|
||||
<img
|
||||
src=path_clone.clone()
|
||||
alt="Background thumbnail"
|
||||
style="max-width:100px;max-height:75px;border:1px solid #555;border-radius:4px;cursor:pointer"
|
||||
title="Click to view full size"
|
||||
on:click=move |_| set_show_image_modal.set(true)
|
||||
/>
|
||||
<span class="text-muted" style="font-size:0.85em">{path_clone}</span>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else if let Some(ref color) = scene_background_color_display {
|
||||
view! {
|
||||
<span style=format!("display:inline-flex;align-items:center;gap:0.5rem")>
|
||||
<span style=format!("display:inline-block;width:20px;height:20px;background:{};border:1px solid #555;border-radius:3px", color)></span>
|
||||
{color.clone()}
|
||||
</span>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! { <span class="text-muted">"None"</span> }.into_any()
|
||||
}}
|
||||
</DetailItem>
|
||||
<DetailItem label="Created">
|
||||
{scene_created_at}
|
||||
</DetailItem>
|
||||
<DetailItem label="Updated">
|
||||
{scene_updated_at}
|
||||
</DetailItem>
|
||||
</DetailGrid>
|
||||
|
||||
{if let Some(ref desc) = scene_description_display {
|
||||
view! {
|
||||
<div class="realm-description">
|
||||
<h4>"Description"</h4>
|
||||
<p>{desc.clone()}</p>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
</Card>
|
||||
|
||||
<Card title="Edit Scene">
|
||||
<form on:submit=on_submit>
|
||||
<h3 class="section-title">"Scene Details"</h3>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="name" class="form-label">"Scene Name"</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
required=true
|
||||
class="form-input"
|
||||
prop:value=move || name.get()
|
||||
on:input=move |ev| set_name.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">"Slug (URL)"</label>
|
||||
<input
|
||||
type="text"
|
||||
value=scene_slug_disabled
|
||||
class="form-input"
|
||||
disabled=true
|
||||
/>
|
||||
<small class="form-help">"Slug cannot be changed"</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="form-label">"Description"</label>
|
||||
<textarea
|
||||
id="description"
|
||||
class="form-textarea"
|
||||
prop:value=move || description.get()
|
||||
on:input=move |ev| set_description.set(event_target_value(&ev))
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title">"Background"</h3>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="background_color" class="form-label">"Background Color"</label>
|
||||
<input
|
||||
type="color"
|
||||
id="background_color"
|
||||
class="form-color"
|
||||
prop:value=move || background_color.get()
|
||||
on:input=move |ev| set_background_color.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group" style="flex: 2">
|
||||
<label for="background_image_url" class="form-label">"New Background Image URL"</label>
|
||||
<div style="display: flex; gap: 0.5rem">
|
||||
<input
|
||||
type="url"
|
||||
id="background_image_url"
|
||||
class="form-input"
|
||||
style="flex: 1"
|
||||
placeholder="https://example.com/image.png"
|
||||
prop:value=move || background_image_url.get()
|
||||
on:input=move |ev| set_background_image_url.set(event_target_value(&ev))
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
disabled=move || fetching_dimensions.get()
|
||||
on:click=fetch_dimensions
|
||||
>
|
||||
{move || if fetching_dimensions.get() { "Fetching..." } else { "Get Size" }}
|
||||
</button>
|
||||
</div>
|
||||
<small class="form-help">"Leave empty to keep current image. Click 'Get Size' to auto-fill dimensions."</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Image preview (for new URL)
|
||||
<Show when=move || !background_image_url.get().is_empty()>
|
||||
<div class="form-group">
|
||||
<label class="form-label">"New Image Preview"</label>
|
||||
<div style="max-width: 300px; border: 1px solid var(--color-border); border-radius: var(--radius-md); overflow: hidden; background: var(--color-bg-tertiary)">
|
||||
<img
|
||||
src=move || background_image_url.get()
|
||||
alt="New background preview"
|
||||
style="max-width: 100%; height: auto; display: block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
// Dimension fetch message
|
||||
<Show when=move || dimension_message.get().is_some()>
|
||||
{move || {
|
||||
let (msg, is_success) = dimension_message.get().unwrap_or_default();
|
||||
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
|
||||
view! {
|
||||
<div class=class role="alert" style="margin-bottom: 1rem">
|
||||
<p>{msg}</p>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</Show>
|
||||
|
||||
// Infer dimensions checkbox (only shown when new URL is provided)
|
||||
<Show when=move || !background_image_url.get().is_empty()>
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || infer_dimensions.get()
|
||||
on:change=move |ev| set_infer_dimensions.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Infer dimensions from image"
|
||||
</label>
|
||||
<small class="form-help">"If enabled, server will extract dimensions from the image when saving"</small>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{if scene_background_image_path_for_check.is_some() {
|
||||
view! {
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || clear_background_image.get()
|
||||
on:change=move |ev| set_clear_background_image.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Remove current background image"
|
||||
</label>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
|
||||
<h3 class="section-title">"Dimensions"</h3>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="width" class="form-label">"Width"</label>
|
||||
<input
|
||||
type="number"
|
||||
id="width"
|
||||
min=100
|
||||
max=10000
|
||||
class="form-input"
|
||||
prop:value=move || width.get()
|
||||
on:input=move |ev| {
|
||||
if let Ok(v) = event_target_value(&ev).parse() {
|
||||
set_width.set(v);
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="height" class="form-label">"Height"</label>
|
||||
<input
|
||||
type="number"
|
||||
id="height"
|
||||
min=100
|
||||
max=10000
|
||||
class="form-input"
|
||||
prop:value=move || height.get()
|
||||
on:input=move |ev| {
|
||||
if let Ok(v) = event_target_value(&ev).parse() {
|
||||
set_height.set(v);
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="dimension_mode" class="form-label">"Dimension Mode"</label>
|
||||
<select
|
||||
id="dimension_mode"
|
||||
class="form-select"
|
||||
on:change=move |ev| set_dimension_mode.set(event_target_value(&ev))
|
||||
>
|
||||
<option value="fixed" selected=move || dimension_mode.get() == "fixed">"Fixed"</option>
|
||||
<option value="viewport" selected=move || dimension_mode.get() == "viewport">"Viewport"</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Button to set dimensions from existing background image
|
||||
{if let Some(ref path) = scene_background_image_path_for_dimensions {
|
||||
let path_for_closure = path.clone();
|
||||
view! {
|
||||
<div class="form-group">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
disabled=move || fetching_dimensions.get()
|
||||
on:click=move |_| {
|
||||
set_fetching_dimensions.set(true);
|
||||
set_dimension_message.set(None);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
let path = path_for_closure.clone();
|
||||
fetch_image_dimensions_client(
|
||||
path,
|
||||
move |w, h| {
|
||||
set_width.set(w as i32);
|
||||
set_height.set(h as i32);
|
||||
set_dimension_message.set(Some((
|
||||
format!("Set from image: {}x{}", w, h),
|
||||
true,
|
||||
)));
|
||||
},
|
||||
move |err| {
|
||||
set_dimension_message.set(Some((err, false)));
|
||||
},
|
||||
set_fetching_dimensions,
|
||||
);
|
||||
}
|
||||
}
|
||||
>
|
||||
{move || if fetching_dimensions.get() { "Fetching..." } else { "Set from background image" }}
|
||||
</button>
|
||||
<small class="form-help">"Set dimensions to match the current background image"</small>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
|
||||
<h3 class="section-title">"Options"</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="sort_order" class="form-label">"Sort Order"</label>
|
||||
<input
|
||||
type="number"
|
||||
id="sort_order"
|
||||
class="form-input"
|
||||
prop:value=move || sort_order.get()
|
||||
on:input=move |ev| {
|
||||
if let Ok(v) = event_target_value(&ev).parse() {
|
||||
set_sort_order.set(v);
|
||||
}
|
||||
}
|
||||
/>
|
||||
<small class="form-help">"Lower numbers appear first in scene lists"</small>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || is_entry_point.get()
|
||||
on:change=move |ev| set_is_entry_point.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Entry Point"
|
||||
</label>
|
||||
<small class="form-help">"Users spawn here when entering the realm"</small>
|
||||
</div>
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || is_hidden.get()
|
||||
on:change=move |ev| set_is_hidden.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Hidden"
|
||||
</label>
|
||||
<small class="form-help">"Scene won't appear in public listings"</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=move || pending.get()
|
||||
>
|
||||
{move || if pending.get() { "Saving..." } else { "Save Changes" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card title="Danger Zone">
|
||||
<p class="text-muted">"Deleting a scene is permanent and cannot be undone. All spots within this scene will also be deleted."</p>
|
||||
|
||||
<Show
|
||||
when=move || !show_delete_confirm.get()
|
||||
fallback={
|
||||
#[cfg(feature = "hydrate")]
|
||||
let id = scene_id_for_delete.clone();
|
||||
#[cfg(feature = "hydrate")]
|
||||
let slug = realm_slug_for_delete.clone();
|
||||
move || {
|
||||
#[cfg(feature = "hydrate")]
|
||||
let id = id.clone();
|
||||
#[cfg(feature = "hydrate")]
|
||||
let slug = slug.clone();
|
||||
view! {
|
||||
<div class="alert alert-warning">
|
||||
<p>"Are you sure you want to delete this scene? This action cannot be undone."</p>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
disabled=move || delete_pending.get()
|
||||
on:click={
|
||||
#[cfg(feature = "hydrate")]
|
||||
let id = id.clone();
|
||||
#[cfg(feature = "hydrate")]
|
||||
let slug = slug.clone();
|
||||
move |_| {
|
||||
set_delete_pending.set(true);
|
||||
set_message.set(None);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let id = id.clone();
|
||||
let slug = slug.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
let response = Request::delete(&format!("/api/admin/scenes/{}", id))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_delete_pending.set(false);
|
||||
set_show_delete_confirm.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.location().set_href(&format!("/admin/realms/{}/scenes", slug));
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
set_message.set(Some(("Failed to delete scene".to_string(), false)));
|
||||
}
|
||||
Err(_) => {
|
||||
set_message.set(Some(("Network error".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
{move || if delete_pending.get() { "Deleting..." } else { "Yes, Delete Scene" }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
on:click=move |_| set_show_delete_confirm.set(false)
|
||||
>
|
||||
"Cancel"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
on:click=move |_| set_show_delete_confirm.set(true)
|
||||
>
|
||||
"Delete Scene"
|
||||
</button>
|
||||
</Show>
|
||||
</Card>
|
||||
|
||||
// Image preview modal
|
||||
<Show when=move || show_image_modal.get()>
|
||||
{
|
||||
let path = scene_background_image_path_for_modal.clone();
|
||||
view! {
|
||||
<div class="modal-overlay">
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
on:click=move |_| set_show_image_modal.set(false)
|
||||
></div>
|
||||
<div class="modal-content" style="max-width:90vw;max-height:90vh;padding:0;background:transparent">
|
||||
<button
|
||||
type="button"
|
||||
class="modal-close"
|
||||
style="position:absolute;top:-30px;right:0;background:#333;color:#fff;border:none;padding:0.5rem;border-radius:4px;cursor:pointer"
|
||||
on:click=move |_| set_show_image_modal.set(false)
|
||||
>
|
||||
"x"
|
||||
</button>
|
||||
{if let Some(ref img_path) = path {
|
||||
view! {
|
||||
<img
|
||||
src=img_path.clone()
|
||||
alt="Background image"
|
||||
style="max-width:90vw;max-height:85vh;object-fit:contain;border-radius:4px"
|
||||
/>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! { <span>"No image"</span> }.into_any()
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
429
crates/chattyness-admin-ui/src/pages/scene_new.rs
Normal file
429
crates/chattyness-admin-ui/src/pages/scene_new.rs
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
//! Create new scene page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::use_params_map;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
|
||||
use crate::components::{Card, PageHeader};
|
||||
#[cfg(feature = "hydrate")]
|
||||
use crate::utils::fetch_image_dimensions_client;
|
||||
|
||||
/// Scene new page component.
|
||||
#[component]
|
||||
pub fn SceneNewPage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let realm_slug = move || params.get().get("slug").unwrap_or_default();
|
||||
|
||||
// Form state
|
||||
let (name, set_name) = signal(String::new());
|
||||
let (slug, set_slug) = signal(String::new());
|
||||
let (description, set_description) = signal(String::new());
|
||||
let (background_color, set_background_color) = signal("#1a1a2e".to_string());
|
||||
let (background_image_url, set_background_image_url) = signal(String::new());
|
||||
let (infer_dimensions, set_infer_dimensions) = signal(false);
|
||||
let (width, set_width) = signal(800i32);
|
||||
let (height, set_height) = signal(600i32);
|
||||
let (dimension_mode, set_dimension_mode) = signal("fixed".to_string());
|
||||
let (sort_order, set_sort_order) = signal(0i32);
|
||||
let (is_entry_point, set_is_entry_point) = signal(false);
|
||||
let (is_hidden, set_is_hidden) = signal(false);
|
||||
|
||||
// UI state
|
||||
let (message, set_message) = signal(Option::<(String, bool)>::None);
|
||||
let (pending, set_pending) = signal(false);
|
||||
let (created_id, _set_created_id) = signal(Option::<String>::None);
|
||||
#[cfg(feature = "hydrate")]
|
||||
let set_created_id = _set_created_id;
|
||||
let (slug_auto, set_slug_auto) = signal(true);
|
||||
let (fetching_dimensions, set_fetching_dimensions) = signal(false);
|
||||
let (dimension_message, set_dimension_message) = signal(Option::<(String, bool)>::None);
|
||||
|
||||
let update_name = move |ev: leptos::ev::Event| {
|
||||
let new_name = event_target_value(&ev);
|
||||
set_name.set(new_name.clone());
|
||||
if slug_auto.get() {
|
||||
let new_slug = new_name
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
||||
.collect::<String>()
|
||||
.trim_matches('-')
|
||||
.to_string();
|
||||
set_slug.set(new_slug);
|
||||
}
|
||||
};
|
||||
|
||||
let fetch_dimensions = move |_: leptos::ev::MouseEvent| {
|
||||
let url = background_image_url.get();
|
||||
if url.is_empty() {
|
||||
set_dimension_message.set(Some(("Please enter an image URL first".to_string(), false)));
|
||||
return;
|
||||
}
|
||||
|
||||
set_fetching_dimensions.set(true);
|
||||
set_dimension_message.set(None);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
fetch_image_dimensions_client(
|
||||
url,
|
||||
move |w, h| {
|
||||
set_width.set(w as i32);
|
||||
set_height.set(h as i32);
|
||||
set_dimension_message.set(Some((
|
||||
format!("Dimensions: {}x{}", w, h),
|
||||
true,
|
||||
)));
|
||||
},
|
||||
move |err| {
|
||||
set_dimension_message.set(Some((err, false)));
|
||||
},
|
||||
set_fetching_dimensions,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
set_pending.set(true);
|
||||
set_message.set(None);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
let realm_slug_val = realm_slug();
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
|
||||
// Build bounds WKT from width/height
|
||||
let w = width.get();
|
||||
let h = height.get();
|
||||
let bounds_wkt = format!("POLYGON((0 0, {} 0, {} {}, 0 {}, 0 0))", w, w, h, h);
|
||||
|
||||
let data = serde_json::json!({
|
||||
"name": name.get(),
|
||||
"slug": slug.get(),
|
||||
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
|
||||
"background_color": if background_color.get().is_empty() { None::<String> } else { Some(background_color.get()) },
|
||||
"background_image_url": if background_image_url.get().is_empty() { None::<String> } else { Some(background_image_url.get()) },
|
||||
"infer_dimensions_from_image": infer_dimensions.get(),
|
||||
"bounds_wkt": bounds_wkt,
|
||||
"dimension_mode": dimension_mode.get(),
|
||||
"sort_order": sort_order.get(),
|
||||
"is_entry_point": is_entry_point.get(),
|
||||
"is_hidden": is_hidden.get()
|
||||
});
|
||||
|
||||
spawn_local(async move {
|
||||
let url = format!("/api/admin/realms/{}/scenes", realm_slug_val);
|
||||
let response = Request::post(&url)
|
||||
.json(&data)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
#[derive(serde::Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct CreateResponse {
|
||||
id: String,
|
||||
slug: String,
|
||||
}
|
||||
if let Ok(result) = resp.json::<CreateResponse>().await {
|
||||
set_created_id.set(Some(result.id));
|
||||
set_message.set(Some(("Scene created successfully!".to_string(), true)));
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ErrorResp {
|
||||
error: String,
|
||||
}
|
||||
if let Ok(err) = resp.json::<ErrorResp>().await {
|
||||
set_message.set(Some((err.error, false)));
|
||||
} else {
|
||||
set_message.set(Some(("Failed to create scene".to_string(), false)));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
set_message.set(Some(("Network error".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let slug_for_header = realm_slug();
|
||||
|
||||
view! {
|
||||
<PageHeader title="Create New Scene" subtitle="Create a new scene in this realm">
|
||||
<a href=format!("/admin/realms/{}/scenes", slug_for_header) class="btn btn-secondary">"Back to Scenes"</a>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<form on:submit=on_submit>
|
||||
<h3 class="section-title">"Scene Details"</h3>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="name" class="form-label">
|
||||
"Scene Name" <span class="required">"*"</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
required=true
|
||||
class="form-input"
|
||||
placeholder="Main Lobby"
|
||||
prop:value=move || name.get()
|
||||
on:input=update_name
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="slug" class="form-label">
|
||||
"Slug (URL)" <span class="required">"*"</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="slug"
|
||||
required=true
|
||||
pattern="[a-z0-9][a-z0-9\\-]*[a-z0-9]|[a-z0-9]"
|
||||
class="form-input"
|
||||
placeholder="main-lobby"
|
||||
prop:value=move || slug.get()
|
||||
on:input=move |ev| {
|
||||
set_slug_auto.set(false);
|
||||
set_slug.set(event_target_value(&ev));
|
||||
}
|
||||
/>
|
||||
<small class="form-help">"Lowercase letters, numbers, hyphens only"</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="form-label">"Description"</label>
|
||||
<textarea
|
||||
id="description"
|
||||
class="form-textarea"
|
||||
placeholder="Description of this scene"
|
||||
prop:value=move || description.get()
|
||||
on:input=move |ev| set_description.set(event_target_value(&ev))
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title">"Background"</h3>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="background_color" class="form-label">"Background Color"</label>
|
||||
<input
|
||||
type="color"
|
||||
id="background_color"
|
||||
class="form-color"
|
||||
prop:value=move || background_color.get()
|
||||
on:input=move |ev| set_background_color.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group" style="flex: 2">
|
||||
<label for="background_image_url" class="form-label">"Background Image URL"</label>
|
||||
<div style="display: flex; gap: 0.5rem">
|
||||
<input
|
||||
type="url"
|
||||
id="background_image_url"
|
||||
class="form-input"
|
||||
style="flex: 1"
|
||||
placeholder="https://example.com/image.png"
|
||||
prop:value=move || background_image_url.get()
|
||||
on:input=move |ev| set_background_image_url.set(event_target_value(&ev))
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
disabled=move || fetching_dimensions.get()
|
||||
on:click=fetch_dimensions
|
||||
>
|
||||
{move || if fetching_dimensions.get() { "Fetching..." } else { "Get Size" }}
|
||||
</button>
|
||||
</div>
|
||||
<small class="form-help">"Enter a public image URL and click 'Get Size' to auto-fill dimensions"</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Image preview
|
||||
<Show when=move || !background_image_url.get().is_empty()>
|
||||
<div class="form-group">
|
||||
<label class="form-label">"Image Preview"</label>
|
||||
<div style="max-width: 300px; border: 1px solid var(--color-border); border-radius: var(--radius-md); overflow: hidden; background: var(--color-bg-tertiary)">
|
||||
<img
|
||||
src=move || background_image_url.get()
|
||||
alt="Background preview"
|
||||
style="max-width: 100%; height: auto; display: block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
// Dimension fetch message
|
||||
<Show when=move || dimension_message.get().is_some()>
|
||||
{move || {
|
||||
let (msg, is_success) = dimension_message.get().unwrap_or_default();
|
||||
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
|
||||
view! {
|
||||
<div class=class role="alert" style="margin-bottom: 1rem">
|
||||
<p>{msg}</p>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || infer_dimensions.get()
|
||||
on:change=move |ev| set_infer_dimensions.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Infer dimensions from image"
|
||||
</label>
|
||||
<small class="form-help">"If enabled, server will extract dimensions from the image when creating the scene"</small>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title">"Dimensions"</h3>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="width" class="form-label">"Width"</label>
|
||||
<input
|
||||
type="number"
|
||||
id="width"
|
||||
min=100
|
||||
max=10000
|
||||
class="form-input"
|
||||
prop:value=move || width.get()
|
||||
on:input=move |ev| {
|
||||
if let Ok(v) = event_target_value(&ev).parse() {
|
||||
set_width.set(v);
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="height" class="form-label">"Height"</label>
|
||||
<input
|
||||
type="number"
|
||||
id="height"
|
||||
min=100
|
||||
max=10000
|
||||
class="form-input"
|
||||
prop:value=move || height.get()
|
||||
on:input=move |ev| {
|
||||
if let Ok(v) = event_target_value(&ev).parse() {
|
||||
set_height.set(v);
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="dimension_mode" class="form-label">"Dimension Mode"</label>
|
||||
<select
|
||||
id="dimension_mode"
|
||||
class="form-select"
|
||||
on:change=move |ev| set_dimension_mode.set(event_target_value(&ev))
|
||||
>
|
||||
<option value="fixed" selected=move || dimension_mode.get() == "fixed">"Fixed"</option>
|
||||
<option value="viewport" selected=move || dimension_mode.get() == "viewport">"Viewport"</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title">"Options"</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="sort_order" class="form-label">"Sort Order"</label>
|
||||
<input
|
||||
type="number"
|
||||
id="sort_order"
|
||||
class="form-input"
|
||||
prop:value=move || sort_order.get()
|
||||
on:input=move |ev| {
|
||||
if let Ok(v) = event_target_value(&ev).parse() {
|
||||
set_sort_order.set(v);
|
||||
}
|
||||
}
|
||||
/>
|
||||
<small class="form-help">"Lower numbers appear first in scene lists"</small>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || is_entry_point.get()
|
||||
on:change=move |ev| set_is_entry_point.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Entry Point"
|
||||
</label>
|
||||
<small class="form-help">"Users spawn here when entering the realm"</small>
|
||||
</div>
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || is_hidden.get()
|
||||
on:change=move |ev| set_is_hidden.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Hidden"
|
||||
</label>
|
||||
<small class="form-help">"Scene won't appear in public listings"</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<Show when=move || created_id.get().is_some()>
|
||||
{move || {
|
||||
let id = created_id.get().unwrap_or_default();
|
||||
let slug = realm_slug();
|
||||
view! {
|
||||
<div class="alert alert-info">
|
||||
<p>
|
||||
<a href=format!("/admin/realms/{}/scenes/{}", slug, id)>
|
||||
"View scene"
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</Show>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=move || pending.get()
|
||||
>
|
||||
{move || if pending.get() { "Creating..." } else { "Create Scene" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
116
crates/chattyness-admin-ui/src/pages/scenes.rs
Normal file
116
crates/chattyness-admin-ui/src/pages/scenes.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
//! Scenes list page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::use_params_map;
|
||||
|
||||
use crate::components::{Card, EmptyState, PageHeader};
|
||||
use crate::hooks::use_fetch_if;
|
||||
use crate::models::SceneSummary;
|
||||
|
||||
/// Scenes list page component.
|
||||
#[component]
|
||||
pub fn ScenesPage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let realm_slug = move || params.get().get("slug").unwrap_or_default();
|
||||
let initial_slug = params.get_untracked().get("slug").unwrap_or_default();
|
||||
|
||||
let scenes = use_fetch_if::<Vec<SceneSummary>>(
|
||||
move || !realm_slug().is_empty(),
|
||||
move || format!("/api/admin/realms/{}/scenes", realm_slug()),
|
||||
);
|
||||
|
||||
let slug_for_create = initial_slug.clone();
|
||||
let slug_for_back = initial_slug.clone();
|
||||
|
||||
view! {
|
||||
<PageHeader title="Scenes" subtitle="Manage scenes for realm">
|
||||
<a href=format!("/admin/realms/{}/scenes/new", slug_for_create) class="btn btn-primary">"Create Scene"</a>
|
||||
</PageHeader>
|
||||
|
||||
<div class="mb-4">
|
||||
<a href=format!("/admin/realms/{}", slug_for_back) class="btn btn-secondary">"Back to Realm"</a>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Suspense fallback=|| view! { <p>"Loading scenes..."</p> }>
|
||||
{move || {
|
||||
let slug = realm_slug();
|
||||
scenes.get().map(|maybe_scenes: Option<Vec<SceneSummary>>| {
|
||||
match maybe_scenes {
|
||||
Some(scene_list) if !scene_list.is_empty() => {
|
||||
view! {
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>"Name"</th>
|
||||
<th>"Slug"</th>
|
||||
<th>"Order"</th>
|
||||
<th>"Entry Point"</th>
|
||||
<th>"Hidden"</th>
|
||||
<th>"Background"</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scene_list.into_iter().map(|scene| {
|
||||
let scene_id = scene.id.to_string();
|
||||
let slug_clone = slug.clone();
|
||||
view! {
|
||||
<tr>
|
||||
<td>
|
||||
<a href=format!("/admin/realms/{}/scenes/{}", slug_clone, scene_id) class="table-link">
|
||||
{scene.name}
|
||||
</a>
|
||||
</td>
|
||||
<td>{scene.slug}</td>
|
||||
<td>{scene.sort_order}</td>
|
||||
<td>
|
||||
{if scene.is_entry_point {
|
||||
view! { <span class="badge badge-success">"Yes"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="text-muted">"-"</span> }.into_any()
|
||||
}}
|
||||
</td>
|
||||
<td>
|
||||
{if scene.is_hidden {
|
||||
view! { <span class="badge badge-warning">"Hidden"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="text-muted">"-"</span> }.into_any()
|
||||
}}
|
||||
</td>
|
||||
<td>
|
||||
{if let Some(color) = scene.background_color {
|
||||
view! {
|
||||
<span style=format!("display:inline-block;width:20px;height:20px;background:{};border:1px solid #555;border-radius:3px", color)></span>
|
||||
}.into_any()
|
||||
} else if scene.background_image_path.is_some() {
|
||||
view! { <span class="text-muted">"Image"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="text-muted">"-"</span> }.into_any()
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}).collect_view()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
_ => {
|
||||
let slug_for_empty = slug.clone();
|
||||
view! {
|
||||
<EmptyState
|
||||
message="No scenes found for this realm."
|
||||
action_href=format!("/admin/realms/{}/scenes/new", slug_for_empty).leak()
|
||||
action_text="Create Scene"
|
||||
/>
|
||||
}.into_any()
|
||||
}
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
263
crates/chattyness-admin-ui/src/pages/staff.rs
Normal file
263
crates/chattyness-admin-ui/src/pages/staff.rs
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
//! Staff management page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
|
||||
use crate::components::{Card, EmptyState, MessageAlertRw, PageHeader, RoleBadge};
|
||||
use crate::hooks::use_fetch;
|
||||
use crate::models::StaffMemberSummary;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use crate::utils::reload_page;
|
||||
|
||||
/// Staff page component.
|
||||
#[component]
|
||||
pub fn StaffPage() -> impl IntoView {
|
||||
let message = RwSignal::new(Option::<(String, bool)>::None);
|
||||
|
||||
let staff = use_fetch::<Vec<StaffMemberSummary>>(|| "/api/admin/staff".to_string());
|
||||
|
||||
view! {
|
||||
<PageHeader title="Server Staff" subtitle="Manage server administrators">
|
||||
<AddStaffButton message=message />
|
||||
</PageHeader>
|
||||
|
||||
<MessageAlertRw message=message />
|
||||
|
||||
<Card>
|
||||
<Suspense fallback=|| view! { <p>"Loading staff..."</p> }>
|
||||
{move || {
|
||||
staff.get().map(|maybe_staff| {
|
||||
match maybe_staff {
|
||||
Some(staff_list) if !staff_list.is_empty() => {
|
||||
view! {
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>"Username"</th>
|
||||
<th>"Display Name"</th>
|
||||
<th>"Email"</th>
|
||||
<th>"Role"</th>
|
||||
<th>"Appointed"</th>
|
||||
<th>"Actions"</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{staff_list.into_iter().map(|member| {
|
||||
let user_id = member.user_id.clone();
|
||||
view! {
|
||||
<tr>
|
||||
<td>
|
||||
<a href=format!("/admin/users/{}", member.user_id) class="table-link">
|
||||
{member.username}
|
||||
</a>
|
||||
</td>
|
||||
<td>{member.display_name}</td>
|
||||
<td>{member.email.unwrap_or_else(|| "-".to_string())}</td>
|
||||
<td><RoleBadge role=member.role /></td>
|
||||
<td>{member.appointed_at}</td>
|
||||
<td>
|
||||
<RemoveStaffButton
|
||||
user_id=user_id
|
||||
message=message
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}).collect_view()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
_ => view! {
|
||||
<EmptyState message="No staff members found." />
|
||||
}.into_any()
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[allow(unused_variables)]
|
||||
fn AddStaffButton(message: RwSignal<Option<(String, bool)>>) -> impl IntoView {
|
||||
let (show_modal, set_show_modal) = signal(false);
|
||||
let (user_id, set_user_id) = signal(String::new());
|
||||
let (role, set_role) = signal("moderator".to_string());
|
||||
let (pending, set_pending) = signal(false);
|
||||
|
||||
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
set_pending.set(true);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let data = serde_json::json!({
|
||||
"user_id": user_id.get(),
|
||||
"role": role.get()
|
||||
});
|
||||
|
||||
spawn_local(async move {
|
||||
let response = Request::post("/api/admin/staff")
|
||||
.json(&data)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
message.set(Some(("Staff member added!".to_string(), true)));
|
||||
set_show_modal.set(false);
|
||||
set_user_id.set(String::new());
|
||||
reload_page();
|
||||
}
|
||||
Ok(_) => {
|
||||
message.set(Some(("Failed to add staff member".to_string(), false)));
|
||||
}
|
||||
Err(_) => {
|
||||
message.set(Some(("Network error".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
on:click=move |_| set_show_modal.set(true)
|
||||
>
|
||||
"Add Staff Member"
|
||||
</button>
|
||||
|
||||
<Show when=move || show_modal.get()>
|
||||
<div class="modal-overlay">
|
||||
<div class="modal-backdrop" on:click=move |_| set_show_modal.set(false)></div>
|
||||
<div class="modal-content">
|
||||
<button
|
||||
type="button"
|
||||
class="modal-close"
|
||||
on:click=move |_| set_show_modal.set(false)
|
||||
>
|
||||
"x"
|
||||
</button>
|
||||
|
||||
<h3 class="modal-title">"Add Staff Member"</h3>
|
||||
|
||||
<form on:submit=on_submit>
|
||||
<div class="form-group">
|
||||
<label for="staff_user_id" class="form-label">"User ID"</label>
|
||||
<input
|
||||
type="text"
|
||||
id="staff_user_id"
|
||||
required=true
|
||||
class="form-input"
|
||||
placeholder="UUID of user to make staff"
|
||||
prop:value=move || user_id.get()
|
||||
on:input=move |ev| set_user_id.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="staff_role" class="form-label">"Role"</label>
|
||||
<select
|
||||
id="staff_role"
|
||||
class="form-select"
|
||||
on:change=move |ev| set_role.set(event_target_value(&ev))
|
||||
>
|
||||
<option value="moderator" selected=move || role.get() == "moderator">"Moderator"</option>
|
||||
<option value="admin" selected=move || role.get() == "admin">"Admin"</option>
|
||||
<option value="owner" selected=move || role.get() == "owner">"Owner"</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
on:click=move |_| set_show_modal.set(false)
|
||||
>
|
||||
"Cancel"
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=move || pending.get()
|
||||
>
|
||||
{move || if pending.get() { "Adding..." } else { "Add Staff" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[allow(unused_variables)]
|
||||
fn RemoveStaffButton(
|
||||
user_id: String,
|
||||
message: RwSignal<Option<(String, bool)>>,
|
||||
) -> impl IntoView {
|
||||
let (pending, set_pending) = signal(false);
|
||||
#[cfg(feature = "hydrate")]
|
||||
let user_id_for_click = user_id.clone();
|
||||
|
||||
let on_click = move |_| {
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use crate::utils::confirm;
|
||||
|
||||
if !confirm("Remove this staff member?") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
set_pending.set(true);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let user_id = user_id_for_click.clone();
|
||||
spawn_local(async move {
|
||||
let response = Request::delete(&format!("/api/admin/staff/{}", user_id))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
message.set(Some(("Staff member removed!".to_string(), true)));
|
||||
reload_page();
|
||||
}
|
||||
_ => {
|
||||
message.set(Some(("Failed to remove staff member".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger btn-sm"
|
||||
disabled=move || pending.get()
|
||||
on:click=on_click
|
||||
>
|
||||
{move || if pending.get() { "..." } else { "Remove" }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
228
crates/chattyness-admin-ui/src/pages/user_detail.rs
Normal file
228
crates/chattyness-admin-ui/src/pages/user_detail.rs
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
//! User detail page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::use_params_map;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
|
||||
use crate::components::{Card, DetailGrid, DetailItem, MessageAlert, PageHeader, StatusBadge, TempPasswordDisplay};
|
||||
use crate::hooks::use_fetch_if;
|
||||
use crate::models::UserDetail;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use crate::utils::reload_page;
|
||||
|
||||
/// User detail page component.
|
||||
#[component]
|
||||
pub fn UserDetailPage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let user_id = move || params.get().get("user_id").unwrap_or_default();
|
||||
let initial_user_id = params.get_untracked().get("user_id").unwrap_or_default();
|
||||
|
||||
let (message, set_message) = signal(Option::<(String, bool)>::None);
|
||||
|
||||
let user = use_fetch_if::<UserDetail>(
|
||||
move || !user_id().is_empty(),
|
||||
move || format!("/api/admin/users/{}", user_id()),
|
||||
);
|
||||
|
||||
view! {
|
||||
<PageHeader title="User Details" subtitle=initial_user_id>
|
||||
<a href="/admin/users" class="btn btn-secondary">"Back to Users"</a>
|
||||
</PageHeader>
|
||||
|
||||
<Suspense fallback=|| view! { <p>"Loading user..."</p> }>
|
||||
{move || {
|
||||
user.get().map(|maybe_user| {
|
||||
match maybe_user {
|
||||
Some(u) => view! {
|
||||
<UserDetailView user=u message=message set_message=set_message />
|
||||
}.into_any(),
|
||||
None => view! {
|
||||
<Card>
|
||||
<p class="text-error">"User not found or you don't have permission to view."</p>
|
||||
</Card>
|
||||
}.into_any()
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn UserDetailView(
|
||||
user: UserDetail,
|
||||
message: ReadSignal<Option<(String, bool)>>,
|
||||
set_message: WriteSignal<Option<(String, bool)>>,
|
||||
) -> impl IntoView {
|
||||
#[cfg(feature = "hydrate")]
|
||||
let user_id = user.id.clone();
|
||||
#[cfg(feature = "hydrate")]
|
||||
let user_id_for_status = user_id.clone();
|
||||
#[cfg(feature = "hydrate")]
|
||||
let user_id_for_reset = user_id.clone();
|
||||
let user_status = user.status.clone();
|
||||
let user_status_for_badge = user_status.clone();
|
||||
|
||||
let (pending_status, set_pending_status) = signal(false);
|
||||
let (pending_reset, set_pending_reset) = signal(false);
|
||||
let (new_password, set_new_password) = signal(Option::<String>::None);
|
||||
|
||||
let update_status = {
|
||||
#[allow(unused_variables)]
|
||||
move |new_status: &'static str| {
|
||||
set_pending_status.set(true);
|
||||
set_message.set(None);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let user_id = user_id_for_status.clone();
|
||||
let status = new_status.to_string();
|
||||
spawn_local(async move {
|
||||
let response = Request::put(&format!("/api/admin/users/{}/status", user_id))
|
||||
.json(&serde_json::json!({ "status": status }))
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_pending_status.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
set_message.set(Some(("Status updated!".to_string(), true)));
|
||||
reload_page();
|
||||
}
|
||||
_ => {
|
||||
set_message.set(Some(("Failed to update status".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let reset_password = move |_| {
|
||||
set_pending_reset.set(true);
|
||||
set_message.set(None);
|
||||
set_new_password.set(None);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let user_id = user_id_for_reset.clone();
|
||||
spawn_local(async move {
|
||||
let response = Request::post(&format!("/api/admin/users/{}/reset-password", user_id))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_pending_reset.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ResetResponse {
|
||||
temporary_password: String,
|
||||
}
|
||||
if let Ok(result) = resp.json::<ResetResponse>().await {
|
||||
set_new_password.set(Some(result.temporary_password));
|
||||
set_message.set(Some(("Password reset successfully!".to_string(), true)));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
set_message.set(Some(("Failed to reset password".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<Card>
|
||||
<div class="user-header">
|
||||
<div class="user-info">
|
||||
<h2>{user.display_name.clone()}</h2>
|
||||
<p class="text-muted">"@" {user.username.clone()}</p>
|
||||
</div>
|
||||
<StatusBadge status=user_status_for_badge />
|
||||
</div>
|
||||
|
||||
<DetailGrid>
|
||||
<DetailItem label="User ID">
|
||||
<code>{user.id.clone()}</code>
|
||||
</DetailItem>
|
||||
<DetailItem label="Email">
|
||||
{user.email.clone().unwrap_or_else(|| "Not set".to_string())}
|
||||
</DetailItem>
|
||||
<DetailItem label="Server Role">
|
||||
{user.server_role.clone().unwrap_or_else(|| "None".to_string())}
|
||||
</DetailItem>
|
||||
<DetailItem label="Created">
|
||||
{user.created_at.clone()}
|
||||
</DetailItem>
|
||||
<DetailItem label="Updated">
|
||||
{user.updated_at.clone()}
|
||||
</DetailItem>
|
||||
</DetailGrid>
|
||||
</Card>
|
||||
|
||||
<Card title="Account Actions">
|
||||
<MessageAlert message=message />
|
||||
<TempPasswordDisplay password=new_password label="New Temporary Password:" />
|
||||
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
disabled=move || pending_reset.get()
|
||||
on:click=reset_password
|
||||
>
|
||||
{move || if pending_reset.get() { "Resetting..." } else { "Reset Password" }}
|
||||
</button>
|
||||
|
||||
{if user_status != "suspended" {
|
||||
let update_status = update_status.clone();
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-warning"
|
||||
disabled=move || pending_status.get()
|
||||
on:click=move |_| update_status("suspended")
|
||||
>
|
||||
"Suspend User"
|
||||
</button>
|
||||
}.into_any()
|
||||
} else {
|
||||
let update_status = update_status.clone();
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
disabled=move || pending_status.get()
|
||||
on:click=move |_| update_status("active")
|
||||
>
|
||||
"Activate User"
|
||||
</button>
|
||||
}.into_any()
|
||||
}}
|
||||
|
||||
{if user_status != "banned" {
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
disabled=move || pending_status.get()
|
||||
on:click=move |_| update_status("banned")
|
||||
>
|
||||
"Ban User"
|
||||
</button>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
</div>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
149
crates/chattyness-admin-ui/src/pages/user_new.rs
Normal file
149
crates/chattyness-admin-ui/src/pages/user_new.rs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
//! Create new user page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
|
||||
use crate::components::{Card, MessageAlert, PageHeader, TempPasswordDisplay};
|
||||
|
||||
/// User new page component.
|
||||
#[component]
|
||||
pub fn UserNewPage() -> impl IntoView {
|
||||
let (username, set_username) = signal(String::new());
|
||||
let (email, set_email) = signal(String::new());
|
||||
let (display_name, set_display_name) = signal(String::new());
|
||||
let (message, set_message) = signal(Option::<(String, bool)>::None);
|
||||
let (pending, set_pending) = signal(false);
|
||||
let (temp_password, _set_temp_password) = signal(Option::<String>::None);
|
||||
#[cfg(feature = "hydrate")]
|
||||
let set_temp_password = _set_temp_password;
|
||||
|
||||
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
set_pending.set(true);
|
||||
set_message.set(None);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let data = serde_json::json!({
|
||||
"username": username.get(),
|
||||
"email": if email.get().is_empty() { None::<String> } else { Some(email.get()) },
|
||||
"display_name": display_name.get()
|
||||
});
|
||||
|
||||
spawn_local(async move {
|
||||
let response = Request::post("/api/admin/users")
|
||||
.json(&data)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CreateResponse {
|
||||
temporary_password: String,
|
||||
}
|
||||
if let Ok(result) = resp.json::<CreateResponse>().await {
|
||||
set_temp_password.set(Some(result.temporary_password));
|
||||
set_message.set(Some(("User created successfully!".to_string(), true)));
|
||||
set_username.set(String::new());
|
||||
set_email.set(String::new());
|
||||
set_display_name.set(String::new());
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ErrorResp {
|
||||
error: String,
|
||||
}
|
||||
if let Ok(err) = resp.json::<ErrorResp>().await {
|
||||
set_message.set(Some((err.error, false)));
|
||||
} else {
|
||||
set_message.set(Some(("Failed to create user".to_string(), false)));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
set_message.set(Some(("Network error".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<PageHeader title="Create New User" subtitle="Add a new user account">
|
||||
<a href="/admin/users" class="btn btn-secondary">"Back to Users"</a>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<form on:submit=on_submit>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="username" class="form-label">
|
||||
"Username" <span class="required">"*"</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
required=true
|
||||
minlength=3
|
||||
maxlength=32
|
||||
pattern="[a-zA-Z][a-zA-Z0-9_]*"
|
||||
class="form-input"
|
||||
placeholder="username"
|
||||
prop:value=move || username.get()
|
||||
on:input=move |ev| set_username.set(event_target_value(&ev))
|
||||
/>
|
||||
<small class="form-help">"Letters, numbers, and underscores only"</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email" class="form-label">"Email"</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
class="form-input"
|
||||
placeholder="user@example.com"
|
||||
prop:value=move || email.get()
|
||||
on:input=move |ev| set_email.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="display_name" class="form-label">
|
||||
"Display Name" <span class="required">"*"</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="display_name"
|
||||
required=true
|
||||
minlength=1
|
||||
maxlength=64
|
||||
class="form-input"
|
||||
placeholder="Display Name"
|
||||
prop:value=move || display_name.get()
|
||||
on:input=move |ev| set_display_name.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MessageAlert message=message />
|
||||
<TempPasswordDisplay password=temp_password />
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=move || pending.get()
|
||||
>
|
||||
{move || if pending.get() { "Creating..." } else { "Create User" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
94
crates/chattyness-admin-ui/src/pages/users.rs
Normal file
94
crates/chattyness-admin-ui/src/pages/users.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
//! Users list page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
use crate::components::{Card, EmptyState, PageHeader, Pagination, SearchForm, StatusBadge};
|
||||
use crate::hooks::{use_fetch, use_pagination};
|
||||
use crate::models::UserSummary;
|
||||
use crate::utils::build_paginated_url;
|
||||
|
||||
/// Users page component.
|
||||
#[component]
|
||||
pub fn UsersPage() -> impl IntoView {
|
||||
let pagination = use_pagination();
|
||||
|
||||
// Fetch users using the new hook
|
||||
let users = use_fetch::<Vec<UserSummary>>(move || {
|
||||
build_paginated_url(
|
||||
"/api/admin/users",
|
||||
pagination.page.get(),
|
||||
&pagination.search_query.get(),
|
||||
25,
|
||||
)
|
||||
});
|
||||
|
||||
view! {
|
||||
<PageHeader title="All Users" subtitle="Manage user accounts">
|
||||
<a href="/admin/users/new" class="btn btn-primary">"Create User"</a>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<SearchForm
|
||||
action="/admin/users"
|
||||
placeholder="Search by username or email..."
|
||||
search_input=pagination.search_input
|
||||
/>
|
||||
|
||||
<Suspense fallback=|| view! { <p>"Loading users..."</p> }>
|
||||
{move || {
|
||||
users.get().map(|maybe_users: Option<Vec<UserSummary>>| {
|
||||
match maybe_users {
|
||||
Some(user_list) if !user_list.is_empty() => {
|
||||
view! {
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>"Username"</th>
|
||||
<th>"Display Name"</th>
|
||||
<th>"Email"</th>
|
||||
<th>"Status"</th>
|
||||
<th>"Created"</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{user_list.into_iter().map(|user| {
|
||||
view! {
|
||||
<tr>
|
||||
<td>
|
||||
<a href=format!("/admin/users/{}", user.id) class="table-link">
|
||||
{user.username}
|
||||
</a>
|
||||
</td>
|
||||
<td>{user.display_name}</td>
|
||||
<td>{user.email.unwrap_or_else(|| "-".to_string())}</td>
|
||||
<td><StatusBadge status=user.status /></td>
|
||||
<td>{user.created_at}</td>
|
||||
</tr>
|
||||
}
|
||||
}).collect_view()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
current_page=pagination.page.get()
|
||||
base_url="/admin/users".to_string()
|
||||
query=pagination.search_query.get()
|
||||
/>
|
||||
}.into_any()
|
||||
}
|
||||
_ => view! {
|
||||
<EmptyState
|
||||
message="No users found."
|
||||
action_href="/admin/users/new"
|
||||
action_text="Create User"
|
||||
/>
|
||||
}.into_any()
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue