Rework avatars.
Now we have a concept of an avatar at the server, realm, and scene level
and we have the groundwork for a realm store. New uesrs no longer props,
they get a default avatar. New system supports gender
{male,female,neutral} and {child,adult}.
This commit is contained in:
parent
e4abdb183f
commit
6fb90e42c3
55 changed files with 7392 additions and 512 deletions
188
crates/chattyness-admin-ui/src/pages/avatars.rs
Normal file
188
crates/chattyness-admin-ui/src/pages/avatars.rs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
//! Server avatars list page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
use crate::components::{Card, EmptyState, PageHeader};
|
||||
use crate::hooks::use_fetch;
|
||||
use crate::models::AvatarSummary;
|
||||
|
||||
/// View mode for avatars listing.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ViewMode {
|
||||
Table,
|
||||
Grid,
|
||||
}
|
||||
|
||||
/// Server avatars page component with table and grid views.
|
||||
#[component]
|
||||
pub fn AvatarsPage() -> impl IntoView {
|
||||
let (view_mode, set_view_mode) = signal(ViewMode::Table);
|
||||
|
||||
let avatars = use_fetch::<Vec<AvatarSummary>>(|| "/api/admin/avatars".to_string());
|
||||
|
||||
view! {
|
||||
<PageHeader title="Server Avatars" subtitle="Manage pre-configured avatar templates">
|
||||
<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/avatars/new" class="btn btn-primary">"Create Avatar"</a>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<Suspense fallback=|| view! { <p>"Loading avatars..."</p> }>
|
||||
{move || {
|
||||
avatars.get().map(|maybe_avatars: Option<Vec<AvatarSummary>>| {
|
||||
match maybe_avatars {
|
||||
Some(avatar_list) if !avatar_list.is_empty() => {
|
||||
if view_mode.get() == ViewMode::Table {
|
||||
view! { <AvatarsTable avatars=avatar_list.clone() /> }.into_any()
|
||||
} else {
|
||||
view! { <AvatarsGrid avatars=avatar_list.clone() /> }.into_any()
|
||||
}
|
||||
}
|
||||
_ => view! {
|
||||
<EmptyState
|
||||
message="No server avatars found."
|
||||
action_href="/admin/avatars/new"
|
||||
action_text="Create Avatar"
|
||||
/>
|
||||
}.into_any()
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
|
||||
/// Table view for avatars.
|
||||
#[component]
|
||||
fn AvatarsTable(avatars: Vec<AvatarSummary>) -> impl IntoView {
|
||||
view! {
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>"Thumbnail"</th>
|
||||
<th>"Name"</th>
|
||||
<th>"Slug"</th>
|
||||
<th>"Public"</th>
|
||||
<th>"Active"</th>
|
||||
<th>"Created"</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{avatars.into_iter().map(|avatar| {
|
||||
let thumbnail_url = avatar.thumbnail_path.clone()
|
||||
.map(|p| format!("/assets/{}", p));
|
||||
view! {
|
||||
<tr>
|
||||
<td>
|
||||
{thumbnail_url.map(|url| view! {
|
||||
<img
|
||||
src=url
|
||||
alt=avatar.name.clone()
|
||||
class="avatar-thumbnail"
|
||||
style="width: 48px; height: 48px; object-fit: contain; border-radius: 4px;"
|
||||
/>
|
||||
})}
|
||||
</td>
|
||||
<td>
|
||||
<a href=format!("/admin/avatars/{}", avatar.id) class="table-link">
|
||||
{avatar.name}
|
||||
</a>
|
||||
</td>
|
||||
<td><code>{avatar.slug}</code></td>
|
||||
<td>
|
||||
{if avatar.is_public {
|
||||
view! { <span class="status-badge status-active">"Public"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="status-badge status-inactive">"Private"</span> }.into_any()
|
||||
}}
|
||||
</td>
|
||||
<td>
|
||||
{if avatar.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>{avatar.created_at}</td>
|
||||
</tr>
|
||||
}
|
||||
}).collect_view()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Grid view for avatars with thumbnails.
|
||||
#[component]
|
||||
fn AvatarsGrid(avatars: Vec<AvatarSummary>) -> impl IntoView {
|
||||
view! {
|
||||
<div class="avatars-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 16px; padding: 16px;">
|
||||
{avatars.into_iter().map(|avatar| {
|
||||
let thumbnail_url = avatar.thumbnail_path.clone()
|
||||
.map(|p| format!("/assets/{}", p))
|
||||
.unwrap_or_else(|| "/static/placeholder-avatar.svg".to_string());
|
||||
let avatar_url = format!("/admin/avatars/{}", avatar.id);
|
||||
let avatar_name_for_title = avatar.name.clone();
|
||||
let avatar_name_for_alt = avatar.name.clone();
|
||||
let avatar_name_for_label = avatar.name;
|
||||
let is_active = avatar.is_active;
|
||||
let is_public = avatar.is_public;
|
||||
view! {
|
||||
<a
|
||||
href=avatar_url
|
||||
class="avatars-grid-item"
|
||||
style="display: flex; flex-direction: column; align-items: center; text-decoration: none; color: inherit; padding: 12px; border-radius: 8px; background: var(--bg-secondary, #1e293b); transition: background 0.2s; position: relative;"
|
||||
title=avatar_name_for_title
|
||||
>
|
||||
<img
|
||||
src=thumbnail_url
|
||||
alt=avatar_name_for_alt
|
||||
style="width: 80px; height: 80px; object-fit: contain; border-radius: 4px;"
|
||||
/>
|
||||
<span style="font-size: 0.75rem; margin-top: 8px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;">
|
||||
{avatar_name_for_label}
|
||||
</span>
|
||||
<div style="display: flex; gap: 4px; margin-top: 4px;">
|
||||
{if is_public {
|
||||
view! { <span class="status-badge status-active" style="font-size: 0.6rem; padding: 2px 4px;">"Public"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="status-badge status-inactive" style="font-size: 0.6rem; padding: 2px 4px;">"Private"</span> }.into_any()
|
||||
}}
|
||||
{if !is_active {
|
||||
view! { <span class="status-badge status-inactive" style="font-size: 0.6rem; padding: 2px 4px;">"Inactive"</span> }.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
}).collect_view()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
306
crates/chattyness-admin-ui/src/pages/avatars_detail.rs
Normal file
306
crates/chattyness-admin-ui/src/pages/avatars_detail.rs
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
//! Server avatar detail/edit page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_router::hooks::use_params_map;
|
||||
|
||||
use crate::components::{Card, DeleteConfirmation, DetailGrid, DetailItem, MessageAlert, PageHeader};
|
||||
use crate::hooks::use_fetch_if;
|
||||
use crate::models::AvatarDetail;
|
||||
use crate::utils::get_api_base;
|
||||
|
||||
/// Server avatar detail page component.
|
||||
#[component]
|
||||
pub fn AvatarsDetailPage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let avatar_id = move || params.get().get("avatar_id").unwrap_or_default();
|
||||
let initial_avatar_id = params.get_untracked().get("avatar_id").unwrap_or_default();
|
||||
|
||||
let (message, set_message) = signal(Option::<(String, bool)>::None);
|
||||
|
||||
let avatar = use_fetch_if::<AvatarDetail>(
|
||||
move || !avatar_id().is_empty(),
|
||||
move || format!("{}/avatars/{}", get_api_base(), avatar_id()),
|
||||
);
|
||||
|
||||
view! {
|
||||
<PageHeader title="Avatar Details" subtitle=format!("ID: {}", initial_avatar_id)>
|
||||
<a href="/admin/avatars" class="btn btn-secondary">"Back to Avatars"</a>
|
||||
</PageHeader>
|
||||
|
||||
<Suspense fallback=|| view! { <p>"Loading avatar..."</p> }>
|
||||
{move || {
|
||||
avatar.get().map(|maybe_avatar| {
|
||||
match maybe_avatar {
|
||||
Some(a) => view! {
|
||||
<AvatarDetailView avatar=a message=message set_message=set_message />
|
||||
}.into_any(),
|
||||
None => view! {
|
||||
<Card>
|
||||
<p class="text-error">"Avatar not found or you don't have permission to view."</p>
|
||||
</Card>
|
||||
}.into_any()
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[allow(unused_variables)]
|
||||
fn AvatarDetailView(
|
||||
avatar: AvatarDetail,
|
||||
message: ReadSignal<Option<(String, bool)>>,
|
||||
set_message: WriteSignal<Option<(String, bool)>>,
|
||||
) -> impl IntoView {
|
||||
#[cfg(feature = "hydrate")]
|
||||
let avatar_id = avatar.id.clone();
|
||||
let slug_display = avatar.slug.clone();
|
||||
let (pending, set_pending) = signal(false);
|
||||
let (delete_pending, set_delete_pending) = signal(false);
|
||||
|
||||
// Form state
|
||||
let (name, set_name) = signal(avatar.name.clone());
|
||||
let (description, set_description) = signal(avatar.description.clone().unwrap_or_default());
|
||||
let (is_public, set_is_public) = signal(avatar.is_public);
|
||||
let (is_active, set_is_active) = signal(avatar.is_active);
|
||||
let (thumbnail_path, set_thumbnail_path) = signal(
|
||||
avatar.thumbnail_path.clone().unwrap_or_default(),
|
||||
);
|
||||
|
||||
// Thumbnail preview
|
||||
let thumbnail_preview = move || {
|
||||
let path = thumbnail_path.get();
|
||||
if path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("/assets/{}", path))
|
||||
}
|
||||
};
|
||||
|
||||
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 avatar_id = avatar_id.clone();
|
||||
let data = serde_json::json!({
|
||||
"name": name.get(),
|
||||
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
|
||||
"is_public": is_public.get(),
|
||||
"is_active": is_active.get(),
|
||||
"thumbnail_path": if thumbnail_path.get().is_empty() { None::<String> } else { Some(thumbnail_path.get()) }
|
||||
});
|
||||
|
||||
spawn_local(async move {
|
||||
let response = Request::put(&format!("{}/avatars/{}", api_base, avatar_id))
|
||||
.json(&data)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
set_message.set(Some(("Avatar updated successfully!".to_string(), true)));
|
||||
}
|
||||
Ok(_) => {
|
||||
set_message.set(Some(("Failed to update avatar".to_string(), false)));
|
||||
}
|
||||
Err(_) => {
|
||||
set_message.set(Some(("Network error".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
let avatar_id_for_delete = avatar.id.clone();
|
||||
|
||||
let on_delete = move || {
|
||||
set_delete_pending.set(true);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let api_base = get_api_base();
|
||||
let avatar_id = avatar_id_for_delete.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
let response = Request::delete(&format!("{}/avatars/{}", api_base, avatar_id))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_delete_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
// Navigate back to avatars list
|
||||
crate::utils::navigate_to("/admin/avatars");
|
||||
}
|
||||
Ok(_) => {
|
||||
set_message.set(Some(("Failed to delete avatar".to_string(), false)));
|
||||
}
|
||||
Err(_) => {
|
||||
set_message.set(Some(("Network error".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Clone values for the view to avoid borrow issues
|
||||
let avatar_name_for_alt = avatar.name.clone();
|
||||
let avatar_name_header = avatar.name.clone();
|
||||
let avatar_desc_header = avatar.description.clone().unwrap_or_default();
|
||||
let avatar_id_display = avatar.id.clone();
|
||||
let avatar_created = avatar.created_at.clone();
|
||||
let avatar_updated = avatar.updated_at.clone();
|
||||
|
||||
view! {
|
||||
<Card>
|
||||
<div class="avatar-header" style="display: flex; align-items: center; gap: 16px; margin-bottom: 16px;">
|
||||
{move || {
|
||||
let name_for_alt = avatar_name_for_alt.clone();
|
||||
thumbnail_preview().map(move |url| view! {
|
||||
<img
|
||||
src=url
|
||||
alt=name_for_alt
|
||||
style="width: 80px; height: 80px; object-fit: contain; border-radius: 8px; background: var(--bg-secondary, #1e293b);"
|
||||
/>
|
||||
})
|
||||
}}
|
||||
<div class="avatar-info">
|
||||
<h2>{avatar_name_header}</h2>
|
||||
<p class="text-muted">{avatar_desc_header}</p>
|
||||
</div>
|
||||
<div class="avatar-badges" style="margin-left: auto; display: flex; gap: 8px;">
|
||||
{if avatar.is_public {
|
||||
view! { <span class="status-badge status-active">"Public"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="status-badge status-inactive">"Private"</span> }.into_any()
|
||||
}}
|
||||
{if avatar.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()
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DetailGrid>
|
||||
<DetailItem label="ID">
|
||||
<code>{avatar_id_display}</code>
|
||||
</DetailItem>
|
||||
<DetailItem label="Slug">
|
||||
<code>{slug_display}</code>
|
||||
</DetailItem>
|
||||
<DetailItem label="Created">
|
||||
{avatar_created}
|
||||
</DetailItem>
|
||||
<DetailItem label="Updated">
|
||||
{avatar_updated}
|
||||
</DetailItem>
|
||||
</DetailGrid>
|
||||
</Card>
|
||||
|
||||
<Card title="Edit Avatar Settings">
|
||||
<form on:submit=on_submit>
|
||||
<div class="form-group">
|
||||
<label for="name" class="form-label">"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 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-group">
|
||||
<label for="thumbnail_path" class="form-label">"Thumbnail Path"</label>
|
||||
<input
|
||||
type="text"
|
||||
id="thumbnail_path"
|
||||
class="form-input"
|
||||
placeholder="avatars/thumbnails/avatar-name.png"
|
||||
prop:value=move || thumbnail_path.get()
|
||||
on:input=move |ev| set_thumbnail_path.set(event_target_value(&ev))
|
||||
/>
|
||||
<small class="form-help">"Relative path to thumbnail image in assets folder."</small>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || is_public.get()
|
||||
on:change=move |ev| set_is_public.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Public"
|
||||
</label>
|
||||
<small class="form-help">"Public avatars can be selected by users."</small>
|
||||
</div>
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || is_active.get()
|
||||
on:change=move |ev| set_is_active.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Active"
|
||||
</label>
|
||||
<small class="form-help">"Inactive avatars are not shown to users."</small>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<Card title="Danger Zone" class="danger-zone">
|
||||
<p class="text-warning">"Deleting an avatar is permanent and cannot be undone."</p>
|
||||
<DeleteConfirmation
|
||||
message="Are you sure you want to delete this avatar? This action cannot be undone."
|
||||
button_text="Delete Avatar"
|
||||
confirm_text="Yes, Delete Avatar"
|
||||
pending=delete_pending
|
||||
on_confirm=on_delete
|
||||
/>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
222
crates/chattyness-admin-ui/src/pages/avatars_new.rs
Normal file
222
crates/chattyness-admin-ui/src/pages/avatars_new.rs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
//! Create new server avatar page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
|
||||
use crate::components::{Card, PageHeader};
|
||||
|
||||
/// Server avatar new page component.
|
||||
#[component]
|
||||
pub fn AvatarsNewPage() -> 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 (is_public, set_is_public) = signal(true);
|
||||
let (thumbnail_path, set_thumbnail_path) = signal(String::new());
|
||||
|
||||
// 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 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 data = 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()) },
|
||||
"is_public": is_public.get(),
|
||||
"thumbnail_path": if thumbnail_path.get().is_empty() { None::<String> } else { Some(thumbnail_path.get()) }
|
||||
});
|
||||
|
||||
spawn_local(async move {
|
||||
let response = Request::post("/api/admin/avatars")
|
||||
.json(&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(("Avatar 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 avatar".to_string(), false)));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
set_message.set(Some(("Network error".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<PageHeader title="Create Server Avatar" subtitle="Add a new pre-configured avatar template">
|
||||
<a href="/admin/avatars" class="btn btn-secondary">"Back to Avatars"</a>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<form on:submit=on_submit>
|
||||
<h3 class="section-title">"Avatar 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="Happy Robot"
|
||||
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="happy-robot"
|
||||
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 cheerful robot avatar for friendly chats"
|
||||
prop:value=move || description.get()
|
||||
on:input=move |ev| set_description.set(event_target_value(&ev))
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="thumbnail_path" class="form-label">"Thumbnail Path"</label>
|
||||
<input
|
||||
type="text"
|
||||
id="thumbnail_path"
|
||||
class="form-input"
|
||||
placeholder="avatars/thumbnails/happy-robot.png"
|
||||
prop:value=move || thumbnail_path.get()
|
||||
on:input=move |ev| set_thumbnail_path.set(event_target_value(&ev))
|
||||
/>
|
||||
<small class="form-help">"Relative path to thumbnail image in assets folder."</small>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title">"Visibility"</h3>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || is_public.get()
|
||||
on:change=move |ev| set_is_public.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Public"
|
||||
</label>
|
||||
<small class="form-help">"Public avatars can be selected by users. Private avatars are only available to admins."</small>
|
||||
</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/avatars/{}", id)>
|
||||
"View avatar"
|
||||
</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 Avatar" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
203
crates/chattyness-admin-ui/src/pages/realm_avatars.rs
Normal file
203
crates/chattyness-admin-ui/src/pages/realm_avatars.rs
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
//! Realm avatars 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::RealmAvatarSummary;
|
||||
use crate::utils::get_api_base;
|
||||
|
||||
/// View mode for avatars listing.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ViewMode {
|
||||
Table,
|
||||
Grid,
|
||||
}
|
||||
|
||||
/// Realm avatars page component with table and grid views.
|
||||
#[component]
|
||||
pub fn RealmAvatarsPage() -> 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 (view_mode, set_view_mode) = signal(ViewMode::Table);
|
||||
|
||||
let avatars = use_fetch_if::<Vec<RealmAvatarSummary>>(
|
||||
move || !slug().is_empty(),
|
||||
move || format!("{}/realms/{}/avatars", get_api_base(), slug()),
|
||||
);
|
||||
|
||||
let slug_for_new = initial_slug.clone();
|
||||
let slug_for_back = initial_slug.clone();
|
||||
|
||||
view! {
|
||||
<PageHeader title="Realm Avatars" subtitle=format!("Manage avatars for /{}", initial_slug)>
|
||||
<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=format!("/admin/realms/{}/avatars/new", slug_for_new) class="btn btn-primary">"Create Avatar"</a>
|
||||
<a href=format!("/admin/realms/{}", slug_for_back) class="btn btn-secondary">"Back to Realm"</a>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<Suspense fallback=|| view! { <p>"Loading avatars..."</p> }>
|
||||
{move || {
|
||||
let slug = slug();
|
||||
avatars.get().map(move |maybe_avatars: Option<Vec<RealmAvatarSummary>>| {
|
||||
match maybe_avatars {
|
||||
Some(avatar_list) if !avatar_list.is_empty() => {
|
||||
if view_mode.get() == ViewMode::Table {
|
||||
view! { <RealmAvatarsTable avatars=avatar_list.clone() realm_slug=slug.clone() /> }.into_any()
|
||||
} else {
|
||||
view! { <RealmAvatarsGrid avatars=avatar_list.clone() realm_slug=slug.clone() /> }.into_any()
|
||||
}
|
||||
}
|
||||
_ => view! {
|
||||
<EmptyState
|
||||
message="No realm avatars found."
|
||||
action_href=format!("/admin/realms/{}/avatars/new", slug).leak()
|
||||
action_text="Create Avatar"
|
||||
/>
|
||||
}.into_any()
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
|
||||
/// Table view for realm avatars.
|
||||
#[component]
|
||||
fn RealmAvatarsTable(avatars: Vec<RealmAvatarSummary>, realm_slug: String) -> impl IntoView {
|
||||
view! {
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>"Thumbnail"</th>
|
||||
<th>"Name"</th>
|
||||
<th>"Slug"</th>
|
||||
<th>"Public"</th>
|
||||
<th>"Active"</th>
|
||||
<th>"Created"</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{avatars.into_iter().map(|avatar| {
|
||||
let thumbnail_url = avatar.thumbnail_path.clone()
|
||||
.map(|p| format!("/assets/{}", p));
|
||||
let detail_url = format!("/admin/realms/{}/avatars/{}", realm_slug, avatar.id);
|
||||
view! {
|
||||
<tr>
|
||||
<td>
|
||||
{thumbnail_url.map(|url| view! {
|
||||
<img
|
||||
src=url
|
||||
alt=avatar.name.clone()
|
||||
class="avatar-thumbnail"
|
||||
style="width: 48px; height: 48px; object-fit: contain; border-radius: 4px;"
|
||||
/>
|
||||
})}
|
||||
</td>
|
||||
<td>
|
||||
<a href=detail_url class="table-link">
|
||||
{avatar.name}
|
||||
</a>
|
||||
</td>
|
||||
<td><code>{avatar.slug}</code></td>
|
||||
<td>
|
||||
{if avatar.is_public {
|
||||
view! { <span class="status-badge status-active">"Public"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="status-badge status-inactive">"Private"</span> }.into_any()
|
||||
}}
|
||||
</td>
|
||||
<td>
|
||||
{if avatar.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>{avatar.created_at}</td>
|
||||
</tr>
|
||||
}
|
||||
}).collect_view()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Grid view for realm avatars with thumbnails.
|
||||
#[component]
|
||||
fn RealmAvatarsGrid(avatars: Vec<RealmAvatarSummary>, realm_slug: String) -> impl IntoView {
|
||||
view! {
|
||||
<div class="avatars-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 16px; padding: 16px;">
|
||||
{avatars.into_iter().map(|avatar| {
|
||||
let thumbnail_url = avatar.thumbnail_path.clone()
|
||||
.map(|p| format!("/assets/{}", p))
|
||||
.unwrap_or_else(|| "/static/placeholder-avatar.svg".to_string());
|
||||
let avatar_url = format!("/admin/realms/{}/avatars/{}", realm_slug, avatar.id);
|
||||
let avatar_name_for_title = avatar.name.clone();
|
||||
let avatar_name_for_alt = avatar.name.clone();
|
||||
let avatar_name_for_label = avatar.name;
|
||||
let is_active = avatar.is_active;
|
||||
let is_public = avatar.is_public;
|
||||
view! {
|
||||
<a
|
||||
href=avatar_url
|
||||
class="avatars-grid-item"
|
||||
style="display: flex; flex-direction: column; align-items: center; text-decoration: none; color: inherit; padding: 12px; border-radius: 8px; background: var(--bg-secondary, #1e293b); transition: background 0.2s; position: relative;"
|
||||
title=avatar_name_for_title
|
||||
>
|
||||
<img
|
||||
src=thumbnail_url
|
||||
alt=avatar_name_for_alt
|
||||
style="width: 80px; height: 80px; object-fit: contain; border-radius: 4px;"
|
||||
/>
|
||||
<span style="font-size: 0.75rem; margin-top: 8px; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;">
|
||||
{avatar_name_for_label}
|
||||
</span>
|
||||
<div style="display: flex; gap: 4px; margin-top: 4px;">
|
||||
{if is_public {
|
||||
view! { <span class="status-badge status-active" style="font-size: 0.6rem; padding: 2px 4px;">"Public"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="status-badge status-inactive" style="font-size: 0.6rem; padding: 2px 4px;">"Private"</span> }.into_any()
|
||||
}}
|
||||
{if !is_active {
|
||||
view! { <span class="status-badge status-inactive" style="font-size: 0.6rem; padding: 2px 4px;">"Inactive"</span> }.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
}).collect_view()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
332
crates/chattyness-admin-ui/src/pages/realm_avatars_detail.rs
Normal file
332
crates/chattyness-admin-ui/src/pages/realm_avatars_detail.rs
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
//! Realm avatar detail/edit page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_router::hooks::use_params_map;
|
||||
|
||||
use crate::components::{Card, DeleteConfirmation, DetailGrid, DetailItem, MessageAlert, PageHeader};
|
||||
use crate::hooks::use_fetch_if;
|
||||
use crate::models::RealmAvatarDetail;
|
||||
use crate::utils::get_api_base;
|
||||
|
||||
/// Realm avatar detail page component.
|
||||
#[component]
|
||||
pub fn RealmAvatarsDetailPage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let realm_slug = move || params.get().get("slug").unwrap_or_default();
|
||||
let avatar_id = move || params.get().get("avatar_id").unwrap_or_default();
|
||||
let initial_realm_slug = params.get_untracked().get("slug").unwrap_or_default();
|
||||
let initial_avatar_id = params.get_untracked().get("avatar_id").unwrap_or_default();
|
||||
|
||||
let (message, set_message) = signal(Option::<(String, bool)>::None);
|
||||
|
||||
let avatar = use_fetch_if::<RealmAvatarDetail>(
|
||||
move || !realm_slug().is_empty() && !avatar_id().is_empty(),
|
||||
move || format!("{}/realms/{}/avatars/{}", get_api_base(), realm_slug(), avatar_id()),
|
||||
);
|
||||
|
||||
let realm_slug_for_back = initial_realm_slug.clone();
|
||||
|
||||
view! {
|
||||
<PageHeader title="Realm Avatar Details" subtitle=format!("ID: {}", initial_avatar_id)>
|
||||
<a href=format!("/admin/realms/{}/avatars", realm_slug_for_back) class="btn btn-secondary">"Back to Avatars"</a>
|
||||
</PageHeader>
|
||||
|
||||
<Suspense fallback=|| view! { <p>"Loading avatar..."</p> }>
|
||||
{move || {
|
||||
let realm_slug = initial_realm_slug.clone();
|
||||
avatar.get().map(move |maybe_avatar| {
|
||||
match maybe_avatar {
|
||||
Some(a) => view! {
|
||||
<RealmAvatarDetailView
|
||||
avatar=a
|
||||
realm_slug=realm_slug.clone()
|
||||
message=message
|
||||
set_message=set_message
|
||||
/>
|
||||
}.into_any(),
|
||||
None => view! {
|
||||
<Card>
|
||||
<p class="text-error">"Avatar not found or you don't have permission to view."</p>
|
||||
</Card>
|
||||
}.into_any()
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[allow(unused_variables)]
|
||||
fn RealmAvatarDetailView(
|
||||
avatar: RealmAvatarDetail,
|
||||
realm_slug: String,
|
||||
message: ReadSignal<Option<(String, bool)>>,
|
||||
set_message: WriteSignal<Option<(String, bool)>>,
|
||||
) -> impl IntoView {
|
||||
#[cfg(feature = "hydrate")]
|
||||
let avatar_id = avatar.id.clone();
|
||||
#[cfg(feature = "hydrate")]
|
||||
let realm_slug_for_api = realm_slug.clone();
|
||||
let realm_slug_for_delete = realm_slug.clone();
|
||||
let slug_display = avatar.slug.clone();
|
||||
let (pending, set_pending) = signal(false);
|
||||
let (delete_pending, set_delete_pending) = signal(false);
|
||||
|
||||
// Form state
|
||||
let (name, set_name) = signal(avatar.name.clone());
|
||||
let (description, set_description) = signal(avatar.description.clone().unwrap_or_default());
|
||||
let (is_public, set_is_public) = signal(avatar.is_public);
|
||||
let (is_active, set_is_active) = signal(avatar.is_active);
|
||||
let (thumbnail_path, set_thumbnail_path) = signal(
|
||||
avatar.thumbnail_path.clone().unwrap_or_default(),
|
||||
);
|
||||
|
||||
// Thumbnail preview
|
||||
let thumbnail_preview = move || {
|
||||
let path = thumbnail_path.get();
|
||||
if path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("/assets/{}", path))
|
||||
}
|
||||
};
|
||||
|
||||
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 avatar_id = avatar_id.clone();
|
||||
let realm_slug = realm_slug_for_api.clone();
|
||||
let data = serde_json::json!({
|
||||
"name": name.get(),
|
||||
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
|
||||
"is_public": is_public.get(),
|
||||
"is_active": is_active.get(),
|
||||
"thumbnail_path": if thumbnail_path.get().is_empty() { None::<String> } else { Some(thumbnail_path.get()) }
|
||||
});
|
||||
|
||||
spawn_local(async move {
|
||||
let response = Request::put(&format!("{}/realms/{}/avatars/{}", api_base, realm_slug, avatar_id))
|
||||
.json(&data)
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
set_message.set(Some(("Avatar updated successfully!".to_string(), true)));
|
||||
}
|
||||
Ok(_) => {
|
||||
set_message.set(Some(("Failed to update avatar".to_string(), false)));
|
||||
}
|
||||
Err(_) => {
|
||||
set_message.set(Some(("Network error".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
let avatar_id_for_delete = avatar.id.clone();
|
||||
#[cfg(feature = "hydrate")]
|
||||
let realm_slug_for_delete_api = realm_slug_for_delete.clone();
|
||||
|
||||
let on_delete = move || {
|
||||
set_delete_pending.set(true);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
|
||||
let api_base = get_api_base();
|
||||
let avatar_id = avatar_id_for_delete.clone();
|
||||
let realm_slug = realm_slug_for_delete_api.clone();
|
||||
let nav_slug = realm_slug_for_delete.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
let response = Request::delete(&format!("{}/realms/{}/avatars/{}", api_base, realm_slug, avatar_id))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
set_delete_pending.set(false);
|
||||
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
// Navigate back to avatars list
|
||||
crate::utils::navigate_to(&format!("/admin/realms/{}/avatars", nav_slug));
|
||||
}
|
||||
Ok(_) => {
|
||||
set_message.set(Some(("Failed to delete avatar".to_string(), false)));
|
||||
}
|
||||
Err(_) => {
|
||||
set_message.set(Some(("Network error".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Clone for the view
|
||||
let avatar_name_header = avatar.name.clone();
|
||||
let avatar_desc_header = avatar.description.clone().unwrap_or_default();
|
||||
let avatar_id_display = avatar.id.clone();
|
||||
let avatar_created = avatar.created_at.clone();
|
||||
let avatar_updated = avatar.updated_at.clone();
|
||||
|
||||
// Additional clone for the closure
|
||||
let avatar_name_for_alt = avatar_name_header.clone();
|
||||
|
||||
view! {
|
||||
<Card>
|
||||
<div class="avatar-header" style="display: flex; align-items: center; gap: 16px; margin-bottom: 16px;">
|
||||
{move || {
|
||||
let name_for_alt = avatar_name_for_alt.clone();
|
||||
thumbnail_preview().map(move |url| view! {
|
||||
<img
|
||||
src=url
|
||||
alt=name_for_alt
|
||||
style="width: 80px; height: 80px; object-fit: contain; border-radius: 8px; background: var(--bg-secondary, #1e293b);"
|
||||
/>
|
||||
})
|
||||
}}
|
||||
<div class="avatar-info">
|
||||
<h2>{avatar_name_header}</h2>
|
||||
<p class="text-muted">{avatar_desc_header}</p>
|
||||
</div>
|
||||
<div class="avatar-badges" style="margin-left: auto; display: flex; gap: 8px;">
|
||||
{if avatar.is_public {
|
||||
view! { <span class="status-badge status-active">"Public"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="status-badge status-inactive">"Private"</span> }.into_any()
|
||||
}}
|
||||
{if avatar.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()
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DetailGrid>
|
||||
<DetailItem label="ID">
|
||||
<code>{avatar_id_display}</code>
|
||||
</DetailItem>
|
||||
<DetailItem label="Slug">
|
||||
<code>{slug_display}</code>
|
||||
</DetailItem>
|
||||
<DetailItem label="Realm">
|
||||
<a href=format!("/admin/realms/{}", realm_slug) class="table-link">
|
||||
{realm_slug.clone()}
|
||||
</a>
|
||||
</DetailItem>
|
||||
<DetailItem label="Created">
|
||||
{avatar_created}
|
||||
</DetailItem>
|
||||
<DetailItem label="Updated">
|
||||
{avatar_updated}
|
||||
</DetailItem>
|
||||
</DetailGrid>
|
||||
</Card>
|
||||
|
||||
<Card title="Edit Avatar Settings">
|
||||
<form on:submit=on_submit>
|
||||
<div class="form-group">
|
||||
<label for="name" class="form-label">"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 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-group">
|
||||
<label for="thumbnail_path" class="form-label">"Thumbnail Path"</label>
|
||||
<input
|
||||
type="text"
|
||||
id="thumbnail_path"
|
||||
class="form-input"
|
||||
placeholder="avatars/thumbnails/avatar-name.png"
|
||||
prop:value=move || thumbnail_path.get()
|
||||
on:input=move |ev| set_thumbnail_path.set(event_target_value(&ev))
|
||||
/>
|
||||
<small class="form-help">"Relative path to thumbnail image in assets folder."</small>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || is_public.get()
|
||||
on:change=move |ev| set_is_public.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Public"
|
||||
</label>
|
||||
<small class="form-help">"Public avatars can be selected by users."</small>
|
||||
</div>
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || is_active.get()
|
||||
on:change=move |ev| set_is_active.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Active"
|
||||
</label>
|
||||
<small class="form-help">"Inactive avatars are not shown to users."</small>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<Card title="Danger Zone" class="danger-zone">
|
||||
<p class="text-warning">"Deleting an avatar is permanent and cannot be undone."</p>
|
||||
<DeleteConfirmation
|
||||
message="Are you sure you want to delete this avatar? This action cannot be undone."
|
||||
button_text="Delete Avatar"
|
||||
confirm_text="Yes, Delete Avatar"
|
||||
pending=delete_pending
|
||||
on_confirm=on_delete
|
||||
/>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
239
crates/chattyness-admin-ui/src/pages/realm_avatars_new.rs
Normal file
239
crates/chattyness-admin-ui/src/pages/realm_avatars_new.rs
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
//! Create new realm avatar page component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_router::hooks::use_params_map;
|
||||
|
||||
use crate::components::{Card, PageHeader};
|
||||
#[cfg(feature = "hydrate")]
|
||||
use crate::utils::get_api_base;
|
||||
|
||||
/// Realm avatar new page component.
|
||||
#[component]
|
||||
pub fn RealmAvatarsNewPage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
#[allow(unused_variables)]
|
||||
let slug = move || params.get().get("slug").unwrap_or_default();
|
||||
let initial_slug = params.get_untracked().get("slug").unwrap_or_default();
|
||||
|
||||
// Form state
|
||||
let (name, set_name) = signal(String::new());
|
||||
let (avatar_slug, set_avatar_slug) = signal(String::new());
|
||||
let (description, set_description) = signal(String::new());
|
||||
let (is_public, set_is_public) = signal(true);
|
||||
let (thumbnail_path, set_thumbnail_path) = signal(String::new());
|
||||
|
||||
// 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 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_avatar_slug.set(new_slug);
|
||||
}
|
||||
};
|
||||
|
||||
let slug_for_back = initial_slug.clone();
|
||||
let slug_for_view = initial_slug.clone();
|
||||
|
||||
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 realm_slug = slug();
|
||||
let data = serde_json::json!({
|
||||
"name": name.get(),
|
||||
"slug": if avatar_slug.get().is_empty() { None::<String> } else { Some(avatar_slug.get()) },
|
||||
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
|
||||
"is_public": is_public.get(),
|
||||
"thumbnail_path": if thumbnail_path.get().is_empty() { None::<String> } else { Some(thumbnail_path.get()) }
|
||||
});
|
||||
|
||||
spawn_local(async move {
|
||||
let response = Request::post(&format!("{}/realms/{}/avatars", api_base, realm_slug))
|
||||
.json(&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(("Avatar 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 avatar".to_string(), false)));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
set_message.set(Some(("Network error".to_string(), false)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<PageHeader title="Create Realm Avatar" subtitle=format!("Add a new avatar for /{}", initial_slug)>
|
||||
<a href=format!("/admin/realms/{}/avatars", slug_for_back) class="btn btn-secondary">"Back to Avatars"</a>
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
<form on:submit=on_submit>
|
||||
<h3 class="section-title">"Avatar 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="Happy Robot"
|
||||
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="happy-robot"
|
||||
prop:value=move || avatar_slug.get()
|
||||
on:input=move |ev| {
|
||||
set_slug_auto.set(false);
|
||||
set_avatar_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 cheerful robot avatar for friendly chats"
|
||||
prop:value=move || description.get()
|
||||
on:input=move |ev| set_description.set(event_target_value(&ev))
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="thumbnail_path" class="form-label">"Thumbnail Path"</label>
|
||||
<input
|
||||
type="text"
|
||||
id="thumbnail_path"
|
||||
class="form-input"
|
||||
placeholder="avatars/thumbnails/happy-robot.png"
|
||||
prop:value=move || thumbnail_path.get()
|
||||
on:input=move |ev| set_thumbnail_path.set(event_target_value(&ev))
|
||||
/>
|
||||
<small class="form-help">"Relative path to thumbnail image in assets folder."</small>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title">"Visibility"</h3>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || is_public.get()
|
||||
on:change=move |ev| set_is_public.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Public"
|
||||
</label>
|
||||
<small class="form-help">"Public avatars can be selected by users. Private avatars are only available to admins."</small>
|
||||
</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()>
|
||||
{
|
||||
let realm_slug = slug_for_view.clone();
|
||||
move || {
|
||||
let id = created_id.get().unwrap_or_default();
|
||||
let realm_slug = realm_slug.clone();
|
||||
view! {
|
||||
<div class="alert alert-info">
|
||||
<p>
|
||||
<a href=format!("/admin/realms/{}/avatars/{}", realm_slug, id)>
|
||||
"View avatar"
|
||||
</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 Avatar" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue