//! Scene detail/edit page component. use leptos::prelude::*; #[cfg(feature = "hydrate")] use leptos::task::spawn_local; use leptos_router::hooks::use_params_map; 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, pub background_image_path: Option, pub background_color: Option, 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::().await.ok(), _ => None, } } #[cfg(not(feature = "hydrate"))] { let _ = id; None:: } } }); let slug_for_back = initial_realm_slug.clone(); view! { "Back to Scenes" "Loading scene..."

}> {move || { let realm_slug_val = realm_slug(); scene.get().map(|maybe_scene| { match maybe_scene { Some(s) => view! { }.into_any(), None => view! {

"Scene not found or you don't have permission to view."

}.into_any() } }) }}
} } #[component] #[allow(unused_variables)] fn SceneDetailView( scene: SceneDetail, realm_slug: String, message: ReadSignal>, set_message: WriteSignal>, ) -> 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:: } else { Some(description.get()) }, "background_color": if background_color.get().is_empty() { None:: } 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::().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! {

{scene_name_display}

"/" {scene_slug_display}

{if scene.is_entry_point { view! { "Entry Point" }.into_any() } else { view! {}.into_any() }} {if scene.is_hidden { view! { "Hidden" }.into_any() } else { view! {}.into_any() }}
{scene.id.to_string()} {scene.realm_id.to_string()} {format!("{}x{}", initial_width, initial_height)} {scene.sort_order.to_string()} {if let Some(ref path) = scene_background_image_path { let path_clone = path.clone(); view! {
Background thumbnail {path_clone}
}.into_any() } else if let Some(ref color) = scene_background_color_display { view! { {color.clone()} }.into_any() } else { view! { "None" }.into_any() }}
{scene_created_at} {scene_updated_at}
{if let Some(ref desc) = scene_description_display { view! {

"Description"

{desc.clone()}

}.into_any() } else { view! {}.into_any() }}

"Scene Details"

"Slug cannot be changed"

"Background"

"Leave empty to keep current image. Click 'Get Size' to auto-fill dimensions."
// Image preview (for new URL)
New background preview
// Show detected dimensions (read-only feedback for new images) {move || { let (w, h) = detected_dimensions.get().unwrap_or((0, 0)); view! {

"Detected Size: " {w} " × " {h} " px"

"Dimensions will be extracted from the new image when saved"
} }}
{if scene_background_image_path_for_check.is_some() { view! {
}.into_any() } else { view! {}.into_any() }}

"Options"

"Lower numbers appear first in scene lists"
"Users spawn here when entering the realm"
"Scene won't appear in public listings"
{move || { let (msg, is_success) = message.get().unwrap_or_default(); let class = if is_success { "alert alert-success" } else { "alert alert-error" }; view! { } }}

"Deleting a scene is permanent and cannot be undone. All spots within this scene will also be deleted."

"Are you sure you want to delete this scene? This action cannot be undone."

} } } >
// Image preview modal { let path = scene_background_image_path_for_modal.clone(); view! { } } } }