add initial crates and apps

This commit is contained in:
Evan Carroll 2026-01-12 15:34:40 -06:00
parent 5c87ba3519
commit 1ca300098f
113 changed files with 28169 additions and 0 deletions

View 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>
}
}