Files
MultiRoombaRover/webui/src/components/UserListPanel/index.jsx
T

284 lines
11 KiB
React

// User List Panel
// Purpose: Defines the User List 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 { useEffect, useMemo, useState, useCallback } from 'react';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { isFeatureEnabled } from '../../lib/features.js';
import NicknameForm from '../NicknameForm/index.jsx';
import SocialButtonsGrid from '../SocialButtonsGrid/index.jsx';
import CardFrame from '../CardFrame/index.jsx';
import RoverLabel from '../RoverLabel/index.jsx';
export function NicknameEntryPanel({ compact = false }) {
return (
<CardFrame hideHeader className="h-full" bodyClassName="flex h-full min-h-0 flex-col gap-0.5 text-base">
<div className="flex w-full items-center px-0 py-0">
<NicknameForm compact={compact} />
</div>
</CardFrame>
);
}
export function LinkButtonsPanel({ className = '', fillHeight = true }) {
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'socials'));
/*
This component is the actual Links panel shell. SocialButtonsGrid already
hides the buttons when socials are disabled, but the shell must also hide
itself so the UI does not leave an empty "Links!" card behind.
*/
if (!enabled) return null;
return (
<CardFrame
title="Links!"
fillHeight={fillHeight}
className={className}
bodyClassName="flex flex-1 min-h-0 flex-col gap-0.5 text-base"
>
<SocialButtonsGrid className="flex-1 min-h-0" />
</CardFrame>
);
}
function roleColors(role) {
switch (role) {
case 'admin':
case 'lockdown':
return 'text-amber-300';
case 'spectator':
return 'text-slate-400';
default:
return 'text-sky-300';
}
}
function formatLabel(user, selfId) {
if (!user) return '';
const base = user.nickname || user.socketId?.slice(0, 6) || 'unknown';
if (user.socketId && user.socketId === selfId) {
return `${base} (you)`;
}
return base;
}
export default function UserListPanel({
hideNicknameForm = false,
hideHeader = false,
className = '',
fillHeight = false,
compact = false,
showBothTurnsAndUsers = false,
}) {
const users = useSessionSelector((state) => state.session?.users ?? []);
const selfId = useSessionSelector((state) => state.session?.socketId || null);
const mode = useSessionSelector((state) => state.session?.mode || null);
const roster = useSessionSelector((state) => state.session?.roster || []);
const queuedRovers = roster.filter((rover) => rover.turn?.queue != null);
const isTurnsMode = mode === 'turns';
const [turnView, setTurnView] = useState('queues');
useEffect(() => {
if (!isTurnsMode) return;
setTurnView('queues');
}, [isTurnsMode]);
const sorted = useMemo(
() =>
[...users].sort((a, b) => {
if (a.socketId === selfId) return -1;
if (b.socketId === selfId) return 1;
return (a.nickname || '').localeCompare(b.nickname || '');
}),
[selfId, users],
);
const rosterEntry = useCallback(
(roverId) => roster.find((r) => String(r.id) === String(roverId)) || null,
[roster],
);
const secondsRemaining = useCallback((deadline) => {
if (!deadline) return null;
const ms = deadline - Date.now();
if (ms <= 0) return 0;
return Math.ceil(ms / 1000);
}, []);
const baseListClass = fillHeight
? 'flex-1 min-h-0 overflow-y-auto'
: compact
? 'h-28 overflow-y-auto'
: 'h-48 overflow-y-auto';
const turnsListClass =
isTurnsMode && fillHeight
? 'max-h-40 overflow-y-auto'
: isTurnsMode && compact
? 'max-h-32 overflow-y-auto'
: baseListClass;
const usersListClass =
isTurnsMode && fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : baseListClass;
const showToggle = isTurnsMode && !showBothTurnsAndUsers;
const showQueuesSection = isTurnsMode && (showBothTurnsAndUsers || turnView === 'queues');
const showUsersSection = !isTurnsMode || (showToggle && turnView === 'users');
const showUsersSecondary = isTurnsMode && showBothTurnsAndUsers;
const renderUserList = () =>
sorted.length === 0 ? (
<p className="text-sm text-slate-500">Waiting for users…</p>
) : (
sorted.map((user) => {
const isAdmin =
user.role === 'admin' || user.role === 'lockdown';
return (
<div
key={user.socketId}
className={`flex items-center gap-0.5 ${compact ? 'py-0.25 text-[0.8rem]' : 'text-sm'}`}
>
<p className={`font-semibold ${roleColors(user.role)}`}>{formatLabel(user, selfId)}</p>
{user.roverId || user.operatingModeDisplay ? (
<RoverLabel
roverId={user.roverId}
name={user.operatingModeDisplay?.name}
color={user.operatingModeDisplay?.color || rosterEntry(user.roverId)?.color}
fallback={user.roverId}
className="text-[0.7rem]"
/>
) : (
<span className="text-[0.7rem] text-slate-500">no rover</span>
)}
{isAdmin && (
<span className="rounded-sm bg-amber-500/30 px-1 text-[0.7rem] text-amber-200">
Admin
</span>
)}
</div>
);
})
);
return (
<CardFrame
title={!hideHeader ? (isTurnsMode ? (showQueuesSection ? 'Turn queues' : 'Users') : 'Users') : ''}
meta={!hideHeader ? (showQueuesSection && isTurnsMode ? queuedRovers.length : sorted.length) : null}
hideHeader={hideHeader}
fillHeight={fillHeight}
className={className}
bodyClassName="space-y-0.5 text-base"
>
{!hideNicknameForm && (
<div className="space-y-0.5">
{/* The shared panel can be mounted in a narrow column on a wide desktop,
so this split must follow the card width rather than the viewport. */}
<div className="grid gap-0.5 @sm:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
<div className="min-w-0">
<div className="flex w-full items-center px-0 py-0">
<NicknameForm compact={compact} />
</div>
</div>
<SocialButtonsGrid />
</div>
</div>
)}
<div className={`space-y-0.5 ${fillHeight ? 'flex flex-1 min-h-0 flex-col' : ''}`}>
{!hideHeader && showToggle ? (
<div className="flex justify-end">
<div className="inline-flex overflow-hidden rounded-sm border border-slate-700 text-[0.7rem]">
<button
type="button"
className={`px-1 py-0.5 ${turnView === 'queues' ? 'bg-slate-600 text-white' : 'bg-transparent text-slate-400 hover:text-white'}`}
onClick={() => setTurnView('queues')}
>
Queues
</button>
<button
type="button"
className={`px-1 py-0.5 ${turnView === 'users' ? 'bg-slate-600 text-white' : 'bg-transparent text-slate-400 hover:text-white'}`}
onClick={() => setTurnView('users')}
>
Users
</button>
</div>
</div>
) : null}
{showQueuesSection ? (
<div className={`space-y-0.5 px-0 pb-0 ${turnsListClass} ${compact ? 'text-[0.8rem]' : ''}`}>
{queuedRovers.length === 0 ? (
<p className="text-sm text-slate-500">No turn queues yet.</p>
) : (
queuedRovers.map(({ id: roverId, turn: info }) => {
const queue = info?.queue || [];
const deadline = info?.idleDeadline || info?.deadline || null;
const remaining = secondsRemaining(deadline);
const { currentId, nextId } = info;
return (
<div key={roverId} className={`flex flex-col gap-0.5 ${compact ? 'text-[0.8rem] py-0.25' : 'text-sm'}`}>
<div className="flex items-center gap-0.5">
<p className="font-semibold text-slate-200">
<RoverLabel roverId={roverId} fallback={roverId} />
</p>
{remaining != null && (
<span className="rounded-sm bg-slate-800 px-1 text-[0.7rem]">
{remaining}s left
</span>
)}
</div>
{queue.length === 0 ? (
<p className="text-[0.75rem] text-slate-500">No drivers queued.</p>
) : (
<div className="flex flex-wrap items-center gap-0.5">
{queue.map((user, idx) => {
const { socketId } = user;
const isCurrent = socketId === currentId;
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
const isAdmin =
user.role === 'admin' || user.role === 'lockdown';
const highlightClass = isCurrent
? 'bg-sky-600 text-white ring-2 ring-amber-300 animate-pulse'
: isNext
? 'bg-emerald-700/60 text-emerald-100 ring-1 ring-emerald-300/70'
: 'bg-slate-800 text-slate-200';
return (
<span
key={`${roverId}-${socketId}-${idx}`}
className={`flex items-center gap-0.5 rounded-sm px-1 ${compact ? 'text-[0.7rem]' : 'text-[0.8rem]'} ${highlightClass}`}
>
<span className={`${roleColors(user.role)} font-semibold`}>
{user.name}{socketId === selfId ? ' (you)' : ''}
</span>
{isAdmin && <span className="text-[0.7rem] text-amber-200">★</span>}
{isCurrent && <span className="text-[0.7rem] text-slate-200">now</span>}
{isNext && <span className="text-[0.7rem] text-emerald-100">next</span>}
</span>
);
})}
</div>
)}
</div>
);
})
)}
</div>
) : null}
{showUsersSection ? (
<div className={`space-y-0.5 px-0 pb-0 ${usersListClass} ${compact ? 'text-[0.8rem]' : ''}`}>
{renderUserList()}
</div>
) : null}
{showUsersSecondary ? (
<div className={`space-y-0.5 ${fillHeight ? 'flex min-h-0 flex-1 flex-col' : ''}`}>
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Users</span>
<span className="text-[0.7rem] text-slate-500">{sorted.length}</span>
</div>
<div className={`space-y-0.5 px-0 pb-0 ${usersListClass}`}>{renderUserList()}</div>
</div>
) : null}
</div>
</CardFrame>
);
}