mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
interinstance ui improvements
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// Inter Instance Panel
|
||||
// Purpose: Renders remote rover servers discovered through the inter-instance directory.
|
||||
// Scope: Owns external server metadata presentation while reusing RoverQueuesPanel for rover/queue rows.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import RoverQueuesPanel from '../RoverQueuesPanel/index.jsx';
|
||||
@@ -133,100 +133,52 @@ function RemoteMediaStrip({ remote }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollableInstanceList({ children }) {
|
||||
const viewportRef = useRef(null);
|
||||
const contentRef = useRef(null);
|
||||
const [canScrollDown, setCanScrollDown] = useState(false);
|
||||
|
||||
const measureScrollRemainder = useCallback(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
|
||||
/*
|
||||
A small tolerance prevents fractional browser measurements from leaving
|
||||
the cue visible when the user is effectively at the bottom. Comparing the
|
||||
live viewport and content dimensions also means the cue only appears when
|
||||
there is genuinely hidden content, rather than merely because several
|
||||
instances happen to exist.
|
||||
*/
|
||||
const remaining = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
||||
setCanScrollDown(remaining > 2);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!viewport || !content) return undefined;
|
||||
|
||||
/*
|
||||
Remote rosters and queues can change height without a window resize. A
|
||||
ResizeObserver on both the viewport and its inner content keeps the cue
|
||||
accurate for those live session updates while avoiding polling timers.
|
||||
*/
|
||||
const observer = new ResizeObserver(measureScrollRemainder);
|
||||
observer.observe(viewport);
|
||||
observer.observe(content);
|
||||
const animationFrame = window.requestAnimationFrame(measureScrollRemainder);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [measureScrollRemainder]);
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
onScroll={measureScrollRemainder}
|
||||
>
|
||||
<div ref={contentRef} className={classNames('space-y-0.5', canScrollDown && 'pb-6')}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{canScrollDown ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-neutral-950 via-neutral-950/90 to-transparent px-1 pb-0.5 pt-5 text-center text-xs font-semibold text-slate-200">
|
||||
Scroll for more ↓
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExternalInstancesCompact() {
|
||||
export function ExternalInstancesCompact({ onBrowse = null }) {
|
||||
const enabled = useInterInstanceEnabled();
|
||||
const instances = useRemoteInstances();
|
||||
const visible = useMemo(() => instances.filter((remote) => remote?.online || remote?.url), [instances]);
|
||||
if (!enabled) return null;
|
||||
if (!visible.length) return null;
|
||||
const browseAction = onBrowse ? (
|
||||
<button type="button" className="button-dark" onClick={onBrowse}>
|
||||
Browse Servers
|
||||
</button>
|
||||
) : null;
|
||||
return (
|
||||
/*
|
||||
External instances are intentionally always mounted. Besides removing an
|
||||
unnecessary disclosure click, this preserves the live queue rows while
|
||||
the local Rover Queues card uses this region as its remaining-height
|
||||
scroller. The viewport cap remains a safety boundary in layouts whose
|
||||
parent has natural height instead of a fixed desktop row height.
|
||||
the local Rover Queues card can provide one continuous scroll surface for
|
||||
both its local and external rows. Scrolling belongs to that owning panel,
|
||||
so this nested section deliberately keeps its natural content height.
|
||||
*/
|
||||
<div className="flex min-h-0 max-h-[min(60vh,36rem)] flex-1 flex-col border-t border-neutral-600/60 pt-0.5">
|
||||
<ScrollableInstanceList>
|
||||
{visible.map((remote) =>
|
||||
remote.online ? (
|
||||
<RoverQueuesPanel
|
||||
key={remote.url}
|
||||
title={remote.instance?.name || remote.url}
|
||||
roster={remote.roster}
|
||||
turnQueues={remote.turnQueues}
|
||||
users={remote.users}
|
||||
externalInstance={remote}
|
||||
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
|
||||
/>
|
||||
) : (
|
||||
<InstancePanel key={remote.url} remote={remote} />
|
||||
),
|
||||
)}
|
||||
</ScrollableInstanceList>
|
||||
</div>
|
||||
<CardFrame
|
||||
title="External servers"
|
||||
actions={browseAction}
|
||||
bodyClassName="space-y-0.5 text-sm"
|
||||
>
|
||||
{/*
|
||||
One containing card gives the remote-server collection a clear boundary
|
||||
below the local rover rows. Individual remote queue cards stay intact
|
||||
inside it because they still own each server's title and operational
|
||||
status, while this outer title bar owns the collection-wide browser.
|
||||
*/}
|
||||
{visible.map((remote) =>
|
||||
remote.online ? (
|
||||
<RoverQueuesPanel
|
||||
key={remote.url}
|
||||
title={remote.instance?.name || remote.url}
|
||||
roster={remote.roster}
|
||||
turnQueues={remote.turnQueues}
|
||||
users={remote.users}
|
||||
externalInstance={remote}
|
||||
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
|
||||
/>
|
||||
) : (
|
||||
<InstancePanel key={remote.url} remote={remote} />
|
||||
),
|
||||
)}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -196,14 +196,18 @@ function QueueReplayLinksRow() {
|
||||
removes that item instead of preserving an empty grid column.
|
||||
*/
|
||||
return (
|
||||
<div className={`flex ${themeGapClass}`}>
|
||||
<div className="min-w-0 basis-0 grow-[1]">
|
||||
<div className={`flex items-stretch ${themeGapClass}`}>
|
||||
<div className="relative min-w-0 basis-0 grow-[1]">
|
||||
{/*
|
||||
The queue card stretches to the desktop row height so its always-open
|
||||
external-instance region receives the same vertical budget as the
|
||||
neighboring replay card and can scroll within that space.
|
||||
The absolutely positioned queue card is removed from flex cross-size
|
||||
calculation. Replay and the links/PTZ stack therefore define the row
|
||||
height entirely through normal CSS layout; this relative column then
|
||||
stretches to that established height and gives the queue card an exact
|
||||
containing block to fill without any JavaScript measurement.
|
||||
*/}
|
||||
<RoverQueuesPanel fillHeight />
|
||||
<div className="absolute inset-0 min-h-0">
|
||||
<RoverQueuesPanel fillHeight />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 basis-0 grow-[0.9]">
|
||||
<ReplaySourcesPanel panelId="replay-sources-desktop" fillHeight />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Rover Queues Panel
|
||||
// Purpose: Defines the Rover Queues Panel module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
@@ -25,6 +25,74 @@ function batteryClass(rover) {
|
||||
return 'text-emerald-300';
|
||||
}
|
||||
|
||||
function ScrollableQueueContent({ enabled = false, children }) {
|
||||
const viewportRef = useRef(null);
|
||||
const contentRef = useRef(null);
|
||||
const [canScrollDown, setCanScrollDown] = useState(false);
|
||||
|
||||
const measureScrollRemainder = useCallback(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
|
||||
/*
|
||||
Fractional layout measurements can leave a sub-pixel remainder even at
|
||||
the bottom. The tolerance keeps the cue from flickering there while still
|
||||
showing it for any meaningful hidden queue content.
|
||||
*/
|
||||
const remaining = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
||||
setCanScrollDown(remaining > 2);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
const viewport = viewportRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!viewport || !content) return undefined;
|
||||
|
||||
/*
|
||||
Queue membership, user chips, and remote instances all update from live
|
||||
session state and may change the content height without resizing the
|
||||
window. Observing both boxes keeps the overflow cue accurate without
|
||||
using JavaScript to calculate or assign the panel's actual height.
|
||||
*/
|
||||
const observer = new ResizeObserver(measureScrollRemainder);
|
||||
observer.observe(viewport);
|
||||
observer.observe(content);
|
||||
const animationFrame = window.requestAnimationFrame(measureScrollRemainder);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [enabled, measureScrollRemainder]);
|
||||
|
||||
if (!enabled) return children;
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
onScroll={measureScrollRemainder}
|
||||
>
|
||||
<div ref={contentRef}>{children}</div>
|
||||
</div>
|
||||
{canScrollDown ? (
|
||||
/*
|
||||
This indicator is deliberately removed from layout so it consumes no
|
||||
permanent panel height. It also adds no padding to the scroll content,
|
||||
keeping scrollHeight stable when the indicator disappears at the
|
||||
bottom. The explicit stacking level and opaque background keep queue
|
||||
cards from painting through or over the message.
|
||||
*/
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-neutral-950 px-1 py-0.5 text-center text-xs font-semibold text-slate-200">
|
||||
Scroll for more ↓
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RoverQueuesPanel({
|
||||
title = 'Rovers',
|
||||
roster: rosterOverride = null,
|
||||
@@ -39,9 +107,6 @@ export default function RoverQueuesPanel({
|
||||
const localTurnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
||||
const localUsers = useSessionSelector((state) => state.session?.users ?? []);
|
||||
const interInstanceEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'interInstance'));
|
||||
const hasRemoteInstances = useSessionSelector(
|
||||
(state) => (state.session?.interInstances?.instances?.length ?? 0) > 0,
|
||||
);
|
||||
const { value: pageSettings } = useSettingsNamespace('page', { interInstanceTransferSettings: true });
|
||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const assignedRoverId = useSessionSelector((state) => String(state.session?.assignment?.roverId || '').trim());
|
||||
@@ -164,20 +229,7 @@ export default function RoverQueuesPanel({
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
const headerActions = !externalMode ? (
|
||||
<>
|
||||
{interInstanceEnabled && hasRemoteInstances ? (
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark"
|
||||
onClick={() => setInterInstancePopupOpen(true)}
|
||||
>
|
||||
Browse servers
|
||||
</button>
|
||||
) : null}
|
||||
{rebootAction}
|
||||
</>
|
||||
) : null;
|
||||
const headerActions = !externalMode ? rebootAction : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -187,75 +239,79 @@ export default function RoverQueuesPanel({
|
||||
fillHeight={fillHeight}
|
||||
bodyClassName="space-y-0.5 text-sm"
|
||||
>
|
||||
<div className={fillHeight ? 'relative flex min-h-0 flex-1 flex-col gap-0.5' : 'relative space-y-0.5'}>
|
||||
{rosterItems.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">No rovers registered.</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5 text-sm">
|
||||
{rosterItems.map((rover) => {
|
||||
const roverId = String(rover.id);
|
||||
const info = turnQueues?.[roverId] || null;
|
||||
const queue = info?.queue || [];
|
||||
const deadline = info?.idleDeadline || info?.deadline || null;
|
||||
const remainingSeconds =
|
||||
deadline && deadline > now ? Math.ceil((deadline - now) / 1000) : deadline ? 0 : null;
|
||||
const currentId = info?.current || null;
|
||||
const currentIdx = currentId ? queue.findIndex((id) => id === currentId) : -1;
|
||||
const nextId =
|
||||
queue.length > 1
|
||||
? currentIdx >= 0
|
||||
? queue[(currentIdx + 1) % queue.length]
|
||||
: queue[0]
|
||||
: null;
|
||||
const isSelfCurrent = Boolean(selfId && currentId && currentId === selfId);
|
||||
const isSelfNext = Boolean(selfId && nextId && nextId === selfId);
|
||||
const showTimer = remainingSeconds != null && (isSelfCurrent || isSelfNext);
|
||||
const isPrivateOpen = Boolean(rover?.private?.enabled && rover?.private?.open);
|
||||
const isGrantedClosedPrivate = Boolean(rover?.private?.enabled && !rover?.private?.open);
|
||||
const locked = Boolean(rover.locked);
|
||||
const lockedBlocked = locked && (externalMode || (!adminCapable && !isGrantedClosedPrivate));
|
||||
const lockLabel = rover.lockReason ? `locked: ${rover.lockReason}` : 'locked';
|
||||
const buttonLabel = pending[roverId]
|
||||
? '...'
|
||||
: lockedBlocked
|
||||
? lockLabel
|
||||
: externalMode
|
||||
? 'Open'
|
||||
: 'request';
|
||||
const canClickRow = canRequest && !lockedBlocked && !pending[roverId];
|
||||
return (
|
||||
<QueueTargetRow
|
||||
key={rover.id}
|
||||
target={{ ...rover, rover, roverId, id: roverId }}
|
||||
queue={queue}
|
||||
currentId={currentId}
|
||||
nextId={nextId}
|
||||
selfId={selfId}
|
||||
lookupUser={lookupUser}
|
||||
canClick={canClickRow}
|
||||
pending={Boolean(pending[roverId])}
|
||||
locked={locked}
|
||||
lockedBlocked={lockedBlocked}
|
||||
privateOpen={isPrivateOpen}
|
||||
buttonLabel={buttonLabel}
|
||||
batteryLabel={formatBattery(rover)}
|
||||
batteryClassName={batteryClass(rover)}
|
||||
timerLabel={showTimer ? (isSelfCurrent ? `${remainingSeconds}s left` : `Your turn in ${remainingSeconds}s`) : ''}
|
||||
thumbnailUrl={externalMode ? rover?.snapshots?.latestUrl : ''}
|
||||
onRequest={handleRequest}
|
||||
showAction={Boolean(canRequest)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{externalBlocked ? (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded bg-black/70 px-2 text-center text-sm font-semibold text-slate-100">
|
||||
{disabledOverlay}
|
||||
</div>
|
||||
) : null}
|
||||
{!externalMode && interInstanceEnabled ? <ExternalInstancesCompact /> : null}
|
||||
</div>
|
||||
<ScrollableQueueContent enabled={fillHeight}>
|
||||
<div className="relative space-y-0.5">
|
||||
{rosterItems.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">No rovers registered.</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5 text-sm">
|
||||
{rosterItems.map((rover) => {
|
||||
const roverId = String(rover.id);
|
||||
const info = turnQueues?.[roverId] || null;
|
||||
const queue = info?.queue || [];
|
||||
const deadline = info?.idleDeadline || info?.deadline || null;
|
||||
const remainingSeconds =
|
||||
deadline && deadline > now ? Math.ceil((deadline - now) / 1000) : deadline ? 0 : null;
|
||||
const currentId = info?.current || null;
|
||||
const currentIdx = currentId ? queue.findIndex((id) => id === currentId) : -1;
|
||||
const nextId =
|
||||
queue.length > 1
|
||||
? currentIdx >= 0
|
||||
? queue[(currentIdx + 1) % queue.length]
|
||||
: queue[0]
|
||||
: null;
|
||||
const isSelfCurrent = Boolean(selfId && currentId && currentId === selfId);
|
||||
const isSelfNext = Boolean(selfId && nextId && nextId === selfId);
|
||||
const showTimer = remainingSeconds != null && (isSelfCurrent || isSelfNext);
|
||||
const isPrivateOpen = Boolean(rover?.private?.enabled && rover?.private?.open);
|
||||
const isGrantedClosedPrivate = Boolean(rover?.private?.enabled && !rover?.private?.open);
|
||||
const locked = Boolean(rover.locked);
|
||||
const lockedBlocked = locked && (externalMode || (!adminCapable && !isGrantedClosedPrivate));
|
||||
const lockLabel = rover.lockReason ? `locked: ${rover.lockReason}` : 'locked';
|
||||
const buttonLabel = pending[roverId]
|
||||
? '...'
|
||||
: lockedBlocked
|
||||
? lockLabel
|
||||
: externalMode
|
||||
? 'Open'
|
||||
: 'request';
|
||||
const canClickRow = canRequest && !lockedBlocked && !pending[roverId];
|
||||
return (
|
||||
<QueueTargetRow
|
||||
key={rover.id}
|
||||
target={{ ...rover, rover, roverId, id: roverId }}
|
||||
queue={queue}
|
||||
currentId={currentId}
|
||||
nextId={nextId}
|
||||
selfId={selfId}
|
||||
lookupUser={lookupUser}
|
||||
canClick={canClickRow}
|
||||
pending={Boolean(pending[roverId])}
|
||||
locked={locked}
|
||||
lockedBlocked={lockedBlocked}
|
||||
privateOpen={isPrivateOpen}
|
||||
buttonLabel={buttonLabel}
|
||||
batteryLabel={formatBattery(rover)}
|
||||
batteryClassName={batteryClass(rover)}
|
||||
timerLabel={showTimer ? (isSelfCurrent ? `${remainingSeconds}s left` : `Your turn in ${remainingSeconds}s`) : ''}
|
||||
thumbnailUrl={externalMode ? rover?.snapshots?.latestUrl : ''}
|
||||
onRequest={handleRequest}
|
||||
showAction={Boolean(canRequest)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{externalBlocked ? (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded bg-black/70 px-2 text-center text-sm font-semibold text-slate-100">
|
||||
{disabledOverlay}
|
||||
</div>
|
||||
) : null}
|
||||
{!externalMode && interInstanceEnabled ? (
|
||||
<ExternalInstancesCompact onBrowse={() => setInterInstancePopupOpen(true)} />
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollableQueueContent>
|
||||
</CardFrame>
|
||||
{interInstancePopupOpen ? (
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user