// 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 (
);
}
export function LinkButtonsPanel({ className = '' }) {
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 (
);
}
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 turnQueues = useSessionSelector((state) => state.session?.turnQueues || {});
const roster = useSessionSelector((state) => state.session?.roster || []);
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 lookupUser = useCallback(
(socketId) => users.find((u) => u.socketId === socketId) || { socketId, nickname: null, role: null },
[users],
);
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 ? (
Waiting for users…
) : (
sorted.map((user) => {
const isAdmin =
user.role === 'admin' || user.role === 'lockdown';
return (
{formatLabel(user, selfId)}
{user.roverId ? (
) : (
no rover
)}
{isAdmin && (
Admin
)}
);
})
);
return (
{!hideNicknameForm && (
)}
{!hideHeader && showToggle ? (
setTurnView('queues')}
>
Queues
setTurnView('users')}
>
Users
) : null}
{showQueuesSection ? (
{Object.keys(turnQueues || {}).length === 0 ? (
No turn queues yet.
) : (
Object.entries(turnQueues).map(([roverId, info]) => {
const queue = info?.queue || [];
const deadline = info?.idleDeadline || info?.deadline || null;
const remaining = secondsRemaining(deadline);
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;
return (
{remaining != null && (
{remaining}s left
)}
{queue.length === 0 ? (
No drivers queued.
) : (
{queue.map((socketId, idx) => {
const user = lookupUser(socketId);
const isCurrent = socketId === currentId;
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
const isSelf = Boolean(selfId && socketId === selfId);
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 (
{formatLabel(user, selfId)}
{isAdmin && ★ }
{/* {isSelf && YOU } */}
{isCurrent && now }
{isNext && next }
);
})}
)}
);
})
)}
) : null}
{showUsersSection ? (
{renderUserList()}
) : null}
{showUsersSecondary ? (
Users
{sorted.length}
{renderUserList()}
) : null}
);
}