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
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>
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue