ui: inform guests of restricted interfaces
This commit is contained in:
parent
1f922f8221
commit
39750c1d82
8 changed files with 137 additions and 49 deletions
|
|
@ -1829,6 +1829,9 @@ pub struct ChannelMemberInfo {
|
|||
/// Current emotion slot (0-9)
|
||||
pub current_emotion: i16,
|
||||
pub joined_at: DateTime<Utc>,
|
||||
/// Whether this user is a guest (has the 'guest' tag)
|
||||
#[serde(default)]
|
||||
pub is_guest: bool,
|
||||
}
|
||||
|
||||
/// Request to update position in a channel.
|
||||
|
|
|
|||
|
|
@ -176,7 +176,8 @@ pub async fn get_channel_members<'e>(
|
|||
cm.is_moving,
|
||||
cm.is_afk,
|
||||
COALESCE(aa.current_emotion, 0::smallint) as current_emotion,
|
||||
cm.joined_at
|
||||
cm.joined_at,
|
||||
COALESCE('guest' = ANY(u.tags), false) as is_guest
|
||||
FROM scene.instance_members cm
|
||||
LEFT JOIN auth.users u ON cm.user_id = u.id
|
||||
LEFT JOIN auth.guest_sessions gs ON cm.guest_session_id = gs.id
|
||||
|
|
@ -214,7 +215,8 @@ pub async fn get_channel_member<'e>(
|
|||
cm.is_moving,
|
||||
cm.is_afk,
|
||||
COALESCE(aa.current_emotion, 0::smallint) as current_emotion,
|
||||
cm.joined_at
|
||||
cm.joined_at,
|
||||
COALESCE('guest' = ANY(u.tags), false) as is_guest
|
||||
FROM scene.instance_members cm
|
||||
LEFT JOIN auth.users u ON cm.user_id = u.id
|
||||
LEFT JOIN auth.active_avatars aa ON cm.user_id = aa.user_id AND aa.realm_id = $3
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue