add initial crates and apps
This commit is contained in:
parent
5c87ba3519
commit
1ca300098f
113 changed files with 28169 additions and 0 deletions
38
crates/chattyness-user-ui/src/components/chat.rs
Normal file
38
crates/chattyness-user-ui/src/components/chat.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
//! Chat components for realm chat interface.
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// Chat input component (placeholder UI).
|
||||
///
|
||||
/// Displays a text input field for typing messages.
|
||||
/// Currently non-functional - just UI placeholder.
|
||||
#[component]
|
||||
pub fn ChatInput() -> impl IntoView {
|
||||
let (message, set_message) = signal(String::new());
|
||||
|
||||
view! {
|
||||
<div class="chat-input-container w-full max-w-4xl mx-auto">
|
||||
<div class="flex gap-2 items-center bg-gray-900/80 backdrop-blur-sm rounded-lg p-2 border border-gray-600">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type a message..."
|
||||
class="flex-1 bg-transparent text-white placeholder-gray-500 px-3 py-2 outline-none"
|
||||
prop:value=move || message.get()
|
||||
on:input=move |ev| {
|
||||
set_message.set(event_target_value(&ev));
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled=move || message.get().trim().is_empty()
|
||||
>
|
||||
"Send"
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-gray-500 text-xs mt-2 text-center">
|
||||
"Chat functionality coming soon"
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
357
crates/chattyness-user-ui/src/components/editor.rs
Normal file
357
crates/chattyness-user-ui/src/components/editor.rs
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
//! Scene editor components.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
use chattyness_db::models::SpotSummary;
|
||||
|
||||
/// Drawing mode for spot editor.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DrawingMode {
|
||||
#[default]
|
||||
Select,
|
||||
Polygon,
|
||||
Rectangle,
|
||||
}
|
||||
|
||||
/// Toolbar for selecting drawing mode.
|
||||
#[component]
|
||||
pub fn DrawingModeToolbar(
|
||||
#[prop(into)] mode: Signal<DrawingMode>,
|
||||
on_change: Callback<DrawingMode>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="flex gap-2" role="radiogroup" aria-label="Drawing mode">
|
||||
<button
|
||||
type="button"
|
||||
class=move || {
|
||||
let base = "px-3 py-1 rounded text-sm transition-colors";
|
||||
if mode.get() == DrawingMode::Select {
|
||||
format!("{} bg-blue-600 text-white", base)
|
||||
} else {
|
||||
format!("{} bg-gray-700 text-gray-300 hover:bg-gray-600", base)
|
||||
}
|
||||
}
|
||||
on:click=move |_| on_change.run(DrawingMode::Select)
|
||||
aria-pressed=move || mode.get() == DrawingMode::Select
|
||||
>
|
||||
"Select"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class=move || {
|
||||
let base = "px-3 py-1 rounded text-sm transition-colors";
|
||||
if mode.get() == DrawingMode::Rectangle {
|
||||
format!("{} bg-blue-600 text-white", base)
|
||||
} else {
|
||||
format!("{} bg-gray-700 text-gray-300 hover:bg-gray-600", base)
|
||||
}
|
||||
}
|
||||
on:click=move |_| on_change.run(DrawingMode::Rectangle)
|
||||
aria-pressed=move || mode.get() == DrawingMode::Rectangle
|
||||
>
|
||||
"Rectangle"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class=move || {
|
||||
let base = "px-3 py-1 rounded text-sm transition-colors";
|
||||
if mode.get() == DrawingMode::Polygon {
|
||||
format!("{} bg-blue-600 text-white", base)
|
||||
} else {
|
||||
format!("{} bg-gray-700 text-gray-300 hover:bg-gray-600", base)
|
||||
}
|
||||
}
|
||||
on:click=move |_| on_change.run(DrawingMode::Polygon)
|
||||
aria-pressed=move || mode.get() == DrawingMode::Polygon
|
||||
>
|
||||
"Polygon"
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Canvas for displaying scene with spots.
|
||||
#[component]
|
||||
pub fn SceneCanvas(
|
||||
#[prop(into)] width: Signal<u32>,
|
||||
#[prop(into)] height: Signal<u32>,
|
||||
#[prop(into)] background_color: Signal<Option<String>>,
|
||||
#[prop(into)] background_image: Signal<Option<String>>,
|
||||
#[prop(into)] spots: Signal<Vec<SpotSummary>>,
|
||||
#[prop(into)] selected_spot_id: Signal<Option<Uuid>>,
|
||||
on_spot_click: Callback<Uuid>,
|
||||
) -> impl IntoView {
|
||||
let canvas_style = Signal::derive(move || {
|
||||
let w = width.get();
|
||||
let h = height.get();
|
||||
let bg_color = background_color.get().unwrap_or_else(|| "#1a1a2e".to_string());
|
||||
|
||||
if let Some(img) = background_image.get() {
|
||||
format!(
|
||||
"width: {}px; height: {}px; background-image: url('{}'); background-size: cover; background-position: center;",
|
||||
w, h, img
|
||||
)
|
||||
} else {
|
||||
format!("width: {}px; height: {}px; background-color: {};", w, h, bg_color)
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="relative overflow-auto border border-gray-700 rounded-lg bg-gray-900">
|
||||
<div class="relative" style=move || canvas_style.get()>
|
||||
{move || {
|
||||
spots
|
||||
.get()
|
||||
.into_iter()
|
||||
.map(|spot| {
|
||||
let spot_id = spot.id;
|
||||
let is_selected = selected_spot_id.get() == Some(spot_id);
|
||||
let style = parse_wkt_to_style(&spot.region_wkt);
|
||||
view! {
|
||||
<div
|
||||
class=move || {
|
||||
let base = "absolute border-2 cursor-pointer transition-colors";
|
||||
if is_selected {
|
||||
format!("{} border-blue-500 bg-blue-500/30", base)
|
||||
} else {
|
||||
format!("{} border-green-500/50 bg-green-500/20 hover:bg-green-500/30", base)
|
||||
}
|
||||
}
|
||||
style=style
|
||||
on:click=move |_| on_spot_click.run(spot_id)
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
}
|
||||
})
|
||||
.collect_view()
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Canvas for drawing new spots.
|
||||
#[component]
|
||||
#[allow(unused_variables)]
|
||||
pub fn SpotDrawer(
|
||||
#[prop(into)] width: Signal<u32>,
|
||||
#[prop(into)] height: Signal<u32>,
|
||||
#[prop(into)] mode: Signal<DrawingMode>,
|
||||
on_complete: Callback<String>,
|
||||
#[prop(into)] background_color: Signal<Option<String>>,
|
||||
#[prop(into)] background_image: Signal<Option<String>>,
|
||||
#[prop(into)] existing_spots_wkt: Signal<Vec<String>>,
|
||||
) -> impl IntoView {
|
||||
let (drawing_points, _set_drawing_points) = signal(Vec::<(f64, f64)>::new());
|
||||
let (is_drawing, _set_is_drawing) = signal(false);
|
||||
let (start_point, _set_start_point) = signal(Option::<(f64, f64)>::None);
|
||||
#[cfg(feature = "hydrate")]
|
||||
let (set_drawing_points, set_is_drawing, set_start_point) =
|
||||
(_set_drawing_points, _set_is_drawing, _set_start_point);
|
||||
|
||||
let canvas_style = Signal::derive(move || {
|
||||
let w = width.get();
|
||||
let h = height.get();
|
||||
let bg_color = background_color.get().unwrap_or_else(|| "#1a1a2e".to_string());
|
||||
|
||||
if let Some(img) = background_image.get() {
|
||||
format!(
|
||||
"width: {}px; height: {}px; background-image: url('{}'); background-size: cover; background-position: center; cursor: crosshair;",
|
||||
w, h, img
|
||||
)
|
||||
} else {
|
||||
format!("width: {}px; height: {}px; background-color: {}; cursor: crosshair;", w, h, bg_color)
|
||||
}
|
||||
});
|
||||
|
||||
let on_mouse_down = move |ev: leptos::ev::MouseEvent| {
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
let rect = ev
|
||||
.target()
|
||||
.and_then(|t| {
|
||||
use wasm_bindgen::JsCast;
|
||||
t.dyn_ref::<web_sys::HtmlElement>()
|
||||
.map(|el| el.get_bounding_client_rect())
|
||||
});
|
||||
|
||||
if let Some(rect) = rect {
|
||||
let x = ev.client_x() as f64 - rect.left();
|
||||
let y = ev.client_y() as f64 - rect.top();
|
||||
|
||||
match mode.get() {
|
||||
DrawingMode::Rectangle => {
|
||||
set_start_point.set(Some((x, y)));
|
||||
set_is_drawing.set(true);
|
||||
}
|
||||
DrawingMode::Polygon => {
|
||||
let mut points = drawing_points.get();
|
||||
points.push((x, y));
|
||||
set_drawing_points.set(points);
|
||||
}
|
||||
DrawingMode::Select => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let on_mouse_up = move |ev: leptos::ev::MouseEvent| {
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
if mode.get() == DrawingMode::Rectangle && is_drawing.get() {
|
||||
if let Some((start_x, start_y)) = start_point.get() {
|
||||
let rect = ev
|
||||
.target()
|
||||
.and_then(|t| {
|
||||
use wasm_bindgen::JsCast;
|
||||
t.dyn_ref::<web_sys::HtmlElement>()
|
||||
.map(|el| el.get_bounding_client_rect())
|
||||
});
|
||||
|
||||
if let Some(rect) = rect {
|
||||
let end_x = ev.client_x() as f64 - rect.left();
|
||||
let end_y = ev.client_y() as f64 - rect.top();
|
||||
|
||||
let min_x = start_x.min(end_x);
|
||||
let min_y = start_y.min(end_y);
|
||||
let max_x = start_x.max(end_x);
|
||||
let max_y = start_y.max(end_y);
|
||||
|
||||
if (max_x - min_x) > 10.0 && (max_y - min_y) > 10.0 {
|
||||
let wkt = format!(
|
||||
"POLYGON(({} {}, {} {}, {} {}, {} {}, {} {}))",
|
||||
min_x, min_y, max_x, min_y, max_x, max_y, min_x, max_y, min_x, min_y
|
||||
);
|
||||
on_complete.run(wkt);
|
||||
}
|
||||
}
|
||||
}
|
||||
set_is_drawing.set(false);
|
||||
set_start_point.set(None);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let on_double_click = move |_| {
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
if mode.get() == DrawingMode::Polygon {
|
||||
let points = drawing_points.get();
|
||||
if points.len() >= 3 {
|
||||
let wkt = points_to_wkt(&points);
|
||||
on_complete.run(wkt);
|
||||
}
|
||||
set_drawing_points.set(Vec::new());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="relative overflow-auto border border-gray-700 rounded-lg bg-gray-900">
|
||||
<div
|
||||
class="relative"
|
||||
style=move || canvas_style.get()
|
||||
on:mousedown=on_mouse_down
|
||||
on:mouseup=on_mouse_up
|
||||
on:dblclick=on_double_click
|
||||
>
|
||||
// Render existing spots
|
||||
{move || {
|
||||
existing_spots_wkt
|
||||
.get()
|
||||
.into_iter()
|
||||
.map(|wkt| {
|
||||
let style = parse_wkt_to_style(&wkt);
|
||||
view! {
|
||||
<div
|
||||
class="absolute border-2 border-gray-500/50 bg-gray-500/20"
|
||||
style=style
|
||||
/>
|
||||
}
|
||||
})
|
||||
.collect_view()
|
||||
}}
|
||||
|
||||
// Render drawing preview
|
||||
{move || {
|
||||
let points = drawing_points.get();
|
||||
if !points.is_empty() && mode.get() == DrawingMode::Polygon {
|
||||
let svg_points: String = points
|
||||
.iter()
|
||||
.map(|(x, y)| format!("{},{}", x, y))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
Some(view! {
|
||||
<svg class="absolute inset-0 pointer-events-none">
|
||||
<polyline
|
||||
points=svg_points
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse WKT polygon to CSS positioning style.
|
||||
fn parse_wkt_to_style(wkt: &str) -> String {
|
||||
let trimmed = wkt.trim();
|
||||
if let Some(coords_str) = trimmed
|
||||
.strip_prefix("POLYGON((")
|
||||
.and_then(|s| s.strip_suffix("))"))
|
||||
{
|
||||
let points: Vec<(f64, f64)> = coords_str
|
||||
.split(',')
|
||||
.filter_map(|p| {
|
||||
let coords: Vec<&str> = p.trim().split_whitespace().collect();
|
||||
if coords.len() >= 2 {
|
||||
Some((coords[0].parse().ok()?, coords[1].parse().ok()?))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if points.len() >= 4 {
|
||||
let min_x = points.iter().map(|(x, _)| *x).fold(f64::INFINITY, f64::min);
|
||||
let min_y = points.iter().map(|(_, y)| *y).fold(f64::INFINITY, f64::min);
|
||||
let max_x = points.iter().map(|(x, _)| *x).fold(f64::NEG_INFINITY, f64::max);
|
||||
let max_y = points.iter().map(|(_, y)| *y).fold(f64::NEG_INFINITY, f64::max);
|
||||
|
||||
return format!(
|
||||
"left: {}px; top: {}px; width: {}px; height: {}px;",
|
||||
min_x,
|
||||
min_y,
|
||||
max_x - min_x,
|
||||
max_y - min_y
|
||||
);
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Convert points to WKT polygon.
|
||||
#[allow(dead_code)]
|
||||
fn points_to_wkt(points: &[(f64, f64)]) -> String {
|
||||
if points.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let coords: String = points
|
||||
.iter()
|
||||
.chain(std::iter::once(&points[0]))
|
||||
.map(|(x, y)| format!("{} {}", x, y))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
format!("POLYGON(({})", coords)
|
||||
}
|
||||
345
crates/chattyness-user-ui/src/components/forms.rs
Normal file
345
crates/chattyness-user-ui/src/components/forms.rs
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
//! Form components with WCAG 2.2 AA accessibility.
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// Text input field with label.
|
||||
#[component]
|
||||
pub fn TextInput(
|
||||
name: &'static str,
|
||||
label: &'static str,
|
||||
#[prop(default = "text")] input_type: &'static str,
|
||||
#[prop(optional)] placeholder: &'static str,
|
||||
#[prop(optional)] help_text: &'static str,
|
||||
#[prop(default = false)] required: bool,
|
||||
#[prop(optional)] minlength: Option<i32>,
|
||||
#[prop(optional)] maxlength: Option<i32>,
|
||||
#[prop(optional)] pattern: &'static str,
|
||||
#[prop(optional)] class: &'static str,
|
||||
#[prop(into)] value: Signal<String>,
|
||||
on_input: Callback<String>,
|
||||
) -> impl IntoView {
|
||||
let input_id = name;
|
||||
let help_id = format!("{}-help", name);
|
||||
let has_help = !help_text.is_empty();
|
||||
|
||||
view! {
|
||||
<div class=format!("space-y-2 {}", class)>
|
||||
<label for=input_id class="block text-sm font-medium text-gray-300">
|
||||
{label}
|
||||
{if required {
|
||||
view! { <span class="text-red-400 ml-1" aria-hidden="true">"*"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="text-gray-500 ml-1">"(optional)"</span> }.into_any()
|
||||
}}
|
||||
</label>
|
||||
<input
|
||||
type=input_type
|
||||
id=input_id
|
||||
name=name
|
||||
placeholder=placeholder
|
||||
required=required
|
||||
minlength=minlength
|
||||
maxlength=maxlength
|
||||
pattern=if pattern.is_empty() { None } else { Some(pattern) }
|
||||
aria-describedby=if has_help { Some(help_id.clone()) } else { None }
|
||||
class="input-base"
|
||||
prop:value=move || value.get()
|
||||
on:input=move |ev| on_input.run(event_target_value(&ev))
|
||||
/>
|
||||
{if has_help {
|
||||
view! { <p id=help_id class="text-sm text-gray-400">{help_text}</p> }.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Textarea field with label.
|
||||
#[component]
|
||||
pub fn TextArea(
|
||||
name: &'static str,
|
||||
label: &'static str,
|
||||
#[prop(optional)] placeholder: &'static str,
|
||||
#[prop(optional)] help_text: &'static str,
|
||||
#[prop(default = false)] required: bool,
|
||||
#[prop(default = 3)] rows: i32,
|
||||
#[prop(optional)] class: &'static str,
|
||||
#[prop(into)] value: Signal<String>,
|
||||
on_input: Callback<String>,
|
||||
) -> impl IntoView {
|
||||
let input_id = name;
|
||||
let help_id = format!("{}-help", name);
|
||||
let has_help = !help_text.is_empty();
|
||||
|
||||
view! {
|
||||
<div class=format!("space-y-2 {}", class)>
|
||||
<label for=input_id class="block text-sm font-medium text-gray-300">
|
||||
{label}
|
||||
{if required {
|
||||
view! { <span class="text-red-400 ml-1" aria-hidden="true">"*"</span> }.into_any()
|
||||
} else {
|
||||
view! { <span class="text-gray-500 ml-1">"(optional)"</span> }.into_any()
|
||||
}}
|
||||
</label>
|
||||
<textarea
|
||||
id=input_id
|
||||
name=name
|
||||
placeholder=placeholder
|
||||
required=required
|
||||
rows=rows
|
||||
aria-describedby=if has_help { Some(help_id.clone()) } else { None }
|
||||
class="input-base resize-y"
|
||||
prop:value=move || value.get()
|
||||
on:input=move |ev| on_input.run(event_target_value(&ev))
|
||||
/>
|
||||
{if has_help {
|
||||
view! { <p id=help_id class="text-sm text-gray-400">{help_text}</p> }.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Radio button group.
|
||||
#[component]
|
||||
pub fn RadioGroup(
|
||||
name: &'static str,
|
||||
legend: &'static str,
|
||||
options: Vec<(&'static str, &'static str, &'static str)>,
|
||||
#[prop(into)] value: Signal<String>,
|
||||
on_change: Callback<String>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<fieldset class="space-y-3">
|
||||
<legend class="block text-sm font-medium text-gray-300 mb-2">{legend}</legend>
|
||||
<div class="space-y-2">
|
||||
{options
|
||||
.into_iter()
|
||||
.map(|(val, label, description)| {
|
||||
let val_clone = val.to_string();
|
||||
let is_selected = Signal::derive(move || value.get() == val);
|
||||
view! {
|
||||
<label class="flex items-start space-x-3 cursor-pointer group">
|
||||
<input
|
||||
type="radio"
|
||||
name=name
|
||||
value=val
|
||||
checked=move || is_selected.get()
|
||||
on:change=move |_| on_change.run(val_clone.clone())
|
||||
class="mt-1 w-4 h-4 text-blue-500 bg-gray-700 border-gray-600 focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-white group-hover:text-blue-400 transition-colors">
|
||||
{label}
|
||||
</span>
|
||||
<p class="text-sm text-gray-400">{description}</p>
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
})
|
||||
.collect_view()}
|
||||
</div>
|
||||
</fieldset>
|
||||
}
|
||||
}
|
||||
|
||||
/// Checkbox input.
|
||||
#[component]
|
||||
pub fn Checkbox(
|
||||
name: &'static str,
|
||||
label: &'static str,
|
||||
#[prop(optional)] description: &'static str,
|
||||
#[prop(into)] checked: Signal<bool>,
|
||||
on_change: Callback<bool>,
|
||||
) -> impl IntoView {
|
||||
let has_description = !description.is_empty();
|
||||
|
||||
view! {
|
||||
<label class="flex items-start space-x-3 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
name=name
|
||||
prop:checked=move || checked.get()
|
||||
on:change=move |ev| on_change.run(event_target_checked(&ev))
|
||||
class="mt-1 w-4 h-4 text-blue-500 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-white group-hover:text-blue-400 transition-colors">{label}</span>
|
||||
{if has_description {
|
||||
view! { <p class="text-sm text-gray-400">{description}</p> }.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
|
||||
/// Range slider input.
|
||||
#[component]
|
||||
pub fn RangeSlider(
|
||||
name: &'static str,
|
||||
label: &'static str,
|
||||
min: i32,
|
||||
max: i32,
|
||||
#[prop(default = 1)] step: i32,
|
||||
#[prop(into)] value: Signal<i32>,
|
||||
on_change: Callback<i32>,
|
||||
) -> impl IntoView {
|
||||
let input_id = name;
|
||||
|
||||
view! {
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<label for=input_id class="block text-sm font-medium text-gray-300">
|
||||
{label}
|
||||
</label>
|
||||
<span class="text-sm text-gray-400">{move || value.get()}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
id=input_id
|
||||
name=name
|
||||
min=min
|
||||
max=max
|
||||
step=step
|
||||
prop:value=move || value.get()
|
||||
on:input=move |ev| {
|
||||
if let Ok(val) = event_target_value(&ev).parse::<i32>() {
|
||||
on_change.run(val);
|
||||
}
|
||||
}
|
||||
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-gray-500">
|
||||
<span>{min}</span>
|
||||
<span>{max}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Primary submit button.
|
||||
#[component]
|
||||
pub fn SubmitButton(
|
||||
#[prop(default = "Submit")] text: &'static str,
|
||||
#[prop(default = "Submitting...")] loading_text: &'static str,
|
||||
#[prop(into)] pending: Signal<bool>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<button type="submit" disabled=move || pending.get() class="btn-primary w-full">
|
||||
{move || if pending.get() { loading_text } else { text }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
/// Error alert box.
|
||||
#[component]
|
||||
pub fn ErrorAlert(#[prop(into)] message: Signal<Option<String>>) -> impl IntoView {
|
||||
view! {
|
||||
<Show when=move || message.get().is_some()>
|
||||
<div class="error-message" role="alert">
|
||||
<p>{move || message.get().unwrap_or_default()}</p>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
|
||||
/// Success alert box.
|
||||
#[component]
|
||||
pub fn SuccessAlert(#[prop(into)] message: Signal<Option<String>>) -> impl IntoView {
|
||||
view! {
|
||||
<Show when=move || message.get().is_some()>
|
||||
<div
|
||||
class="p-4 bg-green-900/50 border border-green-500 rounded-lg text-green-200"
|
||||
role="alert"
|
||||
>
|
||||
<p>{move || message.get().unwrap_or_default()}</p>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
|
||||
/// Color picker component.
|
||||
#[component]
|
||||
pub fn ColorPicker(
|
||||
#[prop(into)] value: Signal<String>,
|
||||
on_change: Callback<String>,
|
||||
label: &'static str,
|
||||
id: &'static str,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="flex items-center gap-3">
|
||||
<label for=id class="text-sm font-medium text-gray-300">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
id=id
|
||||
prop:value=move || value.get()
|
||||
on:input=move |ev| on_change.run(event_target_value(&ev))
|
||||
class="w-10 h-10 rounded border border-gray-600 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
prop:value=move || value.get()
|
||||
on:input=move |ev| on_change.run(event_target_value(&ev))
|
||||
class="input-base w-24 text-sm"
|
||||
placeholder="#1a1a2e"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Color palette component.
|
||||
#[component]
|
||||
pub fn ColorPalette(#[prop(into)] value: Signal<String>, on_change: Callback<String>) -> impl IntoView {
|
||||
let colors = [
|
||||
"#1a1a2e", "#16213e", "#0f3460", "#e94560", "#533483", "#2c3e50", "#1e8449", "#d35400",
|
||||
];
|
||||
|
||||
view! {
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{colors
|
||||
.into_iter()
|
||||
.map(|color| {
|
||||
let is_selected = Signal::derive(move || value.get() == color);
|
||||
let color_string = color.to_string();
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class=move || {
|
||||
let base = "w-8 h-8 rounded border-2 transition-transform hover:scale-110";
|
||||
if is_selected.get() {
|
||||
format!("{} border-white", base)
|
||||
} else {
|
||||
format!("{} border-transparent", base)
|
||||
}
|
||||
}
|
||||
style=format!("background-color: {}", color)
|
||||
title=color
|
||||
on:click=move |_| on_change.run(color_string.clone())
|
||||
/>
|
||||
}
|
||||
})
|
||||
.collect_view()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the checked state of a checkbox input.
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn event_target_checked(ev: &leptos::ev::Event) -> bool {
|
||||
use wasm_bindgen::JsCast;
|
||||
ev.target()
|
||||
.and_then(|t| t.dyn_ref::<web_sys::HtmlInputElement>().map(|el| el.checked()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Stub for SSR.
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
fn event_target_checked(_ev: &leptos::ev::Event) -> bool {
|
||||
false
|
||||
}
|
||||
214
crates/chattyness-user-ui/src/components/layout.rs
Normal file
214
crates/chattyness-user-ui/src/components/layout.rs
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
//! Layout components.
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// Main page layout wrapper.
|
||||
#[component]
|
||||
pub fn PageLayout(children: Children) -> impl IntoView {
|
||||
view! {
|
||||
<div class="min-h-screen bg-gray-900 text-white flex flex-col overflow-x-hidden">
|
||||
<Header />
|
||||
<main class="flex-1">{children()}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple site header for non-realm pages.
|
||||
#[component]
|
||||
pub fn Header() -> impl IntoView {
|
||||
view! {
|
||||
<header class="bg-gray-800 border-b border-gray-700">
|
||||
<nav class="px-4" aria-label="Main navigation">
|
||||
<div class="flex items-center h-16">
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center space-x-2 text-xl font-bold text-white hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<img src="/icons/castle.svg" alt="" class="w-6 h-6" aria-hidden="true" />
|
||||
<span>"Chattyness"</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
}
|
||||
}
|
||||
|
||||
/// Realm-specific header with realm/scene info and user actions.
|
||||
#[component]
|
||||
pub fn RealmHeader(
|
||||
realm_name: String,
|
||||
realm_slug: String,
|
||||
realm_description: Option<String>,
|
||||
scene_name: String,
|
||||
scene_description: Option<String>,
|
||||
online_count: i32,
|
||||
total_members: i32,
|
||||
max_capacity: i32,
|
||||
can_admin: bool,
|
||||
on_logout: Callback<()>,
|
||||
) -> impl IntoView {
|
||||
let stats_tooltip = format!("Members: {} / Max: {}", total_members, max_capacity);
|
||||
let online_text = format!("{} ONLINE", online_count);
|
||||
let admin_url = format!("/admin/realms/{}", realm_slug);
|
||||
|
||||
view! {
|
||||
<header class="bg-gray-800 border-b border-gray-700">
|
||||
<div class="flex items-center justify-between h-16 px-4">
|
||||
// Left side: Logo + Realm/Scene info
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center space-x-2 text-xl font-bold text-white hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<img src="/icons/castle.svg" alt="" class="w-6 h-6" aria-hidden="true" />
|
||||
<span>"Chattyness"</span>
|
||||
</a>
|
||||
<span class="text-gray-500">"|"</span>
|
||||
<span
|
||||
class="text-white font-medium cursor-default"
|
||||
title=realm_description.unwrap_or_default()
|
||||
>
|
||||
{realm_name}
|
||||
</span>
|
||||
<span class="text-gray-500">"/"</span>
|
||||
<span
|
||||
class="text-gray-300 cursor-default"
|
||||
title=scene_description.unwrap_or_default()
|
||||
>
|
||||
{scene_name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
// Right side: Stats + Actions
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-green-400 font-medium cursor-default" title=stats_tooltip>
|
||||
{online_text}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
on:click=move |_| on_logout.run(())
|
||||
>
|
||||
"Logout"
|
||||
</button>
|
||||
{can_admin.then(|| {
|
||||
view! {
|
||||
<a
|
||||
href=admin_url
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
"Admin"
|
||||
</a>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
}
|
||||
|
||||
/// Site footer.
|
||||
#[component]
|
||||
pub fn Footer() -> impl IntoView {
|
||||
view! {
|
||||
<footer class="bg-gray-800 border-t border-gray-700 py-8">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col md:flex-row items-center justify-between">
|
||||
<p class="text-gray-400 text-sm">
|
||||
"Built with "
|
||||
<a
|
||||
href="https://leptos.dev"
|
||||
class="text-blue-400 hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
"Leptos"
|
||||
</a>
|
||||
" and "
|
||||
<a
|
||||
href="https://www.rust-lang.org"
|
||||
class="text-blue-400 hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
"Rust"
|
||||
</a>
|
||||
"."
|
||||
</p>
|
||||
<nav class="flex items-center space-x-4 mt-4 md:mt-0" aria-label="Footer navigation">
|
||||
<a
|
||||
href="/about"
|
||||
class="text-gray-400 hover:text-white text-sm transition-colors"
|
||||
>
|
||||
"About"
|
||||
</a>
|
||||
<a
|
||||
href="/privacy"
|
||||
class="text-gray-400 hover:text-white text-sm transition-colors"
|
||||
>
|
||||
"Privacy"
|
||||
</a>
|
||||
<a
|
||||
href="/terms"
|
||||
class="text-gray-400 hover:text-white text-sm transition-colors"
|
||||
>
|
||||
"Terms"
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple centered layout for forms and dialogs.
|
||||
#[component]
|
||||
pub fn CenteredLayout(children: Children) -> impl IntoView {
|
||||
view! {
|
||||
<div class="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
||||
{children()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Card container.
|
||||
#[component]
|
||||
pub fn Card(#[prop(optional)] class: &'static str, children: Children) -> impl IntoView {
|
||||
let base_class = "bg-gray-800 rounded-lg shadow-xl";
|
||||
let combined_class = if class.is_empty() {
|
||||
base_class.to_string()
|
||||
} else {
|
||||
format!("{} {}", base_class, class)
|
||||
};
|
||||
|
||||
view! { <div class=combined_class>{children()}</div> }
|
||||
}
|
||||
|
||||
/// Scene thumbnail component.
|
||||
#[component]
|
||||
pub fn SceneThumbnail(scene: chattyness_db::models::SceneSummary) -> impl IntoView {
|
||||
let background_style = match (&scene.background_image_path, &scene.background_color) {
|
||||
(Some(path), _) => format!("background-image: url('{}'); background-size: cover; background-position: center;", path),
|
||||
(None, Some(color)) => format!("background-color: {};", color),
|
||||
(None, None) => "background-color: #1a1a2e;".to_string(),
|
||||
};
|
||||
|
||||
view! {
|
||||
<Card class="overflow-hidden">
|
||||
<div class="h-32 w-full" style=background_style></div>
|
||||
<div class="p-4">
|
||||
<h3 class="text-lg font-semibold text-white">{scene.name}</h3>
|
||||
<p class="text-gray-400 text-sm">"/" {scene.slug}</p>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
{scene.is_entry_point.then(|| view! {
|
||||
<span class="text-xs px-2 py-0.5 bg-green-600 rounded">"Entry"</span>
|
||||
})}
|
||||
{scene.is_hidden.then(|| view! {
|
||||
<span class="text-xs px-2 py-0.5 bg-gray-600 rounded">"Hidden"</span>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
200
crates/chattyness-user-ui/src/components/modals.rs
Normal file
200
crates/chattyness-user-ui/src/components/modals.rs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
//! Modal components with WCAG 2.2 AA accessibility.
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// Confirmation modal for joining a realm.
|
||||
#[component]
|
||||
pub fn JoinRealmModal(
|
||||
#[prop(into)] open: Signal<bool>,
|
||||
realm_name: String,
|
||||
realm_slug: String,
|
||||
#[prop(into)] pending: Signal<bool>,
|
||||
on_confirm: Callback<()>,
|
||||
on_cancel: Callback<()>,
|
||||
) -> impl IntoView {
|
||||
let on_cancel_backdrop = on_cancel.clone();
|
||||
let on_cancel_close = on_cancel.clone();
|
||||
let on_cancel_button = on_cancel.clone();
|
||||
|
||||
let (name_sig, _) = signal(realm_name);
|
||||
let (slug_sig, _) = signal(realm_slug);
|
||||
|
||||
view! {
|
||||
<Show when=move || open.get()>
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="join-modal-title"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
on:click=move |_| on_cancel_backdrop.run(())
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||
on:click=move |_| on_cancel_close.run(())
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="mx-auto w-16 h-16 rounded-full bg-blue-600/20 flex items-center justify-center mb-4">
|
||||
<img
|
||||
src="/icons/castle.svg"
|
||||
alt=""
|
||||
class="w-8 h-8"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 id="join-modal-title" class="text-xl font-bold text-white mb-2">
|
||||
"Join " {move || name_sig.get()} "?"
|
||||
</h3>
|
||||
|
||||
<p class="text-gray-400 mb-6">
|
||||
"You're not a member of "
|
||||
<span class="text-blue-400 font-medium">{move || slug_sig.get()}</span>
|
||||
" yet. Would you like to join this realm?"
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary px-6 py-2"
|
||||
on:click=move |_| on_cancel_button.run(())
|
||||
disabled=move || pending.get()
|
||||
>
|
||||
"Cancel"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary px-6 py-2"
|
||||
on:click=move |_| on_confirm.run(())
|
||||
disabled=move || pending.get()
|
||||
>
|
||||
{move || if pending.get() { "Joining..." } else { "Join Realm" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500 mt-4">
|
||||
"You can leave a realm at any time from your profile settings."
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirmation modal for general actions.
|
||||
#[component]
|
||||
pub fn ConfirmModal(
|
||||
#[prop(into)] open: Signal<bool>,
|
||||
title: &'static str,
|
||||
message: String,
|
||||
#[prop(default = "Confirm")] confirm_text: &'static str,
|
||||
#[prop(default = "Cancel")] cancel_text: &'static str,
|
||||
#[prop(default = false)] destructive: bool,
|
||||
#[prop(into)] pending: Signal<bool>,
|
||||
on_confirm: Callback<()>,
|
||||
on_cancel: Callback<()>,
|
||||
) -> impl IntoView {
|
||||
let on_cancel_backdrop = on_cancel.clone();
|
||||
let on_cancel_close = on_cancel.clone();
|
||||
let on_cancel_button = on_cancel.clone();
|
||||
|
||||
let (message_sig, _) = signal(message);
|
||||
|
||||
let confirm_class = if destructive {
|
||||
"bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
} else {
|
||||
"btn-primary px-6 py-2"
|
||||
};
|
||||
|
||||
view! {
|
||||
<Show when=move || open.get()>
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-modal-title"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
on:click=move |_| on_cancel_backdrop.run(())
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-md w-full mx-4 p-6 border border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||
on:click=move |_| on_cancel_close.run(())
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<h3 id="confirm-modal-title" class="text-xl font-bold text-white mb-4">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<p class="text-gray-400 mb-6">{move || message_sig.get()}</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary px-6 py-2"
|
||||
on:click=move |_| on_cancel_button.run(())
|
||||
disabled=move || pending.get()
|
||||
>
|
||||
{cancel_text}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class=confirm_class
|
||||
on:click=move |_| on_confirm.run(())
|
||||
disabled=move || pending.get()
|
||||
>
|
||||
{move || if pending.get() { "Please wait..." } else { confirm_text }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
421
crates/chattyness-user-ui/src/components/scene_viewer.rs
Normal file
421
crates/chattyness-user-ui/src/components/scene_viewer.rs
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
//! Scene viewer component for displaying realm scenes with avatars.
|
||||
//!
|
||||
//! Uses layered canvases for efficient rendering:
|
||||
//! - Background canvas: Static, drawn once when scene loads
|
||||
//! - Avatar canvas: Dynamic, redrawn when members change
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
use chattyness_db::models::{ChannelMemberWithAvatar, Scene};
|
||||
|
||||
/// Parse bounds WKT to extract width and height.
|
||||
///
|
||||
/// Expected format: "POLYGON((0 0, WIDTH 0, WIDTH HEIGHT, 0 HEIGHT, 0 0))"
|
||||
fn parse_bounds_dimensions(bounds_wkt: &str) -> Option<(u32, u32)> {
|
||||
let trimmed = bounds_wkt.trim();
|
||||
let coords_str = trimmed
|
||||
.strip_prefix("POLYGON((")
|
||||
.and_then(|s| s.strip_suffix("))"))?;
|
||||
|
||||
let points: Vec<&str> = coords_str.split(',').collect();
|
||||
if points.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut max_x: f64 = 0.0;
|
||||
let mut max_y: f64 = 0.0;
|
||||
|
||||
for point in points.iter() {
|
||||
let coords: Vec<&str> = point.trim().split_whitespace().collect();
|
||||
if coords.len() >= 2 {
|
||||
if let (Ok(x), Ok(y)) = (coords[0].parse::<f64>(), coords[1].parse::<f64>()) {
|
||||
if x > max_x {
|
||||
max_x = x;
|
||||
}
|
||||
if y > max_y {
|
||||
max_y = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if max_x > 0.0 && max_y > 0.0 {
|
||||
Some((max_x as u32, max_y as u32))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Scene viewer component for displaying a realm scene with avatars.
|
||||
///
|
||||
/// Uses two layered canvases:
|
||||
/// - Background canvas (z-index 0): Static background, drawn once
|
||||
/// - Avatar canvas (z-index 1): Transparent, redrawn on member updates
|
||||
#[component]
|
||||
pub fn RealmSceneViewer(
|
||||
scene: Scene,
|
||||
#[allow(unused)]
|
||||
realm_slug: String,
|
||||
#[prop(into)]
|
||||
members: Signal<Vec<ChannelMemberWithAvatar>>,
|
||||
#[prop(into)]
|
||||
on_move: Callback<(f64, f64)>,
|
||||
) -> impl IntoView {
|
||||
let dimensions = parse_bounds_dimensions(&scene.bounds_wkt);
|
||||
let (scene_width, scene_height) = dimensions.unwrap_or((800, 600));
|
||||
|
||||
let bg_color = scene
|
||||
.background_color
|
||||
.clone()
|
||||
.unwrap_or_else(|| "#1a1a2e".to_string());
|
||||
|
||||
#[allow(unused_variables)]
|
||||
let has_background_image = scene.background_image_path.is_some();
|
||||
#[allow(unused_variables)]
|
||||
let image_path = scene.background_image_path.clone().unwrap_or_default();
|
||||
|
||||
// Two separate canvas refs for layered rendering
|
||||
let bg_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||
let avatar_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||
|
||||
// Store scale factors for coordinate conversion (shared between both canvases)
|
||||
let scale_x = StoredValue::new(1.0_f64);
|
||||
let scale_y = StoredValue::new(1.0_f64);
|
||||
let offset_x = StoredValue::new(0.0_f64);
|
||||
let offset_y = StoredValue::new(0.0_f64);
|
||||
|
||||
// Handle canvas click for movement (on avatar canvas - topmost layer)
|
||||
#[cfg(feature = "hydrate")]
|
||||
let on_canvas_click = {
|
||||
let on_move = on_move.clone();
|
||||
move |ev: web_sys::MouseEvent| {
|
||||
let Some(canvas) = avatar_canvas_ref.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
let rect = canvas_el.get_bounding_client_rect();
|
||||
|
||||
let canvas_x = ev.client_x() as f64 - rect.left();
|
||||
let canvas_y = ev.client_y() as f64 - rect.top();
|
||||
|
||||
let sx = scale_x.get_value();
|
||||
let sy = scale_y.get_value();
|
||||
let ox = offset_x.get_value();
|
||||
let oy = offset_y.get_value();
|
||||
|
||||
if sx > 0.0 && sy > 0.0 {
|
||||
let scene_x = (canvas_x - ox) / sx;
|
||||
let scene_y = (canvas_y - oy) / sy;
|
||||
|
||||
let scene_x = scene_x.max(0.0).min(scene_width as f64);
|
||||
let scene_y = scene_y.max(0.0).min(scene_height as f64);
|
||||
|
||||
on_move.run((scene_x, scene_y));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use wasm_bindgen::{closure::Closure, JsCast};
|
||||
|
||||
let image_path_clone = image_path.clone();
|
||||
let bg_color_clone = bg_color.clone();
|
||||
let scene_width_f = scene_width as f64;
|
||||
let scene_height_f = scene_height as f64;
|
||||
|
||||
// Flag to track if background has been drawn
|
||||
let bg_drawn = Rc::new(RefCell::new(false));
|
||||
|
||||
// =========================================================
|
||||
// Background Effect - runs once on mount, draws static background
|
||||
// =========================================================
|
||||
Effect::new(move |_| {
|
||||
// Don't track any reactive signals - this should only run once
|
||||
let Some(canvas) = bg_canvas_ref.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Skip if already drawn
|
||||
if *bg_drawn.borrow() {
|
||||
return;
|
||||
}
|
||||
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
let canvas_el = canvas_el.clone();
|
||||
let bg_color = bg_color_clone.clone();
|
||||
let image_path = image_path_clone.clone();
|
||||
let bg_drawn_inner = bg_drawn.clone();
|
||||
|
||||
let draw_bg = Closure::once(Box::new(move || {
|
||||
let display_width = canvas_el.client_width() as u32;
|
||||
let display_height = canvas_el.client_height() as u32;
|
||||
|
||||
if display_width == 0 || display_height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas_el.set_width(display_width);
|
||||
canvas_el.set_height(display_height);
|
||||
|
||||
// Calculate scale to fit scene in canvas
|
||||
let canvas_aspect = display_width as f64 / display_height as f64;
|
||||
let scene_aspect = scene_width_f / scene_height_f;
|
||||
|
||||
let (draw_width, draw_height, draw_x, draw_y) = if canvas_aspect > scene_aspect {
|
||||
let h = display_height as f64;
|
||||
let w = h * scene_aspect;
|
||||
let x = (display_width as f64 - w) / 2.0;
|
||||
(w, h, x, 0.0)
|
||||
} else {
|
||||
let w = display_width as f64;
|
||||
let h = w / scene_aspect;
|
||||
let y = (display_height as f64 - h) / 2.0;
|
||||
(w, h, 0.0, y)
|
||||
};
|
||||
|
||||
// Store scale factors
|
||||
let sx = draw_width / scene_width_f;
|
||||
let sy = draw_height / scene_height_f;
|
||||
scale_x.set_value(sx);
|
||||
scale_y.set_value(sy);
|
||||
offset_x.set_value(draw_x);
|
||||
offset_y.set_value(draw_y);
|
||||
|
||||
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
|
||||
let ctx: web_sys::CanvasRenderingContext2d =
|
||||
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
|
||||
|
||||
// Fill letterbox area with black
|
||||
ctx.set_fill_style_str("#000");
|
||||
ctx.fill_rect(0.0, 0.0, display_width as f64, display_height as f64);
|
||||
|
||||
// Fill scene area with background color
|
||||
ctx.set_fill_style_str(&bg_color);
|
||||
ctx.fill_rect(draw_x, draw_y, draw_width, draw_height);
|
||||
|
||||
// Draw background image if available
|
||||
if has_background_image && !image_path.is_empty() {
|
||||
let img = web_sys::HtmlImageElement::new().unwrap();
|
||||
let img_clone = img.clone();
|
||||
let ctx_clone = ctx.clone();
|
||||
|
||||
let onload = Closure::once(Box::new(move || {
|
||||
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
|
||||
&img_clone, draw_x, draw_y, draw_width, draw_height,
|
||||
);
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||
onload.forget();
|
||||
img.set_src(&image_path);
|
||||
}
|
||||
|
||||
// Mark background as drawn
|
||||
*bg_drawn_inner.borrow_mut() = true;
|
||||
}
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
let window = web_sys::window().unwrap();
|
||||
let _ = window.request_animation_frame(draw_bg.as_ref().unchecked_ref());
|
||||
draw_bg.forget();
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// Avatar Effect - runs when members change, redraws avatars only
|
||||
// =========================================================
|
||||
Effect::new(move |_| {
|
||||
// Track members signal - this Effect reruns when members change
|
||||
let current_members = members.get();
|
||||
|
||||
let Some(canvas) = avatar_canvas_ref.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
let canvas_el = canvas_el.clone();
|
||||
|
||||
let draw_avatars_closure = Closure::once(Box::new(move || {
|
||||
let display_width = canvas_el.client_width() as u32;
|
||||
let display_height = canvas_el.client_height() as u32;
|
||||
|
||||
if display_width == 0 || display_height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resize avatar canvas to match (if needed)
|
||||
if canvas_el.width() != display_width || canvas_el.height() != display_height {
|
||||
canvas_el.set_width(display_width);
|
||||
canvas_el.set_height(display_height);
|
||||
}
|
||||
|
||||
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
|
||||
let ctx: web_sys::CanvasRenderingContext2d =
|
||||
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
|
||||
|
||||
// Clear with transparency (not fill - keeps canvas transparent)
|
||||
ctx.clear_rect(0.0, 0.0, display_width as f64, display_height as f64);
|
||||
|
||||
// Get stored scale factors
|
||||
let sx = scale_x.get_value();
|
||||
let sy = scale_y.get_value();
|
||||
let ox = offset_x.get_value();
|
||||
let oy = offset_y.get_value();
|
||||
|
||||
// Draw avatars
|
||||
draw_avatars(&ctx, ¤t_members, sx, sy, ox, oy);
|
||||
}
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
let window = web_sys::window().unwrap();
|
||||
let _ = window.request_animation_frame(draw_avatars_closure.as_ref().unchecked_ref());
|
||||
draw_avatars_closure.forget();
|
||||
});
|
||||
}
|
||||
|
||||
let aspect_ratio = scene_width as f64 / scene_height as f64;
|
||||
|
||||
view! {
|
||||
<div class="scene-container w-full h-full flex justify-center items-center">
|
||||
<div
|
||||
class="scene-canvas relative overflow-hidden cursor-pointer"
|
||||
style:background-color=bg_color.clone()
|
||||
style:aspect-ratio=format!("{} / {}", scene_width, scene_height)
|
||||
style:width=format!("min(100%, calc((100vh - 64px) * {}))", aspect_ratio)
|
||||
style:max-height="calc(100vh - 64px)"
|
||||
>
|
||||
// Background layer - static, drawn once
|
||||
<canvas
|
||||
node_ref=bg_canvas_ref
|
||||
class="absolute inset-0 w-full h-full"
|
||||
style="z-index: 0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
// Avatar layer - dynamic, transparent background
|
||||
<canvas
|
||||
node_ref=avatar_canvas_ref
|
||||
class="absolute inset-0 w-full h-full"
|
||||
style="z-index: 1"
|
||||
aria-label=format!("Scene: {}", scene.name)
|
||||
role="img"
|
||||
on:click=move |ev| {
|
||||
#[cfg(feature = "hydrate")]
|
||||
on_canvas_click(ev);
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
let _ = ev;
|
||||
}
|
||||
>
|
||||
{format!("Scene: {}", scene.name)}
|
||||
</canvas>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
/// Normalize an asset path to be absolute, prefixing with /static/ if needed.
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn normalize_asset_path(path: &str) -> String {
|
||||
if path.starts_with('/') {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("/static/{}", path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn draw_avatars(
|
||||
ctx: &web_sys::CanvasRenderingContext2d,
|
||||
members: &[ChannelMemberWithAvatar],
|
||||
scale_x: f64,
|
||||
scale_y: f64,
|
||||
offset_x: f64,
|
||||
offset_y: f64,
|
||||
) {
|
||||
for member in members {
|
||||
let x = member.member.position_x * scale_x + offset_x;
|
||||
let y = member.member.position_y * scale_y + offset_y;
|
||||
|
||||
let avatar_size = 48.0 * scale_x.min(scale_y);
|
||||
|
||||
// Draw avatar placeholder circle
|
||||
ctx.begin_path();
|
||||
let _ = ctx.arc(x, y - avatar_size / 2.0, avatar_size / 2.0, 0.0, std::f64::consts::PI * 2.0);
|
||||
ctx.set_fill_style_str("#6366f1");
|
||||
ctx.fill();
|
||||
|
||||
// Draw skin layer sprite if available
|
||||
if let Some(ref skin_path) = member.avatar.skin_layer[4] {
|
||||
let img = web_sys::HtmlImageElement::new().unwrap();
|
||||
let img_clone = img.clone();
|
||||
let ctx_clone = ctx.clone();
|
||||
let draw_x = x;
|
||||
let draw_y = y - avatar_size;
|
||||
let size = avatar_size;
|
||||
|
||||
let onload = wasm_bindgen::closure::Closure::once(Box::new(move || {
|
||||
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
|
||||
&img_clone, draw_x - size / 2.0, draw_y, size, size,
|
||||
);
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||
onload.forget();
|
||||
img.set_src(&normalize_asset_path(skin_path));
|
||||
}
|
||||
|
||||
// Draw emotion overlay if available
|
||||
if let Some(ref emotion_path) = member.avatar.emotion_layer[4] {
|
||||
let img = web_sys::HtmlImageElement::new().unwrap();
|
||||
let img_clone = img.clone();
|
||||
let ctx_clone = ctx.clone();
|
||||
let draw_x = x;
|
||||
let draw_y = y - avatar_size;
|
||||
let size = avatar_size;
|
||||
|
||||
let onload = wasm_bindgen::closure::Closure::once(Box::new(move || {
|
||||
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
|
||||
&img_clone, draw_x - size / 2.0, draw_y, size, size,
|
||||
);
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||
onload.forget();
|
||||
img.set_src(&normalize_asset_path(emotion_path));
|
||||
}
|
||||
|
||||
// Draw emotion indicator on avatar
|
||||
let emotion = member.member.current_emotion;
|
||||
if emotion > 0 {
|
||||
// Draw emotion number in a small badge
|
||||
let badge_size = 16.0 * scale_x.min(scale_y);
|
||||
let badge_x = x + avatar_size / 2.0 - badge_size / 2.0;
|
||||
let badge_y = y - avatar_size - badge_size / 2.0;
|
||||
|
||||
// Badge background
|
||||
ctx.begin_path();
|
||||
let _ = ctx.arc(badge_x, badge_y, badge_size / 2.0, 0.0, std::f64::consts::PI * 2.0);
|
||||
ctx.set_fill_style_str("#f59e0b"); // Amber color for emotion badge
|
||||
ctx.fill();
|
||||
|
||||
// Emotion number
|
||||
ctx.set_fill_style_str("#000");
|
||||
ctx.set_font(&format!("bold {}px sans-serif", 10.0 * scale_x.min(scale_y)));
|
||||
ctx.set_text_align("center");
|
||||
ctx.set_text_baseline("middle");
|
||||
let _ = ctx.fill_text(&format!("{}", emotion), badge_x, badge_y);
|
||||
}
|
||||
|
||||
// Draw display name
|
||||
ctx.set_fill_style_str("#fff");
|
||||
ctx.set_font(&format!("{}px sans-serif", 12.0 * scale_x.min(scale_y)));
|
||||
ctx.set_text_align("center");
|
||||
ctx.set_text_baseline("alphabetic");
|
||||
let _ = ctx.fill_text(&member.member.display_name, x, y + 10.0 * scale_y);
|
||||
}
|
||||
}
|
||||
257
crates/chattyness-user-ui/src/components/ws_client.rs
Normal file
257
crates/chattyness-user-ui/src/components/ws_client.rs
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
//! WebSocket client for channel presence.
|
||||
//!
|
||||
//! Provides a Leptos hook to manage WebSocket connections for real-time
|
||||
//! position updates, emotion changes, and member synchronization.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos::reactive::owner::LocalStorage;
|
||||
|
||||
use chattyness_db::models::ChannelMemberWithAvatar;
|
||||
use chattyness_db::ws_messages::{ClientMessage, ServerMessage};
|
||||
|
||||
/// WebSocket connection state.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum WsState {
|
||||
/// Attempting to connect.
|
||||
Connecting,
|
||||
/// Connected and ready.
|
||||
Connected,
|
||||
/// Disconnected (not connected).
|
||||
Disconnected,
|
||||
/// Connection error occurred.
|
||||
Error,
|
||||
}
|
||||
|
||||
/// Sender function type for WebSocket messages.
|
||||
pub type WsSender = Box<dyn Fn(ClientMessage)>;
|
||||
|
||||
/// Local stored value type for the sender (non-Send, WASM-compatible).
|
||||
pub type WsSenderStorage = StoredValue<Option<WsSender>, LocalStorage>;
|
||||
|
||||
/// Hook to manage WebSocket connection for a channel.
|
||||
///
|
||||
/// Returns a tuple of:
|
||||
/// - `Signal<WsState>` - The current connection state
|
||||
/// - `WsSenderStorage` - A stored sender function to send messages
|
||||
#[cfg(feature = "hydrate")]
|
||||
pub fn use_channel_websocket(
|
||||
realm_slug: Signal<String>,
|
||||
channel_id: Signal<Option<uuid::Uuid>>,
|
||||
on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
|
||||
) -> (Signal<WsState>, WsSenderStorage) {
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use wasm_bindgen::{closure::Closure, JsCast};
|
||||
use web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket};
|
||||
|
||||
let (ws_state, set_ws_state) = signal(WsState::Disconnected);
|
||||
let ws_ref: Rc<RefCell<Option<WebSocket>>> = Rc::new(RefCell::new(None));
|
||||
let members: Rc<RefCell<Vec<ChannelMemberWithAvatar>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
|
||||
// Create a stored sender function (using new_local for WASM single-threaded environment)
|
||||
let ws_ref_for_send = ws_ref.clone();
|
||||
let sender: WsSenderStorage = StoredValue::new_local(Some(Box::new(
|
||||
move |msg: ClientMessage| {
|
||||
if let Some(ws) = ws_ref_for_send.borrow().as_ref() {
|
||||
if ws.ready_state() == WebSocket::OPEN {
|
||||
if let Ok(json) = serde_json::to_string(&msg) {
|
||||
#[cfg(debug_assertions)]
|
||||
web_sys::console::log_1(&format!("[WS->Server] {}", json).into());
|
||||
let _ = ws.send_with_str(&json);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)));
|
||||
|
||||
// Effect to manage WebSocket lifecycle
|
||||
let ws_ref_clone = ws_ref.clone();
|
||||
let members_clone = members.clone();
|
||||
|
||||
Effect::new(move |_| {
|
||||
let slug = realm_slug.get();
|
||||
let ch_id = channel_id.get();
|
||||
|
||||
// Cleanup previous connection
|
||||
if let Some(old_ws) = ws_ref_clone.borrow_mut().take() {
|
||||
let _ = old_ws.close();
|
||||
}
|
||||
|
||||
let Some(ch_id) = ch_id else {
|
||||
set_ws_state.set(WsState::Disconnected);
|
||||
return;
|
||||
};
|
||||
|
||||
if slug.is_empty() {
|
||||
set_ws_state.set(WsState::Disconnected);
|
||||
return;
|
||||
}
|
||||
|
||||
// Construct WebSocket URL
|
||||
let window = web_sys::window().unwrap();
|
||||
let location = window.location();
|
||||
let protocol = if location.protocol().unwrap_or_default() == "https:" {
|
||||
"wss:"
|
||||
} else {
|
||||
"ws:"
|
||||
};
|
||||
let host = location.host().unwrap_or_default();
|
||||
let url = format!(
|
||||
"{}//{}/api/realms/{}/channels/{}/ws",
|
||||
protocol, host, slug, ch_id
|
||||
);
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
web_sys::console::log_1(&format!("[WS] Connecting to: {}", url).into());
|
||||
|
||||
set_ws_state.set(WsState::Connecting);
|
||||
|
||||
let ws = match WebSocket::new(&url) {
|
||||
Ok(ws) => ws,
|
||||
Err(e) => {
|
||||
#[cfg(debug_assertions)]
|
||||
web_sys::console::error_1(&format!("[WS] Failed to create: {:?}", e).into());
|
||||
set_ws_state.set(WsState::Error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// onopen
|
||||
let set_ws_state_open = set_ws_state;
|
||||
let onopen = Closure::wrap(Box::new(move |_: web_sys::Event| {
|
||||
#[cfg(debug_assertions)]
|
||||
web_sys::console::log_1(&"[WS] Connected".into());
|
||||
set_ws_state_open.set(WsState::Connected);
|
||||
}) as Box<dyn FnMut(web_sys::Event)>);
|
||||
ws.set_onopen(Some(onopen.as_ref().unchecked_ref()));
|
||||
onopen.forget();
|
||||
|
||||
// onmessage
|
||||
let members_for_msg = members_clone.clone();
|
||||
let on_members_update_clone = on_members_update.clone();
|
||||
let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| {
|
||||
if let Ok(text) = e.data().dyn_into::<js_sys::JsString>() {
|
||||
let text: String = text.into();
|
||||
#[cfg(debug_assertions)]
|
||||
web_sys::console::log_1(&format!("[WS<-Server] {}", text).into());
|
||||
|
||||
if let Ok(msg) = serde_json::from_str::<ServerMessage>(&text) {
|
||||
handle_server_message(msg, &members_for_msg, &on_members_update_clone);
|
||||
}
|
||||
}
|
||||
}) as Box<dyn FnMut(MessageEvent)>);
|
||||
ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
|
||||
onmessage.forget();
|
||||
|
||||
// onerror
|
||||
let set_ws_state_err = set_ws_state;
|
||||
let onerror = Closure::wrap(Box::new(move |e: ErrorEvent| {
|
||||
#[cfg(debug_assertions)]
|
||||
web_sys::console::error_1(&format!("[WS] Error: {:?}", e.message()).into());
|
||||
set_ws_state_err.set(WsState::Error);
|
||||
}) as Box<dyn FnMut(ErrorEvent)>);
|
||||
ws.set_onerror(Some(onerror.as_ref().unchecked_ref()));
|
||||
onerror.forget();
|
||||
|
||||
// onclose
|
||||
let set_ws_state_close = set_ws_state;
|
||||
let onclose = Closure::wrap(Box::new(move |e: CloseEvent| {
|
||||
#[cfg(debug_assertions)]
|
||||
web_sys::console::log_1(
|
||||
&format!("[WS] Closed: code={}, reason={}", e.code(), e.reason()).into(),
|
||||
);
|
||||
set_ws_state_close.set(WsState::Disconnected);
|
||||
}) as Box<dyn FnMut(CloseEvent)>);
|
||||
ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
|
||||
onclose.forget();
|
||||
|
||||
*ws_ref_clone.borrow_mut() = Some(ws);
|
||||
});
|
||||
|
||||
(Signal::derive(move || ws_state.get()), sender)
|
||||
}
|
||||
|
||||
/// Handle a message received from the server.
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn handle_server_message(
|
||||
msg: ServerMessage,
|
||||
members: &std::rc::Rc<std::cell::RefCell<Vec<ChannelMemberWithAvatar>>>,
|
||||
on_update: &Callback<Vec<ChannelMemberWithAvatar>>,
|
||||
) {
|
||||
let mut members_vec = members.borrow_mut();
|
||||
|
||||
match msg {
|
||||
ServerMessage::Welcome {
|
||||
member: _,
|
||||
members: initial_members,
|
||||
} => {
|
||||
*members_vec = initial_members;
|
||||
on_update.run(members_vec.clone());
|
||||
}
|
||||
ServerMessage::MemberJoined { member } => {
|
||||
// Remove if exists (rejoin case), then add
|
||||
members_vec.retain(|m| {
|
||||
m.member.user_id != member.member.user_id
|
||||
|| m.member.guest_session_id != member.member.guest_session_id
|
||||
});
|
||||
members_vec.push(member);
|
||||
on_update.run(members_vec.clone());
|
||||
}
|
||||
ServerMessage::MemberLeft {
|
||||
user_id,
|
||||
guest_session_id,
|
||||
} => {
|
||||
members_vec.retain(|m| {
|
||||
m.member.user_id != user_id || m.member.guest_session_id != guest_session_id
|
||||
});
|
||||
on_update.run(members_vec.clone());
|
||||
}
|
||||
ServerMessage::PositionUpdated {
|
||||
user_id,
|
||||
guest_session_id,
|
||||
x,
|
||||
y,
|
||||
} => {
|
||||
if let Some(m) = members_vec.iter_mut().find(|m| {
|
||||
m.member.user_id == user_id && m.member.guest_session_id == guest_session_id
|
||||
}) {
|
||||
m.member.position_x = x;
|
||||
m.member.position_y = y;
|
||||
}
|
||||
on_update.run(members_vec.clone());
|
||||
}
|
||||
ServerMessage::EmotionUpdated {
|
||||
user_id,
|
||||
guest_session_id,
|
||||
emotion,
|
||||
emotion_layer,
|
||||
} => {
|
||||
if let Some(m) = members_vec.iter_mut().find(|m| {
|
||||
m.member.user_id == user_id && m.member.guest_session_id == guest_session_id
|
||||
}) {
|
||||
m.member.current_emotion = emotion as i16;
|
||||
m.avatar.emotion_layer = emotion_layer;
|
||||
}
|
||||
on_update.run(members_vec.clone());
|
||||
}
|
||||
ServerMessage::Pong => {
|
||||
// Heartbeat acknowledged - nothing to do
|
||||
}
|
||||
ServerMessage::Error { code, message } => {
|
||||
#[cfg(debug_assertions)]
|
||||
web_sys::console::error_1(&format!("[WS] Server error: {} - {}", code, message).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub implementation for SSR (server-side rendering).
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
pub fn use_channel_websocket(
|
||||
_realm_slug: Signal<String>,
|
||||
_channel_id: Signal<Option<uuid::Uuid>>,
|
||||
_on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
|
||||
) -> (Signal<WsState>, WsSenderStorage) {
|
||||
let (ws_state, _) = signal(WsState::Disconnected);
|
||||
let sender: WsSenderStorage = StoredValue::new_local(None);
|
||||
(Signal::derive(move || ws_state.get()), sender)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue