add initial crates and apps

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

View file

@ -0,0 +1,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>
}
}

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

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

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

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

View 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, &current_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);
}
}

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