Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/Menu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import TabStrips from './menu/TabStrips.svelte';
import VoiceChat from './menu/VoiceChat.svelte';
import AnnotationPopover from './menu/AnnotationPopover.svelte';
import NotesDrawer from './menu/NotesDrawer.svelte';
import Library from './menu/Library.svelte';
import { DarkMode } from 'flowbite-svelte';
import Users from './menu/Users.svelte';
Expand Down Expand Up @@ -49,6 +50,7 @@
<TabStrips />
<VoiceChat />
<AnnotationPopover />
<NotesDrawer />
<Library />
<Toasts />
<Users />
Expand Down
67 changes: 67 additions & 0 deletions src/components/menu/NotesDrawer.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<script>
// E2 (roadmap #13): scene-notes drawer — the missing "see every note" surface.
// Right-docked list of all annotations; a row flies the camera to the pin and
// opens its note (openAnnotation), and can be deleted inline. Toggled from the
// notes button in the top-right chrome (Users.svelte).
import { notesDrawerOpen } from '../../stores/appStore.js';
import { annotations, openAnnotation, deleteAnnotation } from '$lib/annotationsHandler';
import { objectsGroup } from '../../stores/sceneStore.js';

/** @param {string} uuid */
function labelFor(uuid) {
const g = $objectsGroup;
const o = g && g.getObjectByProperty ? g.getObjectByProperty('uuid', uuid) : null;
return o?.name || o?.type || uuid.slice(0, 8);
}

/** @param {number} ts */
function when(ts) {
try {
return new Date(ts).toLocaleString();
} catch {
return '';
}
}
</script>

{#if $notesDrawerOpen}
<aside id="notes-drawer" class="ui-panel flex flex-col" style="position: fixed; right: 0; top: 64px; bottom: max(var(--bottom-inset, 0px), var(--controls-inset, 0px)); width: min(320px, 92vw); z-index: calc(var(--z-bottom) - 1); border-radius: 0.5rem 0 0 0.5rem;">
<div class="ui-panel-header shrink-0 justify-between">
<span>Scene notes {#if $annotations.length}<span class="text-xs text-gray-400">({$annotations.length})</span>{/if}</span>
<button class="ui-button-quiet" title="Close" aria-label="Close notes" onclick={() => notesDrawerOpen.set(false)}>✕</button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-2">
{#if !$annotations.length}
<p class="px-1 py-6 text-center text-sm text-gray-400">
No notes yet. Select an object and add a note from its context menu or the object list.
</p>
{:else}
<ul class="flex flex-col gap-1.5">
{#each $annotations as a (a.id)}
<li class="group rounded bg-gray-800/60 hover:bg-gray-700/60">
<div class="flex items-start gap-2 p-2">
<button
class="min-w-0 flex-1 text-left"
title="Fly to this note"
onclick={() => openAnnotation(a.id)}
>
<div class="truncate text-sm text-gray-100">{a.text || '(empty note)'}</div>
<div class="mt-0.5 flex items-center gap-1.5 truncate text-[10px] text-gray-500">
<span class="rounded bg-gray-700/70 px-1 text-gray-300">{labelFor(a.objectUuid)}</span>
<span class="truncate">{a.author || 'peer'} · {when(a.ts)}</span>
</div>
</button>
<button
class="shrink-0 text-gray-500 hover:text-red-400"
title="Delete note"
aria-label="Delete note"
onclick={() => deleteAnnotation(a.id)}
>✕</button>
</div>
</li>
{/each}
</ul>
{/if}
</div>
</aside>
{/if}
75 changes: 75 additions & 0 deletions src/components/menu/NotificationCenter.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<script>
// E1 (roadmap #13): notification center. A bell with an unread badge; the panel
// is the SCROLLABLE history of everything that flashed as a toast, so a message
// missed (or dismissed while a modal was open) is still recoverable. Placed in the
// top-right chrome next to the peers/profile cluster (Users.svelte), mirroring the
// peers popover pattern (absolute dropdown, click-catcher backdrop).
import { notifications, notificationsUnread, notificationCenterOpen } from '../../stores/appStore.js';

function toggle() {
const willOpen = !$notificationCenterOpen;
notificationCenterOpen.set(willOpen);
if (willOpen) notificationsUnread.set(0); // opening clears the badge
}

function clearAll() {
notifications.set([]);
notificationsUnread.set(0);
}

/** @param {number} ts */
function ago(ts) {
const s = Math.floor((Date.now() - ts) / 1000);
if (s < 60) return 'just now';
const m = Math.floor(s / 60);
if (m < 60) return m + 'm ago';
const h = Math.floor(m / 60);
if (h < 24) return h + 'h ago';
return Math.floor(h / 24) + 'd ago';
}
</script>

<div class="relative">
<button
id="notif-bell"
class="relative flex h-8 w-8 items-center justify-center rounded-full border border-gray-700/60 bg-gray-800/85 text-gray-200 backdrop-blur hover:bg-gray-700/85"
title="Notifications"
aria-label="Notifications"
onclick={toggle}
>
<i class="fas fa-bell text-xs"></i>
{#if $notificationsUnread > 0}
<span
class="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-semibold text-white"
>
{$notificationsUnread > 9 ? '9+' : $notificationsUnread}
</span>
{/if}
</button>

{#if $notificationCenterOpen}
<div class="fixed inset-0" style="z-index: 996;" role="presentation" onclick={() => notificationCenterOpen.set(false)}></div>
<div id="notif-panel" class="ui-panel absolute right-0 top-10 w-80 p-2" style="z-index: 998;">
<div class="mb-1 flex items-center justify-between">
<p class="ui-section-label">Notifications</p>
{#if $notifications.length}
<button class="text-[11px] text-gray-400 hover:text-gray-200" onclick={clearAll}>Clear all</button>
{/if}
</div>
<div class="max-h-[60vh] overflow-y-auto">
{#if !$notifications.length}
<p class="px-1 py-4 text-center text-xs text-gray-400">No notifications yet.</p>
{:else}
<ul class="flex flex-col gap-1">
{#each [...$notifications].reverse() as n (n.id)}
<li class="rounded bg-gray-800/60 px-2 py-1.5">
<div class="text-xs text-gray-100">{n.text}</div>
<div class="mt-0.5 text-[10px] text-gray-500">{ago(n.ts)}</div>
</li>
{/each}
</ul>
{/if}
</div>
</div>
{/if}
</div>
82 changes: 42 additions & 40 deletions src/components/menu/Toasts.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,11 @@ if (--counter > 0) return setTimeout(timeout, 4000);
toastStatus = false;
}
</script>
<!-- pointer-events: none lets clicks pass through the (invisible) container area;
each toast re-enables them for itself -->
<div class="my-4 toasts-container"
<!-- E1: CRITICAL container — connection requests + pending outbound requests stay
ABOVE modals (--z-toast) so an approval is never missed while a modal is open. -->
<div class="my-4 toasts-container toasts-critical"
style="left: 50%; max-width: 500px; transform: translate(-50%, 0%); z-index: var(--z-toast); pointer-events: none;"
>
{#if showToast}
{#if $loadingcount > 0}
<Toast dismissable={false} transition={fly} bind:toastStatus>
<div class="mb-1 text-base font-medium text-green-700 dark:text-green-500">Receiving objects: {($loadingcount-$loading.length)}/{$loadingcount}</div>
<Progressbar progress="{100 * (($loadingcount-$loading.length) - 0) / ($loadingcount - 0)}" color="green" />
</Toast>
{/if}
{/if}


{#each $pendingApprovals as approval}
<div class="my-1">
{#if approval.status != 'retry'}
Expand All @@ -69,7 +59,7 @@ style="left: 50%; max-width: 500px; transform: translate(-50%, 0%); z-index: var

</div>
<div class="mb-1 text-base font-medium text-green-700 dark:text-green-500 inline-flex items-center">

<p class="text-sm font-medium text-gray-500 dark:text-gray-200 pr-4 overflow-hidden max-w-80">
Connection request from peer:&nbsp;{approval.peerId}
</p>
Expand Down Expand Up @@ -103,7 +93,7 @@ style="left: 50%; max-width: 500px; transform: translate(-50%, 0%); z-index: var

</div>
<div class="mb-1 text-base font-medium text-green-700 dark:text-green-500 inline-flex items-center">

<p class="text-sm font-medium text-gray-500 dark:text-gray-200 pr-4 overflow-hidden max-w-80">
Connection &nbsp;{approval.peerId} already exists
</p>
Expand All @@ -115,14 +105,9 @@ style="left: 50%; max-width: 500px; transform: translate(-50%, 0%); z-index: var
onclick={() => {
console.log($peers.connections[approval.peerId])
$peers.connections[approval.peerId].close();
// $peers.peers[approval.peerId].close()
// Remove approved peer from pending approvals
$pendingApprovals = $pendingApprovals.filter(peer => peer.peerId !== approval.peerId);

// Add peer to user data (whitelist)
// let data = [approval.peerId, '', '']
// $userdata.push(data);

// Broadcast updated whitelist to all connected peers
$peers.send({type: 'userdata', userdata: $userdata})

Expand All @@ -138,6 +123,43 @@ style="left: 50%; max-width: 500px; transform: translate(-50%, 0%); z-index: var
</div>
{/each}

{#each $waitingForApproval as status}
{#if status[1] === 'pending'}
<div class="my-1">
<Toast transition={fly} class="p-2 rounded-lg dark:bg-green-800 dark:border-dark-700 border-2 border-green-500" divClass="flex items-center gap-3">
<div style="position: relative; left: 50%; transform: translate(-25%, -50%);">

</div>
<div class="mb-1 text-base font-medium text-green-700 dark:text-green-500 inline-flex items-center">

<p class="text-sm font-medium text-gray-500 dark:text-gray-400 pr-4 overflow-hidden max-w-80">
Connection request to peer:&nbsp;{status[0]} <br />
Status: {status[1]}
</p>
</div>

</Toast>
</div>
{/if}
{/each}
</div>

<!-- pointer-events: none lets clicks pass through the (invisible) container area;
each toast re-enables them for itself. REGULAR container: info/decision toasts
sit BELOW modals (--z-toast-low) so Settings/Modules/Sessions cover them. -->
<div class="my-4 toasts-container toasts-regular"
style="left: 50%; max-width: 500px; transform: translate(-50%, 0%); z-index: var(--z-toast-low); pointer-events: none;"
>
{#if showToast}
{#if $loadingcount > 0}
<Toast dismissable={false} transition={fly} bind:toastStatus>
<div class="mb-1 text-base font-medium text-green-700 dark:text-green-500">Receiving objects: {($loadingcount-$loading.length)}/{$loadingcount}</div>
<Progressbar progress="{100 * (($loadingcount-$loading.length) - 0) / ($loadingcount - 0)}" color="green" />
</Toast>
{/if}
{/if}


{#if $restoreAvailable}
<div class="my-1">
<Toast dismissable={false} transition={fly} class="p-2 rounded-lg dark:bg-gray-700 dark:border-dark-700 border-2 border-blue-500" divClass="flex items-center gap-3">
Expand Down Expand Up @@ -263,26 +285,6 @@ style="left: 50%; max-width: 500px; transform: translate(-50%, 0%); z-index: var
</div>
{/if}

{#each $waitingForApproval as status}
{#if status[1] === 'pending'}
<div class="my-1">
<Toast transition={fly} class="p-2 rounded-lg dark:bg-green-800 dark:border-dark-700 border-2 border-green-500" divClass="flex items-center gap-3">
<div style="position: relative; left: 50%; transform: translate(-25%, -50%);">

</div>
<div class="mb-1 text-base font-medium text-green-700 dark:text-green-500 inline-flex items-center">

<p class="text-sm font-medium text-gray-500 dark:text-gray-400 pr-4 overflow-hidden max-w-80">
Connection request to peer:&nbsp;{status[0]} <br />
Status: {status[1]}
</p>
</div>

</Toast>
</div>
{/if}
{/each}

{#if $toastStore.length > MAX_TOASTS}
<div class="my-1 text-center text-xs text-gray-400">+{$toastStore.length - MAX_TOASTS} more…</div>
{/if}
Expand Down
18 changes: 16 additions & 2 deletions src/components/menu/Users.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@
userdata,
peers,
hidePanels,
characterModalOpen
characterModalOpen,
notesDrawerOpen
} from '../../stores/appStore.js';
import { globalScene, globalCamera, camSave, peerHands } from '../../stores/sceneStore.js';
import { mutedPeers, toggleMutePeer } from '$lib/voiceChat';
import { peerQuality } from '$lib/networkQuality';
import ContextMenu from '../ContextMenu.svelte';
import NotificationCenter from './NotificationCenter.svelte';

// N3: latency-band dot color for a peer's network-quality indicator
const qColor = (level: string) =>
Expand Down Expand Up @@ -118,7 +120,19 @@
</script>

<div class="top-right-chrome" style="position: fixed; right: 0px; z-index: 997;">
<div class="flex" style=" position: absolute; top: 15px; right: 100px; z-index: 997;">
<div class="flex items-center gap-2" style=" position: absolute; top: 15px; right: 100px; z-index: 997;">
<!-- E2: scene-notes drawer toggle -->
<button
id="notes-toggle"
class="flex h-8 w-8 items-center justify-center rounded-full border border-gray-700/60 bg-gray-800/85 text-gray-200 backdrop-blur hover:bg-gray-700/85 {$notesDrawerOpen ? 'ring-2 ring-primary-500/60' : ''}"
title="Scene notes"
aria-label="Scene notes"
onclick={() => notesDrawerOpen.update((v) => !v)}
>
<i class="fas fa-note-sticky text-xs"></i>
</button>
<!-- E1: notifications bell + history panel -->
<NotificationCenter />
{#if $userdata && $userdata.length > 1}
<div class="relative">
<!-- compact trigger: a few stacked avatars + the peer count -->
Expand Down
42 changes: 42 additions & 0 deletions src/stores/appStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,46 @@ enable3dPreview.subscribe((on) => {
if (typeof localStorage !== 'undefined') localStorage.setItem('enable3dPreview', String(on));
});

// E1 (roadmap #13): notification center — a persisted history of everything that
// flashed as a toast, so a message missed (or dismissed while a modal was open) is
// still recoverable. The bell + panel live in NotificationCenter.svelte.
/** @type {import('svelte/store').Writable<any[]>} */
export const notifications = writable(
(() => {
if (typeof localStorage === 'undefined') return [];
try {
return JSON.parse(localStorage.getItem('notifications') || '[]');
} catch {
return [];
}
})()
);
notifications.subscribe((list) => {
if (typeof localStorage === 'undefined') return;
try {
localStorage.setItem('notifications', JSON.stringify(list.slice(-50)));
} catch {
/* storage full / disabled */
}
});
/** count of notifications arrived since the center was last opened */
export const notificationsUnread = writable(0);
/** the notification center panel open state */
export const notificationCenterOpen = writable(false);
/** E2: the scene-notes drawer (lists every annotation) open state */
export const notesDrawerOpen = writable(false);
let _notifId = 0;
/**
* Append a notification to the history + bump the unread badge.
* @param {string} text @param {string} [kind]
*/
export function pushNotification(text, kind = 'info') {
if (!text) return;
const entry = { id: `n${Date.now()}_${++_notifId}`, text, ts: Date.now(), kind };
notifications.update((list) => [...list, entry].slice(-50));
notificationsUnread.update((c) => c + 1);
}

/**
* Plain string = 3s info toast. Pass `actions` ([{label, action}]) for a
* sticky decision toast (15s) with buttons.
Expand All @@ -219,6 +259,8 @@ export function showToast(message, actions) {
if (!actions && toast.some((entry) => entry === message)) return toast;
return [...toast, actions ? { text: message, actions } : message];
});
// E1: every toast also lands in the notification history
pushNotification(message, actions ? 'action' : 'info');
}

export function clearToast(toast) {
Expand Down
4 changes: 4 additions & 0 deletions src/styles/ui.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
(~996-998) and Connect (300) — which live outside this scale, so a modal and
its X are never covered. Kept below ThemedSelect (9999) so in-modal dropdowns
still open over the modal. */
/* E1: regular/info toasts drop BELOW modals so Settings/Modules/Sessions cover
them; connection-request (action) toasts keep the high --z-toast tier so an
approval is never missed while a modal is open. */
--z-toast-low: 1050;
--z-modal: 1100;
--z-toast: 1200;
/* the logo/burger menu is the TOP-most persistent chrome — above Connect (300)
Expand Down
Loading