chattyness/crates/chattyness-user-ui/src/pages/realm.rs

450 lines
21 KiB
Rust

//! Realm landing page after login.
use leptos::prelude::*;
use leptos::reactive::owner::LocalStorage;
#[cfg(feature = "hydrate")]
use leptos::task::spawn_local;
#[cfg(feature = "hydrate")]
use leptos_router::hooks::use_navigate;
use leptos_router::hooks::use_params_map;
use crate::components::{Card, ChatInput, RealmHeader, RealmSceneViewer};
#[cfg(feature = "hydrate")]
use crate::components::use_channel_websocket;
use chattyness_db::models::{
ChannelMemberWithAvatar, EmotionAvailability, RealmRole, RealmWithUserRole, Scene,
};
#[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::ClientMessage;
use crate::components::ws_client::WsSender;
/// Realm landing page component.
#[component]
pub fn RealmPage() -> impl IntoView {
let params = use_params_map();
#[cfg(feature = "hydrate")]
let navigate = use_navigate();
let slug = Signal::derive(move || params.read().get("slug").unwrap_or_default());
// Channel member state
let (members, set_members) = signal(Vec::<ChannelMemberWithAvatar>::new());
let (channel_id, set_channel_id) = signal(Option::<uuid::Uuid>::None);
// Chat focus coordination
let (chat_focused, set_chat_focused) = signal(false);
let (focus_chat_trigger, set_focus_chat_trigger) = signal(false);
// Emotion availability for emote picker
let (emotion_availability, set_emotion_availability) =
signal(Option::<EmotionAvailability>::None);
// Skin preview path for emote picker (position 4 of skin layer)
let (skin_preview_path, set_skin_preview_path) = signal(Option::<String>::None);
let realm_data = LocalResource::new(move || {
let slug = slug.get();
async move {
if slug.is_empty() {
return None;
}
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
let response = Request::get(&format!("/api/realms/{}", slug)).send().await;
match response {
Ok(resp) if resp.ok() => resp.json::<RealmWithUserRole>().await.ok(),
_ => None,
}
}
#[cfg(not(feature = "hydrate"))]
{
let _ = slug;
None::<RealmWithUserRole>
}
}
});
// Fetch entry scene for the realm
let entry_scene = LocalResource::new(move || {
let slug = slug.get();
async move {
if slug.is_empty() {
return None;
}
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
let response = Request::get(&format!("/api/realms/{}/entry-scene", slug))
.send()
.await;
match response {
Ok(resp) if resp.ok() => resp.json::<Scene>().await.ok(),
_ => None,
}
}
#[cfg(not(feature = "hydrate"))]
{
let _ = slug;
None::<Scene>
}
}
});
// Fetch emotion availability and avatar render data for emote picker
#[cfg(feature = "hydrate")]
{
let slug_for_emotions = slug.clone();
Effect::new(move |_| {
use gloo_net::http::Request;
let current_slug = slug_for_emotions.get();
if current_slug.is_empty() {
return;
}
// Fetch emotion availability
let slug_clone = current_slug.clone();
spawn_local(async move {
let response = Request::get(&format!("/api/realms/{}/avatar/emotions", slug_clone))
.send()
.await;
if let Ok(resp) = response {
if resp.ok() {
if let Ok(avail) = resp.json::<EmotionAvailability>().await {
set_emotion_availability.set(Some(avail));
}
}
}
});
// Fetch avatar render data for skin preview
let slug_clone2 = current_slug.clone();
spawn_local(async move {
use chattyness_db::models::AvatarRenderData;
let response = Request::get(&format!("/api/realms/{}/avatar/current", slug_clone2))
.send()
.await;
if let Ok(resp) = response {
if resp.ok() {
if let Ok(render_data) = resp.json::<AvatarRenderData>().await {
// Get skin layer position 4 (center)
set_skin_preview_path.set(render_data.skin_layer[4].clone());
}
}
}
});
});
}
// WebSocket connection for real-time updates
#[cfg(feature = "hydrate")]
let on_members_update = Callback::new(move |new_members: Vec<ChannelMemberWithAvatar>| {
set_members.set(new_members);
});
#[cfg(feature = "hydrate")]
let (_ws_state, ws_sender) = use_channel_websocket(
slug,
Signal::derive(move || channel_id.get()),
on_members_update,
);
// Set channel ID when scene loads (triggers WebSocket connection)
#[cfg(feature = "hydrate")]
{
Effect::new(move |_| {
let Some(scene) = entry_scene.get().flatten() else {
return;
};
set_channel_id.set(Some(scene.id));
});
}
// Handle position update via WebSocket
#[cfg(feature = "hydrate")]
let on_move = Callback::new(move |(x, y): (f64, f64)| {
ws_sender.with_value(|sender| {
if let Some(send_fn) = sender {
send_fn(ClientMessage::UpdatePosition { x, y });
}
});
});
#[cfg(not(feature = "hydrate"))]
let on_move = Callback::new(move |(_x, _y): (f64, f64)| {});
// Handle global keyboard shortcuts (e+0-9 for emotions, : for chat focus)
#[cfg(feature = "hydrate")]
{
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::{closure::Closure, JsCast};
let closure_holder: Rc<RefCell<Option<Closure<dyn Fn(web_sys::KeyboardEvent)>>>> =
Rc::new(RefCell::new(None));
let closure_holder_clone = closure_holder.clone();
Effect::new(move |_| {
// Cleanup previous closure if any
if let Some(old_closure) = closure_holder_clone.borrow_mut().take() {
if let Some(window) = web_sys::window() {
let _ = window.remove_event_listener_with_callback(
"keydown",
old_closure.as_ref().unchecked_ref(),
);
}
}
let current_slug = slug.get();
if current_slug.is_empty() {
return;
}
// Track if 'e' was pressed (for e+0-9 emotion sequence)
let e_pressed: Rc<RefCell<bool>> = Rc::new(RefCell::new(false));
let e_pressed_clone = e_pressed.clone();
let closure = Closure::new(move |ev: web_sys::KeyboardEvent| {
let key = ev.key();
// If chat is focused, let it handle all keys
if chat_focused.get() {
*e_pressed_clone.borrow_mut() = false;
return;
}
// Handle ':' to focus chat input
if key == ":" {
set_focus_chat_trigger.set(true);
// Reset trigger after a short delay so it can be triggered again
use gloo_timers::callback::Timeout;
Timeout::new(100, move || {
set_focus_chat_trigger.set(false);
})
.forget();
ev.prevent_default();
return;
}
// Check if 'e' key was pressed
if key == "e" || key == "E" {
*e_pressed_clone.borrow_mut() = true;
return;
}
// Check for 0-9 after 'e' was pressed
if *e_pressed_clone.borrow() {
*e_pressed_clone.borrow_mut() = false; // Reset regardless of outcome
if key.len() == 1 {
if let Ok(emotion) = key.parse::<u8>() {
if emotion <= 9 {
#[cfg(debug_assertions)]
web_sys::console::log_1(
&format!("[Emotion] Sending emotion {}", emotion).into(),
);
ws_sender.with_value(|sender| {
if let Some(send_fn) = sender {
send_fn(ClientMessage::UpdateEmotion { emotion });
}
});
}
}
}
} else {
// Any other key resets the 'e' state
*e_pressed_clone.borrow_mut() = false;
}
});
if let Some(window) = web_sys::window() {
let _ = window
.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref());
}
// Store the closure for cleanup
*closure_holder_clone.borrow_mut() = Some(closure);
});
}
// Callback for chat focus changes
let on_chat_focus_change = Callback::new(move |focused: bool| {
set_chat_focused.set(focused);
});
// Create logout callback (WebSocket disconnects automatically)
let on_logout = Callback::new(move |_: ()| {
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
let navigate = navigate.clone();
spawn_local(async move {
// WebSocket close handles channel leave automatically
let _: Result<gloo_net::http::Response, gloo_net::Error> =
Request::post("/api/auth/logout").send().await;
navigate("/", Default::default());
});
}
});
view! {
<div class="h-screen bg-gray-900 text-white flex flex-col overflow-hidden">
<Suspense fallback=move || {
view! {
<div class="flex items-center justify-center min-h-screen">
<p class="text-gray-400">"Loading realm..."</p>
</div>
}
}>
{move || {
let on_logout = on_logout.clone();
let on_move = on_move.clone();
realm_data
.get()
.map(|maybe_data| {
match maybe_data {
Some(data) => {
let realm = data.realm;
let user_role = data.user_role;
// Determine if user can access admin
// Admin visible for: Owner, Moderator, or staff
let can_admin = matches!(
user_role,
Some(RealmRole::Owner) | Some(RealmRole::Moderator)
);
// Get scene name and description for header
let scene_info = entry_scene
.get()
.flatten()
.map(|s| (s.name.clone(), s.description.clone()))
.unwrap_or_else(|| ("Loading...".to_string(), None));
let realm_name = realm.name.clone();
let realm_slug_val = realm.slug.clone();
let realm_description = realm.tagline.clone();
let online_count = realm.current_user_count;
let total_members = realm.member_count;
let max_capacity = realm.max_users;
let scene_name = scene_info.0;
let scene_description = scene_info.1;
view! {
<RealmHeader
realm_name=realm_name
realm_slug=realm_slug_val.clone()
realm_description=realm_description
scene_name=scene_name
scene_description=scene_description
online_count=online_count
total_members=total_members
max_capacity=max_capacity
can_admin=can_admin
on_logout=on_logout.clone()
/>
<main class="flex-1 w-full">
// Scene viewer - full width
<Suspense fallback=move || {
view! {
<div class="flex items-center justify-center py-12">
<p class="text-gray-400">"Loading scene..."</p>
</div>
}
}>
{move || {
let on_move = on_move.clone();
let on_chat_focus_change = on_chat_focus_change.clone();
let realm_slug_for_viewer = realm_slug_val.clone();
#[cfg(feature = "hydrate")]
let ws_sender_clone = ws_sender.clone();
entry_scene
.get()
.map(|maybe_scene| {
match maybe_scene {
Some(scene) => {
let members_signal = Signal::derive(move || members.get());
let emotion_avail_signal = Signal::derive(move || emotion_availability.get());
let skin_path_signal = Signal::derive(move || skin_preview_path.get());
let focus_trigger_signal = Signal::derive(move || focus_chat_trigger.get());
#[cfg(feature = "hydrate")]
let ws_for_chat = ws_sender_clone.clone();
#[cfg(not(feature = "hydrate"))]
let ws_for_chat: StoredValue<Option<WsSender>, LocalStorage> = StoredValue::new_local(None);
view! {
<div class="relative w-full">
<RealmSceneViewer
scene=scene
realm_slug=realm_slug_for_viewer.clone()
members=members_signal
on_move=on_move.clone()
/>
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none">
<ChatInput
ws_sender=ws_for_chat
emotion_availability=emotion_avail_signal
skin_preview_path=skin_path_signal
focus_trigger=focus_trigger_signal
on_focus_change=on_chat_focus_change.clone()
/>
</div>
</div>
}
.into_any()
}
None => {
view! {
<div class="max-w-4xl mx-auto px-4 py-8">
<Card class="p-8 text-center">
<p class="text-gray-400">
"No scenes have been created for this realm yet."
</p>
</Card>
</div>
}
.into_any()
}
}
})
}}
</Suspense>
</main>
}
.into_any()
}
None => {
view! {
<div class="flex items-center justify-center min-h-screen">
<Card class="p-8 text-center max-w-md">
<div class="mx-auto w-20 h-20 rounded-full bg-red-900/20 flex items-center justify-center mb-4">
<img
src="/icons/x.svg"
alt=""
class="w-10 h-10"
aria-hidden="true"
/>
</div>
<h2 class="text-xl font-semibold text-white mb-2">
"Realm Not Found"
</h2>
<p class="text-gray-400 mb-6">
"The realm you're looking for doesn't exist or you don't have access."
</p>
<a href="/" class="btn-primary inline-block">
"Back to Home"
</a>
</Card>
</div>
}
.into_any()
}
}
})
}}
</Suspense>
</div>
}
}