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}.
203 lines
9.3 KiB
Rust
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>
|
|
}
|
|
}
|