add initial crates and apps
This commit is contained in:
parent
5c87ba3519
commit
1ca300098f
113 changed files with 28169 additions and 0 deletions
783
crates/chattyness-admin-ui/src/pages/scene_detail.rs
Normal file
783
crates/chattyness-admin-ui/src/pages/scene_detail.rs
Normal file
|
|
@ -0,0 +1,783 @@
|
|||
//! 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
|
||||
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_image_path_for_dimensions = 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 (infer_dimensions, set_infer_dimensions) = signal(false);
|
||||
let (width, set_width) = signal(initial_width);
|
||||
let (height, set_height) = signal(initial_height);
|
||||
let (dimension_mode, set_dimension_mode) = signal(scene.dimension_mode.clone());
|
||||
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 fetching
|
||||
let (fetching_dimensions, set_fetching_dimensions) = signal(false);
|
||||
let (dimension_message, set_dimension_message) = signal(Option::<(String, bool)>::None);
|
||||
|
||||
let fetch_dimensions = move |_: leptos::ev::MouseEvent| {
|
||||
let url = background_image_url.get();
|
||||
if url.is_empty() {
|
||||
set_dimension_message.set(Some(("Please enter an image URL first".to_string(), false)));
|
||||
return;
|
||||
}
|
||||
|
||||
set_fetching_dimensions.set(true);
|
||||
set_dimension_message.set(None);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
fetch_image_dimensions_client(
|
||||
url,
|
||||
move |w, h| {
|
||||
set_width.set(w as i32);
|
||||
set_height.set(h as i32);
|
||||
set_dimension_message.set(Some((
|
||||
format!("Dimensions: {}x{}", w, h),
|
||||
true,
|
||||
)));
|
||||
},
|
||||
move |err| {
|
||||
set_dimension_message.set(Some((err, false)));
|
||||
},
|
||||
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();
|
||||
// Build bounds WKT from width/height
|
||||
let w = width.get();
|
||||
let h = height.get();
|
||||
let bounds_wkt = format!("POLYGON((0 0, {} 0, {} {}, 0 {}, 0 0))", w, w, h, h);
|
||||
|
||||
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()) },
|
||||
"bounds_wkt": bounds_wkt,
|
||||
"dimension_mode": dimension_mode.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);
|
||||
// Include infer dimensions flag when uploading new image
|
||||
if infer_dimensions.get() {
|
||||
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>
|
||||
|
||||
// Dimension fetch message
|
||||
<Show when=move || dimension_message.get().is_some()>
|
||||
{move || {
|
||||
let (msg, is_success) = dimension_message.get().unwrap_or_default();
|
||||
let class = if is_success { "alert alert-success" } else { "alert alert-error" };
|
||||
view! {
|
||||
<div class=class role="alert" style="margin-bottom: 1rem">
|
||||
<p>{msg}</p>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</Show>
|
||||
|
||||
// Infer dimensions checkbox (only shown when new URL is provided)
|
||||
<Show when=move || !background_image_url.get().is_empty()>
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox"
|
||||
prop:checked=move || infer_dimensions.get()
|
||||
on:change=move |ev| set_infer_dimensions.set(event_target_checked(&ev))
|
||||
/>
|
||||
"Infer dimensions from image"
|
||||
</label>
|
||||
<small class="form-help">"If enabled, server will extract dimensions from the image when saving"</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">"Dimensions"</h3>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="width" class="form-label">"Width"</label>
|
||||
<input
|
||||
type="number"
|
||||
id="width"
|
||||
min=100
|
||||
max=10000
|
||||
class="form-input"
|
||||
prop:value=move || width.get()
|
||||
on:input=move |ev| {
|
||||
if let Ok(v) = event_target_value(&ev).parse() {
|
||||
set_width.set(v);
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="height" class="form-label">"Height"</label>
|
||||
<input
|
||||
type="number"
|
||||
id="height"
|
||||
min=100
|
||||
max=10000
|
||||
class="form-input"
|
||||
prop:value=move || height.get()
|
||||
on:input=move |ev| {
|
||||
if let Ok(v) = event_target_value(&ev).parse() {
|
||||
set_height.set(v);
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="dimension_mode" class="form-label">"Dimension Mode"</label>
|
||||
<select
|
||||
id="dimension_mode"
|
||||
class="form-select"
|
||||
on:change=move |ev| set_dimension_mode.set(event_target_value(&ev))
|
||||
>
|
||||
<option value="fixed" selected=move || dimension_mode.get() == "fixed">"Fixed"</option>
|
||||
<option value="viewport" selected=move || dimension_mode.get() == "viewport">"Viewport"</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Button to set dimensions from existing background image
|
||||
{if let Some(ref path) = scene_background_image_path_for_dimensions {
|
||||
let path_for_closure = path.clone();
|
||||
view! {
|
||||
<div class="form-group">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
disabled=move || fetching_dimensions.get()
|
||||
on:click=move |_| {
|
||||
set_fetching_dimensions.set(true);
|
||||
set_dimension_message.set(None);
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
let path = path_for_closure.clone();
|
||||
fetch_image_dimensions_client(
|
||||
path,
|
||||
move |w, h| {
|
||||
set_width.set(w as i32);
|
||||
set_height.set(h as i32);
|
||||
set_dimension_message.set(Some((
|
||||
format!("Set from image: {}x{}", w, h),
|
||||
true,
|
||||
)));
|
||||
},
|
||||
move |err| {
|
||||
set_dimension_message.set(Some((err, false)));
|
||||
},
|
||||
set_fetching_dimensions,
|
||||
);
|
||||
}
|
||||
}
|
||||
>
|
||||
{move || if fetching_dimensions.get() { "Fetching..." } else { "Set from background image" }}
|
||||
</button>
|
||||
<small class="form-help">"Set dimensions to match the current background image"</small>
|
||||
</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>
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue