mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
better um interinstance ui um stuff yeah lole
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 { useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import RoverQueuesPanel from '../RoverQueuesPanel/index.jsx';
|
||||
@@ -133,44 +133,99 @@ 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() {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [popupOpen, setPopupOpen] = useState(false);
|
||||
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;
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="grid grid-cols-2 gap-0.5">
|
||||
<button type="button" className="button-dark w-full" onClick={() => setExpanded((value) => !value)}>
|
||||
{expanded ? 'Hide external' : `Show external (${visible.length})`}
|
||||
</button>
|
||||
<button type="button" className="button-dark w-full" onClick={() => setPopupOpen(true)}>
|
||||
Browse servers
|
||||
</button>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className="space-y-0.5">
|
||||
{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} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{popupOpen ? <InterInstancePopup onClose={() => setPopupOpen(false)} /> : null}
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -180,8 +235,9 @@ export function InterInstancePopup({ onClose }) {
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/80 p-0.5">
|
||||
<InterInstanceBrowserFrame
|
||||
onClose={onClose}
|
||||
className="max-w-[calc(100vw-0.5rem)]"
|
||||
bodyClassName="max-h-[82vh] overflow-y-auto p-0.5"
|
||||
scaledOverlay
|
||||
className="inter-instance-overlay-frame"
|
||||
bodyClassName="inter-instance-overlay-body overflow-y-auto p-0.5"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -226,6 +282,7 @@ export function InterInstanceBrowserFrame({
|
||||
className = '',
|
||||
bodyClassName = 'p-0.5',
|
||||
centered = false,
|
||||
scaledOverlay = false,
|
||||
}) {
|
||||
const enabled = useInterInstanceEnabled();
|
||||
const instances = useRemoteInstances();
|
||||
@@ -245,7 +302,7 @@ export function InterInstanceBrowserFrame({
|
||||
<CardFrame
|
||||
title="External instances"
|
||||
actions={actions}
|
||||
className={className}
|
||||
className={classNames(scaledOverlay && 'inter-instance-overlay-scale', className)}
|
||||
bodyClassName={bodyClassName}
|
||||
clipOverflow={false}
|
||||
>
|
||||
|
||||
@@ -102,8 +102,9 @@ export default function ModeGateOverlay() {
|
||||
*/
|
||||
<InterInstanceBrowserFrame
|
||||
hideWhenEmpty
|
||||
className="max-w-[calc(100vw-0.5rem)]"
|
||||
bodyClassName="max-h-[86vh] overflow-y-auto p-0.5"
|
||||
scaledOverlay
|
||||
className="inter-instance-overlay-frame"
|
||||
bodyClassName="inter-instance-overlay-body overflow-y-auto p-0.5"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -197,8 +197,13 @@ function QueueReplayLinksRow() {
|
||||
*/
|
||||
return (
|
||||
<div className={`flex ${themeGapClass}`}>
|
||||
<div className={`min-w-0 basis-0 grow-[1] space-y-0.5`}>
|
||||
<RoverQueuesPanel />
|
||||
<div className="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.
|
||||
*/}
|
||||
<RoverQueuesPanel fillHeight />
|
||||
</div>
|
||||
<div className="min-w-0 basis-0 grow-[0.9]">
|
||||
<ReplaySourcesPanel panelId="replay-sources-desktop" fillHeight />
|
||||
|
||||
@@ -8,7 +8,7 @@ import CardFrame from '../CardFrame/index.jsx';
|
||||
import QueueTargetRow from '../QueueTargetRow/index.jsx';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
import { openExternalRover } from '../../lib/interInstanceTransfer.js';
|
||||
import { ExternalInstancesCompact } from '../InterInstancePanel/index.jsx';
|
||||
import { ExternalInstancesCompact, InterInstancePopup } from '../InterInstancePanel/index.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
|
||||
@@ -32,12 +32,16 @@ export default function RoverQueuesPanel({
|
||||
users: usersOverride = null,
|
||||
externalInstance = null,
|
||||
disabledOverlay = '',
|
||||
fillHeight = false,
|
||||
}) {
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const localRoster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
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());
|
||||
@@ -50,6 +54,7 @@ export default function RoverQueuesPanel({
|
||||
const { requestControl, rebootOwnRover } = useSessionActions();
|
||||
const [pending, setPending] = useState({});
|
||||
const [rebootPending, setRebootPending] = useState(false);
|
||||
const [interInstancePopupOpen, setInterInstancePopupOpen] = useState(false);
|
||||
const externalMode = Boolean(externalInstance);
|
||||
const externalBlocked = Boolean(externalMode && disabledOverlay);
|
||||
const includeInterInstanceSettings = pageSettings?.interInstanceTransferSettings !== false;
|
||||
@@ -146,8 +151,8 @@ export default function RoverQueuesPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const headerActions =
|
||||
!externalMode && role !== 'spectator' && assignedRoverId ? (
|
||||
const rebootAction =
|
||||
role !== 'spectator' && assignedRoverId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRebootOwnRover}
|
||||
@@ -159,14 +164,35 @@ 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;
|
||||
|
||||
return (
|
||||
<CardFrame title={title} actions={headerActions} bodyClassName="space-y-0.5 text-sm">
|
||||
<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) => {
|
||||
<>
|
||||
<CardFrame
|
||||
title={title}
|
||||
actions={headerActions}
|
||||
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 || [];
|
||||
@@ -220,16 +246,25 @@ export default function RoverQueuesPanel({
|
||||
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>
|
||||
</CardFrame>
|
||||
})}
|
||||
</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>
|
||||
</CardFrame>
|
||||
{interInstancePopupOpen ? (
|
||||
/*
|
||||
The popup remains owned by the local Rover Queues panel because its
|
||||
title-bar action opens it. External queue panels never render that
|
||||
action, which prevents recursively opening browsers from remote rows.
|
||||
*/
|
||||
<InterInstancePopup onClose={() => setInterInstancePopupOpen(false)} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user