chattyness/crates/chattyness-admin-ui/src/pages/scene_detail.rs
Evan Carroll b430c80000 fix: scaling, and chat
* Chat ergonomics vastly improved.
* Scaling now done through client side settings
2026-01-14 12:53:16 -06:00

657 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Scene detail/edit page component.
use leptos::prelude::*;
use leptos_router::hooks::use_params_map;
#[cfg(feature = "hydrate")]
use leptos::task::spawn_local;
use uuid::Uuid;
use crate::components::{Card, DetailGrid, DetailItem, PageHeader};
#[cfg(feature = "hydrate")]
use crate::utils::fetch_image_dimensions_client;
/// Scene detail from API.
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub struct SceneDetail {
pub id: Uuid,
pub realm_id: Uuid,
pub name: String,
pub slug: String,
pub description: Option<String>,
pub background_image_path: Option<String>,
pub background_color: Option<String>,
pub bounds_wkt: String,
pub dimension_mode: String,
pub sort_order: i32,
pub is_entry_point: bool,
pub is_hidden: bool,
pub created_at: String,
pub updated_at: String,
}
/// Parse width and height from WKT bounds string.
/// Example: "POLYGON((0 0, 800 0, 800 600, 0 600, 0 0))" -> (800, 600)
/// Handles both "0 0, 800 0" (with space) and "0 0,800 0" (without space) formats.
fn parse_bounds_wkt(wkt: &str) -> (i32, i32) {
// Extract coordinates from POLYGON((x1 y1, x2 y2, x3 y3, x4 y4, x5 y5))
// The second point has (width, 0) and third point has (width, height)
if let Some(start) = wkt.find("((") {
if let Some(end) = wkt.find("))") {
let coords_str = &wkt[start + 2..end];
let points: Vec<&str> = coords_str.split(',').map(|s| s.trim()).collect();
if points.len() >= 3 {
// Second point: "width 0"
let second: Vec<&str> = points[1].split_whitespace().collect();
// Third point: "width height"
let third: Vec<&str> = points[2].split_whitespace().collect();
if !second.is_empty() && third.len() >= 2 {
let width = second[0].parse().unwrap_or(800);
let height = third[1].parse().unwrap_or(600);
return (width, height);
}
}
}
}
(800, 600)
}
/// Scene detail page component.
#[component]
pub fn SceneDetailPage() -> impl IntoView {
let params = use_params_map();
let realm_slug = move || params.get().get("slug").unwrap_or_default();
let scene_id = move || params.get().get("scene_id").unwrap_or_default();
let initial_realm_slug = params.get_untracked().get("slug").unwrap_or_default();
let (message, set_message) = signal(Option::<(String, bool)>::None);
let scene = LocalResource::new(move || {
let id = scene_id();
async move {
if id.is_empty() {
return None;
}
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
let resp = Request::get(&format!("/api/admin/scenes/{}", id)).send().await;
match resp {
Ok(r) if r.ok() => r.json::<SceneDetail>().await.ok(),
_ => None,
}
}
#[cfg(not(feature = "hydrate"))]
{
let _ = id;
None::<SceneDetail>
}
}
});
let slug_for_back = initial_realm_slug.clone();
view! {
<PageHeader title="Scene Details" subtitle="View and edit scene">
<a href=format!("/admin/realms/{}/scenes", slug_for_back) class="btn btn-secondary">"Back to Scenes"</a>
</PageHeader>
<Suspense fallback=|| view! { <p>"Loading scene..."</p> }>
{move || {
let realm_slug_val = realm_slug();
scene.get().map(|maybe_scene| {
match maybe_scene {
Some(s) => view! {
<SceneDetailView scene=s realm_slug=realm_slug_val message=message set_message=set_message />
}.into_any(),
None => view! {
<Card>
<p class="text-error">"Scene not found or you don't have permission to view."</p>
</Card>
}.into_any()
}
})
}}
</Suspense>
}
}
#[component]
#[allow(unused_variables)]
fn SceneDetailView(
scene: SceneDetail,
realm_slug: String,
message: ReadSignal<Option<(String, bool)>>,
set_message: WriteSignal<Option<(String, bool)>>,
) -> impl IntoView {
#[cfg(feature = "hydrate")]
let scene_id = scene.id.to_string();
#[cfg(feature = "hydrate")]
let scene_id_for_delete = scene.id.to_string();
#[cfg(feature = "hydrate")]
let realm_slug_for_delete = realm_slug.clone();
let (pending, set_pending) = signal(false);
let (delete_pending, set_delete_pending) = signal(false);
let (show_delete_confirm, set_show_delete_confirm) = signal(false);
let (show_image_modal, set_show_image_modal) = signal(false);
// Parse dimensions from bounds_wkt for display
let (initial_width, initial_height) = parse_bounds_wkt(&scene.bounds_wkt);
// Clone scene data for view (to avoid move issues)
let scene_name_display = scene.name.clone();
let scene_slug_display = scene.slug.clone();
let scene_slug_disabled = scene.slug.clone();
let scene_description_display = scene.description.clone();
let scene_background_image_path = scene.background_image_path.clone();
let scene_background_image_path_for_modal = scene.background_image_path.clone();
let scene_background_image_path_for_check = scene.background_image_path.clone();
let scene_background_color_display = scene.background_color.clone();
let scene_created_at = scene.created_at.clone();
let scene_updated_at = scene.updated_at.clone();
// Form state
let (name, set_name) = signal(scene.name.clone());
let (description, set_description) = signal(scene.description.clone().unwrap_or_default());
let (background_color, set_background_color) = signal(
scene.background_color.clone().unwrap_or_else(|| "#1a1a2e".to_string()),
);
let (background_image_url, set_background_image_url) = signal(String::new());
let (clear_background_image, set_clear_background_image) = signal(false);
let (sort_order, set_sort_order) = signal(scene.sort_order);
let (is_entry_point, set_is_entry_point) = signal(scene.is_entry_point);
let (is_hidden, set_is_hidden) = signal(scene.is_hidden);
// UI state for dimension detection (read-only display)
let (fetching_dimensions, set_fetching_dimensions) = signal(false);
let (detected_dimensions, set_detected_dimensions) = signal(Option::<(u32, u32)>::None);
let fetch_dimensions = move |_: leptos::ev::MouseEvent| {
let url = background_image_url.get();
if url.is_empty() {
return;
}
set_fetching_dimensions.set(true);
set_detected_dimensions.set(None);
#[cfg(feature = "hydrate")]
{
fetch_image_dimensions_client(
url,
move |w, h| {
set_detected_dimensions.set(Some((w, h)));
},
move |_err| {
set_detected_dimensions.set(None);
},
set_fetching_dimensions,
);
}
};
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 id = scene_id.clone();
let mut data = serde_json::json!({
"name": name.get(),
"description": if description.get().is_empty() { None::<String> } else { Some(description.get()) },
"background_color": if background_color.get().is_empty() { None::<String> } else { Some(background_color.get()) },
"sort_order": sort_order.get(),
"is_entry_point": is_entry_point.get(),
"is_hidden": is_hidden.get()
});
// Only include background_image_url if provided
let bg_url = background_image_url.get();
if !bg_url.is_empty() {
data["background_image_url"] = serde_json::json!(bg_url);
// Always infer dimensions from new image
data["infer_dimensions_from_image"] = serde_json::json!(true);
}
// Include clear flag if set
if clear_background_image.get() {
data["clear_background_image"] = serde_json::json!(true);
}
spawn_local(async move {
let response = Request::put(&format!("/api/admin/scenes/{}", id))
.json(&data)
.unwrap()
.send()
.await;
set_pending.set(false);
match response {
Ok(resp) if resp.ok() => {
set_message.set(Some(("Scene updated successfully!".to_string(), true)));
// Clear the background image URL field after success
set_background_image_url.set(String::new());
set_clear_background_image.set(false);
}
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 update scene".to_string(), false)));
}
}
Err(_) => {
set_message.set(Some(("Network error".to_string(), false)));
}
}
});
}
};
view! {
<Card>
<div class="realm-header">
<div class="realm-info">
<h2>{scene_name_display}</h2>
<p class="text-muted">"/" {scene_slug_display}</p>
</div>
<div class="realm-badges">
{if scene.is_entry_point {
view! { <span class="badge badge-success">"Entry Point"</span> }.into_any()
} else {
view! {}.into_any()
}}
{if scene.is_hidden {
view! { <span class="badge badge-warning">"Hidden"</span> }.into_any()
} else {
view! {}.into_any()
}}
</div>
</div>
<DetailGrid>
<DetailItem label="Scene ID">
<code>{scene.id.to_string()}</code>
</DetailItem>
<DetailItem label="Realm ID">
<code>{scene.realm_id.to_string()}</code>
</DetailItem>
<DetailItem label="Dimensions">
{format!("{}x{}", initial_width, initial_height)}
</DetailItem>
<DetailItem label="Sort Order">
{scene.sort_order.to_string()}
</DetailItem>
<DetailItem label="Background">
{if let Some(ref path) = scene_background_image_path {
let path_clone = path.clone();
view! {
<div style="display:inline-flex;align-items:center;gap:0.75rem">
<img
src=path_clone.clone()
alt="Background thumbnail"
style="max-width:100px;max-height:75px;border:1px solid #555;border-radius:4px;cursor:pointer"
title="Click to view full size"
on:click=move |_| set_show_image_modal.set(true)
/>
<span class="text-muted" style="font-size:0.85em">{path_clone}</span>
</div>
}.into_any()
} else if let Some(ref color) = scene_background_color_display {
view! {
<span style=format!("display:inline-flex;align-items:center;gap:0.5rem")>
<span style=format!("display:inline-block;width:20px;height:20px;background:{};border:1px solid #555;border-radius:3px", color)></span>
{color.clone()}
</span>
}.into_any()
} else {
view! { <span class="text-muted">"None"</span> }.into_any()
}}
</DetailItem>
<DetailItem label="Created">
{scene_created_at}
</DetailItem>
<DetailItem label="Updated">
{scene_updated_at}
</DetailItem>
</DetailGrid>
{if let Some(ref desc) = scene_description_display {
view! {
<div class="realm-description">
<h4>"Description"</h4>
<p>{desc.clone()}</p>
</div>
}.into_any()
} else {
view! {}.into_any()
}}
</Card>
<Card title="Edit Scene">
<form on:submit=on_submit>
<h3 class="section-title">"Scene Details"</h3>
<div class="form-row">
<div class="form-group">
<label for="name" class="form-label">"Scene 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=scene_slug_disabled
class="form-input"
disabled=true
/>
<small class="form-help">"Slug cannot be changed"</small>
</div>
</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>
<h3 class="section-title">"Background"</h3>
<div class="form-row">
<div class="form-group">
<label for="background_color" class="form-label">"Background Color"</label>
<input
type="color"
id="background_color"
class="form-color"
prop:value=move || background_color.get()
on:input=move |ev| set_background_color.set(event_target_value(&ev))
/>
</div>
<div class="form-group" style="flex: 2">
<label for="background_image_url" class="form-label">"New Background Image URL"</label>
<div style="display: flex; gap: 0.5rem">
<input
type="url"
id="background_image_url"
class="form-input"
style="flex: 1"
placeholder="https://example.com/image.png"
prop:value=move || background_image_url.get()
on:input=move |ev| set_background_image_url.set(event_target_value(&ev))
/>
<button
type="button"
class="btn btn-secondary"
disabled=move || fetching_dimensions.get()
on:click=fetch_dimensions
>
{move || if fetching_dimensions.get() { "Fetching..." } else { "Get Size" }}
</button>
</div>
<small class="form-help">"Leave empty to keep current image. Click 'Get Size' to auto-fill dimensions."</small>
</div>
</div>
// Image preview (for new URL)
<Show when=move || !background_image_url.get().is_empty()>
<div class="form-group">
<label class="form-label">"New Image Preview"</label>
<div style="max-width: 300px; border: 1px solid var(--color-border); border-radius: var(--radius-md); overflow: hidden; background: var(--color-bg-tertiary)">
<img
src=move || background_image_url.get()
alt="New background preview"
style="max-width: 100%; height: auto; display: block"
/>
</div>
</div>
</Show>
// Show detected dimensions (read-only feedback for new images)
<Show when=move || detected_dimensions.get().is_some()>
{move || {
let (w, h) = detected_dimensions.get().unwrap_or((0, 0));
view! {
<div class="alert alert-info" role="status" style="margin-bottom: 1rem">
<p>"Detected Size: " <strong>{w}</strong> " × " <strong>{h}</strong> " px"</p>
<small>"Dimensions will be extracted from the new image when saved"</small>
</div>
}
}}
</Show>
{if scene_background_image_path_for_check.is_some() {
view! {
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || clear_background_image.get()
on:change=move |ev| set_clear_background_image.set(event_target_checked(&ev))
/>
"Remove current background image"
</label>
</div>
}.into_any()
} else {
view! {}.into_any()
}}
<h3 class="section-title">"Options"</h3>
<div class="form-group">
<label for="sort_order" class="form-label">"Sort Order"</label>
<input
type="number"
id="sort_order"
class="form-input"
prop:value=move || sort_order.get()
on:input=move |ev| {
if let Ok(v) = event_target_value(&ev).parse() {
set_sort_order.set(v);
}
}
/>
<small class="form-help">"Lower numbers appear first in scene lists"</small>
</div>
<div class="form-row">
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || is_entry_point.get()
on:change=move |ev| set_is_entry_point.set(event_target_checked(&ev))
/>
"Entry Point"
</label>
<small class="form-help">"Users spawn here when entering the realm"</small>
</div>
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
class="form-checkbox"
prop:checked=move || is_hidden.get()
on:change=move |ev| set_is_hidden.set(event_target_checked(&ev))
/>
"Hidden"
</label>
<small class="form-help">"Scene won't appear in public listings"</small>
</div>
</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>
<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">
<p class="text-muted">"Deleting a scene is permanent and cannot be undone. All spots within this scene will also be deleted."</p>
<Show
when=move || !show_delete_confirm.get()
fallback={
#[cfg(feature = "hydrate")]
let id = scene_id_for_delete.clone();
#[cfg(feature = "hydrate")]
let slug = realm_slug_for_delete.clone();
move || {
#[cfg(feature = "hydrate")]
let id = id.clone();
#[cfg(feature = "hydrate")]
let slug = slug.clone();
view! {
<div class="alert alert-warning">
<p>"Are you sure you want to delete this scene? This action cannot be undone."</p>
<div class="action-buttons">
<button
type="button"
class="btn btn-danger"
disabled=move || delete_pending.get()
on:click={
#[cfg(feature = "hydrate")]
let id = id.clone();
#[cfg(feature = "hydrate")]
let slug = slug.clone();
move |_| {
set_delete_pending.set(true);
set_message.set(None);
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
let id = id.clone();
let slug = slug.clone();
spawn_local(async move {
let response = Request::delete(&format!("/api/admin/scenes/{}", id))
.send()
.await;
set_delete_pending.set(false);
set_show_delete_confirm.set(false);
match response {
Ok(resp) if resp.ok() => {
if let Some(window) = web_sys::window() {
let _ = window.location().set_href(&format!("/admin/realms/{}/scenes", slug));
}
}
Ok(_) => {
set_message.set(Some(("Failed to delete scene".to_string(), false)));
}
Err(_) => {
set_message.set(Some(("Network error".to_string(), false)));
}
}
});
}
}
}
>
{move || if delete_pending.get() { "Deleting..." } else { "Yes, Delete Scene" }}
</button>
<button
type="button"
class="btn btn-secondary"
on:click=move |_| set_show_delete_confirm.set(false)
>
"Cancel"
</button>
</div>
</div>
}
}
}
>
<button
type="button"
class="btn btn-danger"
on:click=move |_| set_show_delete_confirm.set(true)
>
"Delete Scene"
</button>
</Show>
</Card>
// Image preview modal
<Show when=move || show_image_modal.get()>
{
let path = scene_background_image_path_for_modal.clone();
view! {
<div class="modal-overlay">
<div
class="modal-backdrop"
on:click=move |_| set_show_image_modal.set(false)
></div>
<div class="modal-content" style="max-width:90vw;max-height:90vh;padding:0;background:transparent">
<button
type="button"
class="modal-close"
style="position:absolute;top:-30px;right:0;background:#333;color:#fff;border:none;padding:0.5rem;border-radius:4px;cursor:pointer"
on:click=move |_| set_show_image_modal.set(false)
>
"x"
</button>
{if let Some(ref img_path) = path {
view! {
<img
src=img_path.clone()
alt="Background image"
style="max-width:90vw;max-height:85vh;object-fit:contain;border-radius:4px"
/>
}.into_any()
} else {
view! { <span>"No image"</span> }.into_any()
}}
</div>
</div>
}
}
</Show>
}
}