add initial crates and apps
This commit is contained in:
parent
5c87ba3519
commit
1ca300098f
113 changed files with 28169 additions and 0 deletions
349
crates/chattyness-user-ui/src/pages/realm.rs
Normal file
349
crates/chattyness-user-ui/src/pages/realm.rs
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
//! Realm landing page after login.
|
||||
|
||||
use leptos::prelude::*;
|
||||
#[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, RealmRole, RealmWithUserRole, Scene};
|
||||
#[cfg(feature = "hydrate")]
|
||||
use chattyness_db::ws_messages::ClientMessage;
|
||||
|
||||
/// 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);
|
||||
|
||||
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>
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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 emotion change via keyboard (e then 0-9)
|
||||
#[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();
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
// 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 realm_slug_for_viewer = realm_slug_val.clone();
|
||||
entry_scene
|
||||
.get()
|
||||
.map(|maybe_scene| {
|
||||
match maybe_scene {
|
||||
Some(scene) => {
|
||||
let members_signal = Signal::derive(move || members.get());
|
||||
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">
|
||||
<ChatInput />
|
||||
</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>
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue