diff --git a/apps/chattyness-app/src/app.rs b/apps/chattyness-app/src/app.rs
index bf7a632..73b8f73 100644
--- a/apps/chattyness-app/src/app.rs
+++ b/apps/chattyness-app/src/app.rs
@@ -259,6 +259,15 @@ pub fn CombinedApp() -> impl IntoView {
+
// ==========================================
// Admin routes (lazy loading)
diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs
index b2034c4..ef81a32 100644
--- a/crates/chattyness-user-ui/src/pages/realm.rs
+++ b/crates/chattyness-user-ui/src/pages/realm.rs
@@ -44,6 +44,9 @@ pub fn RealmPage() -> impl IntoView {
let slug = Signal::derive(move || params.read().get("slug").unwrap_or_default());
+ // Scene slug from URL (for direct scene navigation)
+ let scene_slug_param = Signal::derive(move || params.read().get("scene_slug"));
+
// Channel member state
let (members, set_members) = signal(Vec::::new());
let (channel_id, set_channel_id) = signal(Option::::None);
@@ -350,10 +353,12 @@ pub fn RealmPage() -> impl IntoView {
#[cfg(feature = "hydrate")]
let on_teleport_approved = Callback::new(move |info: TeleportInfo| {
let scene_id = info.scene_id;
- let scene_slug = info.scene_slug;
+ let scene_slug = info.scene_slug.clone();
let realm_slug = slug.get_untracked();
// Fetch the new scene data to update the canvas background
+ let scene_slug_for_url = scene_slug.clone();
+ let realm_slug_for_url = realm_slug.clone();
spawn_local(async move {
use gloo_net::http::Request;
let response = Request::get(&format!(
@@ -370,6 +375,26 @@ pub fn RealmPage() -> impl IntoView {
if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) {
set_scene_dimensions.set((w as f64, h as f64));
}
+
+ // Update URL to reflect new scene
+ if let Some(window) = web_sys::window() {
+ if let Ok(history) = window.history() {
+ let new_url = if scene.is_entry_point {
+ format!("/realms/{}", realm_slug_for_url)
+ } else {
+ format!(
+ "/realms/{}/scenes/{}",
+ realm_slug_for_url, scene_slug_for_url
+ )
+ };
+ let _ = history.replace_state_with_url(
+ &wasm_bindgen::JsValue::NULL,
+ "",
+ Some(&new_url),
+ );
+ }
+ }
+
// Update the current scene for the viewer
set_current_scene.set(Some(scene));
}
@@ -408,16 +433,125 @@ pub fn RealmPage() -> impl IntoView {
// uses scenes directly. Proper channel infrastructure can be added later.
#[cfg(feature = "hydrate")]
{
- Effect::new(move |_| {
- let Some(scene) = entry_scene.get().flatten() else {
- return;
- };
- set_channel_id.set(Some(scene.id));
- set_current_scene.set(Some(scene.clone()));
+ // Track whether we've handled initial scene load to prevent double-loading
+ let initial_scene_handled = StoredValue::new_local(false);
- // Extract scene dimensions from bounds_wkt
- if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) {
- set_scene_dimensions.set((w as f64, h as f64));
+ Effect::new(move |_| {
+ // Skip if already handled
+ if initial_scene_handled.get_value() {
+ return;
+ }
+
+ let url_scene_slug = scene_slug_param.get();
+ let has_url_scene = url_scene_slug
+ .as_ref()
+ .is_some_and(|s| !s.is_empty());
+
+ if has_url_scene {
+ // URL has a scene slug - wait for realm data to check if teleport is allowed
+ let Some(realm_with_role) = realm_data.get().flatten() else {
+ return;
+ };
+
+ let realm_slug_val = slug.get();
+ let scene_slug_val = url_scene_slug.unwrap();
+
+ if !realm_with_role.realm.allow_user_teleport {
+ // Teleport disabled - redirect to base realm URL and show error
+ initial_scene_handled.set_value(true);
+ set_error_message.set(Some(
+ "Direct scene access is disabled for this realm".to_string(),
+ ));
+
+ // Redirect to base realm URL
+ let navigate = use_navigate();
+ navigate(
+ &format!("/realms/{}", realm_slug_val),
+ leptos_router::NavigateOptions {
+ replace: true,
+ ..Default::default()
+ },
+ );
+
+ // Auto-dismiss error after 5 seconds
+ use gloo_timers::callback::Timeout;
+ Timeout::new(5000, move || {
+ set_error_message.set(None);
+ })
+ .forget();
+
+ // Fall back to entry scene
+ if let Some(scene) = entry_scene.get().flatten() {
+ set_channel_id.set(Some(scene.id));
+ set_current_scene.set(Some(scene.clone()));
+ if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) {
+ set_scene_dimensions.set((w as f64, h as f64));
+ }
+ }
+ return;
+ }
+
+ // Teleport allowed - fetch the specific scene
+ initial_scene_handled.set_value(true);
+ spawn_local(async move {
+ use gloo_net::http::Request;
+ let response = Request::get(&format!(
+ "/api/realms/{}/scenes/{}",
+ realm_slug_val, scene_slug_val
+ ))
+ .send()
+ .await;
+
+ if let Ok(resp) = response {
+ if resp.ok() {
+ if let Ok(scene) = resp.json::().await {
+ set_channel_id.set(Some(scene.id));
+ if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) {
+ set_scene_dimensions.set((w as f64, h as f64));
+ }
+ set_current_scene.set(Some(scene));
+ return;
+ }
+ }
+ }
+
+ // Scene not found - show error and fall back to entry scene
+ set_error_message.set(Some(format!(
+ "Scene '{}' not found",
+ scene_slug_val
+ )));
+
+ // Update URL to base realm URL
+ if let Some(window) = web_sys::window() {
+ if let Ok(history) = window.history() {
+ let _ = history.replace_state_with_url(
+ &wasm_bindgen::JsValue::NULL,
+ "",
+ Some(&format!("/realms/{}", realm_slug_val)),
+ );
+ }
+ }
+
+ // Auto-dismiss error after 5 seconds
+ use gloo_timers::callback::Timeout;
+ Timeout::new(5000, move || {
+ set_error_message.set(None);
+ })
+ .forget();
+ });
+ } else {
+ // No URL scene slug - use entry scene
+ let Some(scene) = entry_scene.get().flatten() else {
+ return;
+ };
+ initial_scene_handled.set_value(true);
+ set_channel_id.set(Some(scene.id));
+ set_current_scene.set(Some(scene.clone()));
+
+ // Extract scene dimensions from bounds_wkt
+ if let Some((w, h)) = parse_bounds_dimensions(&scene.bounds_wkt) {
+ set_scene_dimensions.set((w as f64, h as f64));
+ }
}
});
}
diff --git a/crates/chattyness-user-ui/src/routes.rs b/crates/chattyness-user-ui/src/routes.rs
index b6febbe..f523062 100644
--- a/crates/chattyness-user-ui/src/routes.rs
+++ b/crates/chattyness-user-ui/src/routes.rs
@@ -31,6 +31,15 @@ pub fn UserRoutes() -> impl IntoView {
+
}
}