chattyness/crates/chattyness-admin-ui/src/pages/realm_avatars.rs
Evan Carroll 6fb90e42c3 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}.
2026-01-22 21:04:27 -06:00

203 lines
9.3 KiB
Rust

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