ui: inform guests of restricted interfaces

This commit is contained in:
Evan Carroll 2026-01-19 00:38:37 -06:00
parent 1f922f8221
commit 39750c1d82
8 changed files with 137 additions and 49 deletions

View file

@ -12,6 +12,7 @@ use chattyness_db::models::{AvatarWithPaths, InventoryItem};
#[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::ClientMessage;
use super::modals::GuestLockedOverlay;
use super::ws_client::WsSenderStorage;
#[cfg(feature = "hydrate")]
use crate::utils::normalize_asset_path;
@ -216,6 +217,7 @@ fn RenderedPreview(#[prop(into)] avatar: Signal<Option<AvatarWithPaths>>) -> imp
/// - `realm_slug`: Current realm slug for API calls
/// - `on_avatar_update`: Callback when avatar is updated
/// - `ws_sender`: WebSocket sender for broadcasting avatar changes
/// - `is_guest`: Whether the current user is a guest (shows locked overlay)
#[component]
pub fn AvatarEditorPopup(
#[prop(into)] open: Signal<bool>,
@ -224,7 +226,11 @@ pub fn AvatarEditorPopup(
#[prop(into)] realm_slug: Signal<String>,
on_avatar_update: Callback<AvatarWithPaths>,
ws_sender: WsSenderStorage,
/// Whether the current user is a guest. Guests see a locked overlay.
#[prop(optional, into)]
is_guest: Option<Signal<bool>>,
) -> impl IntoView {
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
// Tab state
let (active_tab, set_active_tab) = signal(EditorTab::BaseLayers);
@ -798,6 +804,11 @@ pub fn AvatarEditorPopup(
</div>
</div>
</div>
// Guest locked overlay
<Show when=move || is_guest.get()>
<GuestLockedOverlay />
</Show>
</div>
// Context menu

View file

@ -8,7 +8,7 @@ use chattyness_db::models::{InventoryItem, PublicProp};
#[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::ClientMessage;
use super::modals::Modal;
use super::modals::{GuestLockedOverlay, Modal};
use super::tabs::{Tab, TabBar};
use super::ws_client::WsSender;
@ -24,13 +24,18 @@ use super::ws_client::WsSender;
/// - `on_close`: Callback when popup should close
/// - `ws_sender`: WebSocket sender for dropping props
/// - `realm_slug`: Current realm slug for fetching realm props
/// - `is_guest`: Whether the current user is a guest (shows locked overlay)
#[component]
pub fn InventoryPopup(
#[prop(into)] open: Signal<bool>,
on_close: Callback<()>,
ws_sender: StoredValue<Option<WsSender>, LocalStorage>,
#[prop(into)] realm_slug: Signal<String>,
/// Whether the current user is a guest. Guests see a locked overlay.
#[prop(optional, into)]
is_guest: Option<Signal<bool>>,
) -> impl IntoView {
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
// Tab state
let (active_tab, set_active_tab) = signal("my_inventory");
@ -238,52 +243,59 @@ pub fn InventoryPopup(
max_width="max-w-2xl"
class="max-h-[80vh] flex flex-col"
>
// Tab bar
<TabBar
tabs=vec![
Tab::new("my_inventory", "My Inventory"),
Tab::new("server", "Server"),
Tab::new("realm", "Realm"),
]
active=Signal::derive(move || active_tab.get())
on_select=Callback::new(move |id| set_active_tab.set(id))
/>
<div class="relative flex-1 flex flex-col">
// Tab bar
<TabBar
tabs=vec![
Tab::new("my_inventory", "My Inventory"),
Tab::new("server", "Server"),
Tab::new("realm", "Realm"),
]
active=Signal::derive(move || active_tab.get())
on_select=Callback::new(move |id| set_active_tab.set(id))
/>
// Tab content
<div class="flex-1 overflow-y-auto min-h-[300px]">
// My Inventory tab
<Show when=move || active_tab.get() == "my_inventory">
<MyInventoryTab
items=items
loading=loading
error=error
selected_item=selected_item
set_selected_item=set_selected_item
dropping=dropping
on_drop=Callback::new(handle_drop)
/>
</Show>
// Tab content
<div class="flex-1 overflow-y-auto min-h-[300px]">
// My Inventory tab
<Show when=move || active_tab.get() == "my_inventory">
<MyInventoryTab
items=items
loading=loading
error=error
selected_item=selected_item
set_selected_item=set_selected_item
dropping=dropping
on_drop=Callback::new(handle_drop)
/>
</Show>
// Server tab
<Show when=move || active_tab.get() == "server">
<PublicPropsTab
props=server_props
loading=server_loading
error=server_error
tab_name="Server"
empty_message="No public server props available"
/>
</Show>
// Server tab
<Show when=move || active_tab.get() == "server">
<PublicPropsTab
props=server_props
loading=server_loading
error=server_error
tab_name="Server"
empty_message="No public server props available"
/>
</Show>
// Realm tab
<Show when=move || active_tab.get() == "realm">
<PublicPropsTab
props=realm_props
loading=realm_loading
error=realm_error
tab_name="Realm"
empty_message="No public realm props available"
/>
// Realm tab
<Show when=move || active_tab.get() == "realm">
<PublicPropsTab
props=realm_props
loading=realm_loading
error=realm_error
tab_name="Realm"
empty_message="No public realm props available"
/>
</Show>
</div>
// Guest locked overlay
<Show when=move || is_guest.get()>
<GuestLockedOverlay />
</Show>
</div>
</Modal>

View file

@ -314,3 +314,45 @@ pub fn ConfirmModal(
</Show>
}
}
// ============================================================================
// Guest Locked Overlay
// ============================================================================
/// Overlay displayed when a feature is restricted to registered users.
///
/// Shows a semi-transparent backdrop with a padlock icon and diagonal
/// "Registered Users" text. Designed to be placed inside a modal container
/// with `position: relative`.
///
/// # Example
///
/// ```ignore
/// <div class="relative">
/// // Modal content here
/// <GuestLockedOverlay />
/// </div>
/// ```
#[component]
pub fn GuestLockedOverlay() -> impl IntoView {
view! {
<div
class="absolute inset-0 z-50 flex items-center justify-center bg-gray-900/80 backdrop-blur-sm rounded-lg"
role="alert"
aria-label="This feature is restricted to registered users"
>
<div class="flex flex-col items-center gap-4 transform -rotate-12">
<img
src="/icons/padlock.svg"
alt=""
class="w-16 h-16 text-gray-400"
style="filter: invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(90%) contrast(90%);"
aria-hidden="true"
/>
<span class="text-2xl font-bold text-gray-400 tracking-wider uppercase whitespace-nowrap">
"Registered Users"
</span>
</div>
</div>
}
}

View file

@ -56,10 +56,14 @@ pub fn RealmSceneViewer(
/// Current user's guest_session_id (for context menu filtering).
#[prop(optional, into)]
current_guest_session_id: Option<Signal<Option<Uuid>>>,
/// Whether the current user is a guest (guests cannot use context menu).
#[prop(optional, into)]
is_guest: Option<Signal<bool>>,
/// Callback when whisper is requested on a member.
#[prop(optional, into)]
on_whisper_request: Option<Callback<String>>,
) -> impl IntoView {
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
// Use default settings if none provided
let settings = settings.unwrap_or_else(|| Signal::derive(ViewerSettings::default));
let dimensions = parse_bounds_dimensions(&scene.bounds_wkt);
@ -183,14 +187,19 @@ pub fn RealmSceneViewer(
move |ev: web_sys::MouseEvent| {
use wasm_bindgen::JsCast;
// Get click position
let client_x = ev.client_x() as f64;
let client_y = ev.client_y() as f64;
// Guests cannot message other users - don't show context menu
if is_guest.get() {
return;
}
// Get current user identity for filtering
let my_user_id = current_user_id.map(|s| s.get()).flatten();
let my_guest_session_id = current_guest_session_id.map(|s| s.get()).flatten();
// Get click position
let client_x = ev.client_x() as f64;
let client_y = ev.client_y() as f64;
// Query all avatar canvases and check for hit
let document = web_sys::window().unwrap().document().unwrap();

View file

@ -60,6 +60,8 @@ pub struct ChannelMemberInfo {
pub guest_session_id: Option<uuid::Uuid>,
/// The user's display name.
pub display_name: String,
/// Whether this user is a guest (has the 'guest' tag).
pub is_guest: bool,
}
/// WebSocket error info for UI display.
@ -240,6 +242,7 @@ pub fn use_channel_websocket(
user_id: member.user_id,
guest_session_id: member.guest_session_id,
display_name: member.display_name.clone(),
is_guest: member.is_guest,
};
callback.run(info);
}

View file

@ -98,6 +98,8 @@ pub fn RealmPage() -> impl IntoView {
// Current user identity (received from WebSocket Welcome message)
let (current_user_id, set_current_user_id) = signal(Option::<Uuid>::None);
let (current_guest_session_id, set_current_guest_session_id) = signal(Option::<Uuid>::None);
// Whether the current user is a guest (has the 'guest' tag)
let (is_guest, set_is_guest) = signal(false);
// Whisper target - when set, triggers pre-fill in ChatInput
let (whisper_target, set_whisper_target) = signal(Option::<String>::None);
@ -313,6 +315,7 @@ pub fn RealmPage() -> impl IntoView {
set_current_user_id.set(info.user_id);
set_current_guest_session_id.set(info.guest_session_id);
set_current_display_name.set(info.display_name.clone());
set_is_guest.set(info.is_guest);
});
// Callback for WebSocket errors (whisper failures, etc.)
@ -772,6 +775,7 @@ pub fn RealmPage() -> impl IntoView {
fading_members=Signal::derive(move || fading_members.get())
current_user_id=Signal::derive(move || current_user_id.get())
current_guest_session_id=Signal::derive(move || current_guest_session_id.get())
is_guest=Signal::derive(move || is_guest.get())
on_whisper_request=on_whisper_request_cb
/>
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none">
@ -823,6 +827,7 @@ pub fn RealmPage() -> impl IntoView {
})
ws_sender=ws_sender_for_inv
realm_slug=Signal::derive(move || slug.get())
is_guest=Signal::derive(move || is_guest.get())
/>
}
}
@ -867,6 +872,7 @@ pub fn RealmPage() -> impl IntoView {
set_skin_preview_path.set(updated.skin_layer[4].clone());
})
ws_sender=ws_sender_for_avatar
is_guest=Signal::derive(move || is_guest.get())
/>
}
}