diff --git a/rulesdocs/refector_rules_and_tracking.md b/rulesdocs/refector_rules_and_tracking.md index 79329123..67bb2ca6 100644 --- a/rulesdocs/refector_rules_and_tracking.md +++ b/rulesdocs/refector_rules_and_tracking.md @@ -9,6 +9,7 @@ - Preserve imports/exports and call signatures unless internal-only and non-observable. - Validate no runtime behavior changes (manual flow checks + targeted tests when available). - Make small, reviewable commits per service/component area. +- Treat `npm run build` output files committed into this repo as intentional deployment artifacts; do not discard them as noise. ## Server backend - Convert every service into a folder-based structure. @@ -56,7 +57,7 @@ ## WebUI frontend ### BIGGEST OFFENDERS - [x] mini summary app -- [ ] spectator app +- [x] spectator app - [ ] vip audio upload card - [ ] admin panel - [ ] drive dock action @@ -68,9 +69,11 @@ ### COMPLETED COMPONENTS - mini summary app +- spectator app ### LARGE CHANGES - Split `webui/src/mini/MiniSummaryApp.jsx` into folderized modules under `webui/src/mini/MiniSummaryApp/` with a compatibility entrypoint preserved. +- Split `webui/src/spectate/SpectatorApp.jsx` into folderized modules under `webui/src/spectate/SpectatorApp/` with a compatibility entrypoint preserved. ## Done criteria (per item) - [ ] Folderized structure created. diff --git a/webui/src/spectate/SpectatorApp.jsx b/webui/src/spectate/SpectatorApp.jsx index edb468b9..93404c52 100644 --- a/webui/src/spectate/SpectatorApp.jsx +++ b/webui/src/spectate/SpectatorApp.jsx @@ -1,231 +1,3 @@ -import { useEffect, useState } from 'react'; -import { useSession } from '../context/SessionContext.jsx'; -import { useSpectatorMode } from '../hooks/useSpectatorMode.js'; -import { useTelemetryFrames } from '../context/TelemetryContext.jsx'; -import { useVideoRequests } from '../hooks/useVideoRequests.js'; -import { useRoverSnapshots } from '../hooks/useRoverSnapshots.js'; -import VideoTile from '../components/VideoTile.jsx'; -import RoomCameraPanel from '../components/RoomCameraPanel.jsx'; -import ChatPanel from '../components/ChatPanel.jsx'; -import LogPanel from '../components/LogPanel.jsx'; -import AlertFeed from '../components/AlertFeed.jsx'; -import useDefaultNickname from '../hooks/useDefaultNickname.js'; -import CommunityGoalBanner from '../components/CommunityGoalBanner.jsx'; -import RoverQueuesPanel from '../components/RoverQueuesPanel.jsx'; -import RawUserPilePanel from '../components/RawUserPilePanel.jsx'; -import ButtonBoxPanel from '../components/ButtonBoxPanel.jsx'; -import RewardRunOverlay from '../components/RewardRunOverlay.jsx'; +import SpectatorAppRoot from './SpectatorApp/SpectatorAppRoot.jsx'; -function usePortraitLayout() { - const [isPortrait, setIsPortrait] = useState(() => { - if (typeof window === 'undefined') return false; - return window.matchMedia('(max-aspect-ratio: 4/3)').matches; - }); - - useEffect(() => { - if (typeof window === 'undefined') return undefined; - const media = window.matchMedia('(max-aspect-ratio: 4/3)'); - const handleChange = (event) => setIsPortrait(event.matches); - if (media.addEventListener) { - media.addEventListener('change', handleChange); - } else { - media.addListener(handleChange); - } - return () => { - if (media.removeEventListener) { - media.removeEventListener('change', handleChange); - } else { - media.removeListener(handleChange); - } - }; - }, []); - - return isPortrait; -} - -function formatDriverLabel({ roverId, session }) { - const activeDriverId = session?.activeDrivers?.[roverId] || null; - const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId); - const label = user?.nickname || (activeDriverId ? activeDriverId.slice(0, 6) : 'No driver'); - const mode = session?.mode; - const turnInfo = session?.turnQueues?.[roverId]; - const driverText = mode === 'turns' && turnInfo?.current ? `${label} (turns)` : label; - - return driverText; -} - -function RoverSpectatorCard({ rover, frame, sessionInfo, videoMode, snapshotFeed, audioInfo, session }) { - const driverLabel = formatDriverLabel({ roverId: rover.id, session }); - return ( -
-
- -
-
- ); -} - -function RoverRow({ roster, frames, videoSources, snapshotFeeds, audioSources, session, canSpectateVideo }) { - if (roster.length === 0) { - return

No rovers registered.

; - } - return ( -
- {roster.map((rover) => ( - - ))} -
- ); -} - -function SecondaryRow() { - return ( -
-
- -
-
- ); -} - -function LogsRow({ className = '' }) { - return ( -
- -
- ); -} - -function SpectatorContent() { - const { session } = useSession(); - const inLockdown = session?.mode === 'lockdown'; - const canSpectateVideo = Boolean(session?.isLocalNetwork); - useDefaultNickname(); - useSpectatorMode(); - const isPortraitLayout = usePortraitLayout(); - const frames = useTelemetryFrames(); - const roster = session?.roster ?? []; - const snapshotFeeds = useRoverSnapshots( - roster.map((rover) => rover.id), - { enabled: !inLockdown && !canSpectateVideo, version: session?.mode }, - ); - const videoEntries = canSpectateVideo - ? roster.map((rover) => ({ type: 'rover', id: rover.id, key: rover.id })) - : []; - const videoSources = useVideoRequests(videoEntries, { enabled: !inLockdown && canSpectateVideo, version: session?.mode }); - const audioEntries = roster.flatMap((rover) => - rover.media?.audioPublishUrl - ? [{ type: 'rover', id: `${rover.id}-audio`, key: `${rover.id}-audio` }] - : [], - ); - const audioSources = useVideoRequests(audioEntries, { enabled: !inLockdown, version: session?.mode }); - - if (inLockdown) { - return ( -
-
-

Spectate disabled during lockdown.

-

Please wait until the server leaves lockdown to view streams.

-
-
- ); - } - - const mainClass = isPortraitLayout - ? 'flex min-h-screen flex-col bg-black text-slate-100 md:h-screen md:overflow-hidden' - : 'grid min-h-screen grid-cols-1 gap-0.5 bg-black text-slate-100 md:h-full md:min-h-0 md:grid-cols-[minmax(0,1fr)_18rem] lg:grid-cols-[minmax(0,1fr)_20rem]'; - const contentClass = isPortraitLayout - ? 'order-2 flex min-h-0 min-w-0 flex-1 flex-col gap-0.5 overflow-y-auto' - : 'order-1 flex min-h-0 min-w-0 flex-col gap-0.5 md:overflow-y-auto'; - const sidebarClass = isPortraitLayout - ? 'order-1 grid w-full min-h-0 items-stretch gap-0.5 border-b border-slate-800/60 bg-slate-950/90 p-0.5 grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)_minmax(0,1.9fr)_minmax(0,0.7fr)]' - : 'order-2 flex min-h-0 min-w-0 flex-col gap-0.5 border-l border-slate-800/60 bg-slate-950/90 md:h-full md:overflow-y-auto'; - const topBarItemClass = isPortraitLayout ? '' : ''; - const portraitItemHeight = isPortraitLayout ? 'h-64' : ''; - - return ( -
-
-
- {isPortraitLayout ? ( -
- - -
- -
-
- ) : ( -
- - -
- )} -
-
- -
-
- {!isPortraitLayout ? ( -
- -
- ) : null} -
- -
-
- -
-
-
- - -
-
- - -
- ); -} - -export default function SpectatorApp() { - return ; -} +export default SpectatorAppRoot; diff --git a/webui/src/spectate/SpectatorApp/SpectatorAppRoot.jsx b/webui/src/spectate/SpectatorApp/SpectatorAppRoot.jsx new file mode 100644 index 00000000..1a1ed8ec --- /dev/null +++ b/webui/src/spectate/SpectatorApp/SpectatorAppRoot.jsx @@ -0,0 +1,6 @@ +// Spectator app compatibility root. +import SpectatorContent from './SpectatorContent.jsx'; + +export default function SpectatorAppRoot() { + return ; +} diff --git a/webui/src/spectate/SpectatorApp/SpectatorContent.jsx b/webui/src/spectate/SpectatorApp/SpectatorContent.jsx new file mode 100644 index 00000000..59352fb5 --- /dev/null +++ b/webui/src/spectate/SpectatorApp/SpectatorContent.jsx @@ -0,0 +1,122 @@ +// Main spectator screen layout and feed wiring. +import { useSession } from '../../context/SessionContext.jsx'; +import { useTelemetryFrames } from '../../context/TelemetryContext.jsx'; +import { useVideoRequests } from '../../hooks/useVideoRequests.js'; +import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js'; +import { useSpectatorMode } from '../../hooks/useSpectatorMode.js'; +import useDefaultNickname from '../../hooks/useDefaultNickname.js'; +import ChatPanel from '../../components/ChatPanel.jsx'; +import AlertFeed from '../../components/AlertFeed.jsx'; +import CommunityGoalBanner from '../../components/CommunityGoalBanner.jsx'; +import RoverQueuesPanel from '../../components/RoverQueuesPanel.jsx'; +import RawUserPilePanel from '../../components/RawUserPilePanel.jsx'; +import ButtonBoxPanel from '../../components/ButtonBoxPanel.jsx'; +import RewardRunOverlay from '../../components/RewardRunOverlay.jsx'; +import usePortraitLayout from './hooks/usePortraitLayout.js'; +import RoverRow from './components/RoverRow.jsx'; +import SecondaryRow from './components/SecondaryRow.jsx'; +import LogsRow from './components/LogsRow.jsx'; + +export default function SpectatorContent() { + const { session } = useSession(); + const inLockdown = session?.mode === 'lockdown'; + const canSpectateVideo = Boolean(session?.isLocalNetwork); + useDefaultNickname(); + useSpectatorMode(); + const isPortraitLayout = usePortraitLayout(); + const frames = useTelemetryFrames(); + const roster = session?.roster ?? []; + const snapshotFeeds = useRoverSnapshots( + roster.map((rover) => rover.id), + { enabled: !inLockdown && !canSpectateVideo, version: session?.mode }, + ); + const videoEntries = canSpectateVideo + ? roster.map((rover) => ({ type: 'rover', id: rover.id, key: rover.id })) + : []; + const videoSources = useVideoRequests(videoEntries, { + enabled: !inLockdown && canSpectateVideo, + version: session?.mode, + }); + const audioEntries = roster.flatMap((rover) => + rover.media?.audioPublishUrl + ? [{ type: 'rover', id: `${rover.id}-audio`, key: `${rover.id}-audio` }] + : [], + ); + const audioSources = useVideoRequests(audioEntries, { enabled: !inLockdown, version: session?.mode }); + + if (inLockdown) { + return ( +
+
+

Spectate disabled during lockdown.

+

Please wait until the server leaves lockdown to view streams.

+
+
+ ); + } + + const mainClass = isPortraitLayout + ? 'flex min-h-screen flex-col bg-black text-slate-100 md:h-screen md:overflow-hidden' + : 'grid min-h-screen grid-cols-1 gap-0.5 bg-black text-slate-100 md:h-full md:min-h-0 md:grid-cols-[minmax(0,1fr)_18rem] lg:grid-cols-[minmax(0,1fr)_20rem]'; + const contentClass = isPortraitLayout + ? 'order-2 flex min-h-0 min-w-0 flex-1 flex-col gap-0.5 overflow-y-auto' + : 'order-1 flex min-h-0 min-w-0 flex-col gap-0.5 md:overflow-y-auto'; + const sidebarClass = isPortraitLayout + ? 'order-1 grid w-full min-h-0 items-stretch gap-0.5 border-b border-slate-800/60 bg-slate-950/90 p-0.5 grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)_minmax(0,1.9fr)_minmax(0,0.7fr)]' + : 'order-2 flex min-h-0 min-w-0 flex-col gap-0.5 border-l border-slate-800/60 bg-slate-950/90 md:h-full md:overflow-y-auto'; + const topBarItemClass = isPortraitLayout ? '' : ''; + const portraitItemHeight = isPortraitLayout ? 'h-64' : ''; + + return ( +
+
+
+ {isPortraitLayout ? ( +
+ + +
+ +
+
+ ) : ( +
+ + +
+ )} +
+
+ +
+
+ {!isPortraitLayout ? ( +
+ +
+ ) : null} +
+ +
+
+ +
+
+
+ + +
+
+ + +
+ ); +} diff --git a/webui/src/spectate/SpectatorApp/components/LogsRow.jsx b/webui/src/spectate/SpectatorApp/components/LogsRow.jsx new file mode 100644 index 00000000..c05fd9e9 --- /dev/null +++ b/webui/src/spectate/SpectatorApp/components/LogsRow.jsx @@ -0,0 +1,10 @@ +// Log panel wrapper row for spectator layout. +import LogPanel from '../../../components/LogPanel.jsx'; + +export default function LogsRow({ className = '' }) { + return ( +
+ +
+ ); +} diff --git a/webui/src/spectate/SpectatorApp/components/RoverRow.jsx b/webui/src/spectate/SpectatorApp/components/RoverRow.jsx new file mode 100644 index 00000000..7a7efe27 --- /dev/null +++ b/webui/src/spectate/SpectatorApp/components/RoverRow.jsx @@ -0,0 +1,26 @@ +// Rover grid row for spectator view. +import RoverSpectatorCard from './RoverSpectatorCard.jsx'; + +export default function RoverRow({ roster, frames, videoSources, snapshotFeeds, audioSources, session, canSpectateVideo }) { + if (roster.length === 0) { + return

No rovers registered.

; + } + return ( +
+ {roster.map((rover) => ( + + ))} +
+ ); +} diff --git a/webui/src/spectate/SpectatorApp/components/RoverSpectatorCard.jsx b/webui/src/spectate/SpectatorApp/components/RoverSpectatorCard.jsx new file mode 100644 index 00000000..71656cd1 --- /dev/null +++ b/webui/src/spectate/SpectatorApp/components/RoverSpectatorCard.jsx @@ -0,0 +1,27 @@ +// Single rover spectator card containing live/snapshot tile and HUD. +import VideoTile from '../../../components/VideoTile.jsx'; +import { formatDriverLabel } from '../utils.js'; + +export default function RoverSpectatorCard({ rover, frame, sessionInfo, videoMode, snapshotFeed, audioInfo, session }) { + const driverLabel = formatDriverLabel({ roverId: rover.id, session }); + return ( +
+
+ +
+
+ ); +} diff --git a/webui/src/spectate/SpectatorApp/components/SecondaryRow.jsx b/webui/src/spectate/SpectatorApp/components/SecondaryRow.jsx new file mode 100644 index 00000000..58b20e1e --- /dev/null +++ b/webui/src/spectate/SpectatorApp/components/SecondaryRow.jsx @@ -0,0 +1,17 @@ +// Secondary panel row showing room cameras. +import RoomCameraPanel from '../../../components/RoomCameraPanel.jsx'; + +export default function SecondaryRow() { + return ( +
+
+ +
+
+ ); +} diff --git a/webui/src/spectate/SpectatorApp/hooks/usePortraitLayout.js b/webui/src/spectate/SpectatorApp/hooks/usePortraitLayout.js new file mode 100644 index 00000000..c43d0115 --- /dev/null +++ b/webui/src/spectate/SpectatorApp/hooks/usePortraitLayout.js @@ -0,0 +1,29 @@ +// Detect portrait-oriented viewport layout mode for spectator UI. +import { useEffect, useState } from 'react'; + +export default function usePortraitLayout() { + const [isPortrait, setIsPortrait] = useState(() => { + if (typeof window === 'undefined') return false; + return window.matchMedia('(max-aspect-ratio: 4/3)').matches; + }); + + useEffect(() => { + if (typeof window === 'undefined') return undefined; + const media = window.matchMedia('(max-aspect-ratio: 4/3)'); + const handleChange = (event) => setIsPortrait(event.matches); + if (media.addEventListener) { + media.addEventListener('change', handleChange); + } else { + media.addListener(handleChange); + } + return () => { + if (media.removeEventListener) { + media.removeEventListener('change', handleChange); + } else { + media.removeListener(handleChange); + } + }; + }, []); + + return isPortrait; +} diff --git a/webui/src/spectate/SpectatorApp/utils.js b/webui/src/spectate/SpectatorApp/utils.js new file mode 100644 index 00000000..d1349f12 --- /dev/null +++ b/webui/src/spectate/SpectatorApp/utils.js @@ -0,0 +1,11 @@ +// Spectator app display formatting helpers. +export function formatDriverLabel({ roverId, session }) { + const activeDriverId = session?.activeDrivers?.[roverId] || null; + const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId); + const label = user?.nickname || (activeDriverId ? activeDriverId.slice(0, 6) : 'No driver'); + const mode = session?.mode; + const turnInfo = session?.turnQueues?.[roverId]; + const driverText = mode === 'turns' && turnInfo?.current ? `${label} (turns)` : label; + + return driverText; +}