450 lines
21 KiB
Rust
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>
|
|
}
|
|
}
|