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