From 55ccf097d93700a6705acdbad2272286985185e5 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Mon, 13 Oct 2025 11:25:21 -0500 Subject: [PATCH] refactor: update core UI components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update core UI components for new room system: - AbacusDisplayDropdown: Enhanced styling and accessibility - AppNavBar: Integration with room info and moderation - PageWithNav: Room-aware page wrapper 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/components/AbacusDisplayDropdown.tsx | 5 +- apps/web/src/components/AppNavBar.tsx | 6 +- apps/web/src/components/PageWithNav.tsx | 137 ++++++++++-------- 3 files changed, 87 insertions(+), 61 deletions(-) diff --git a/apps/web/src/components/AbacusDisplayDropdown.tsx b/apps/web/src/components/AbacusDisplayDropdown.tsx index f4017574..d85f6ee2 100644 --- a/apps/web/src/components/AbacusDisplayDropdown.tsx +++ b/apps/web/src/components/AbacusDisplayDropdown.tsx @@ -15,7 +15,10 @@ interface AbacusDisplayDropdownProps { onOpenChange?: (open: boolean) => void } -export function AbacusDisplayDropdown({ isFullscreen = false, onOpenChange: onOpenChangeProp }: AbacusDisplayDropdownProps) { +export function AbacusDisplayDropdown({ + isFullscreen = false, + onOpenChange: onOpenChangeProp, +}: AbacusDisplayDropdownProps) { const [open, setOpen] = useState(false) const { config, updateConfig, resetToDefaults } = useAbacusDisplay() diff --git a/apps/web/src/components/AppNavBar.tsx b/apps/web/src/components/AppNavBar.tsx index 19f19ac7..2013f455 100644 --- a/apps/web/src/components/AppNavBar.tsx +++ b/apps/web/src/components/AppNavBar.tsx @@ -122,7 +122,10 @@ function HamburgerMenu({ onInteractOutside={(e) => { // Don't close the hamburger menu when clicking inside the nested style dropdown const target = e.target as HTMLElement - if (target.closest('[role="dialog"]') || target.closest('[data-radix-popper-content-wrapper]')) { + if ( + target.closest('[role="dialog"]') || + target.closest('[data-radix-popper-content-wrapper]') + ) { e.preventDefault() } }} @@ -619,4 +622,3 @@ function NavLink({ ) } - diff --git a/apps/web/src/components/PageWithNav.tsx b/apps/web/src/components/PageWithNav.tsx index 721de69f..5980772a 100644 --- a/apps/web/src/components/PageWithNav.tsx +++ b/apps/web/src/components/PageWithNav.tsx @@ -2,21 +2,21 @@ import React from 'react' import { useGameMode } from '../contexts/GameModeContext' -import { useArcadeGuard } from '../hooks/useArcadeGuard' import { useRoomData } from '../hooks/useRoomData' import { useViewerId } from '../hooks/useViewerId' import { AppNavBar } from './AppNavBar' import { GameContextNav } from './nav/GameContextNav' import { PlayerConfigDialog } from './nav/PlayerConfigDialog' +import { ModerationNotifications } from './nav/ModerationNotifications' interface PageWithNavProps { navTitle?: string navEmoji?: string + gameName?: 'matching' | 'memory-quiz' | 'complement-race' // Internal game name for API emphasizeGameContext?: boolean onExitSession?: () => void onSetup?: () => void onNewGame?: () => void - canModifyPlayers?: boolean children: React.ReactNode // Game state for turn indicator currentPlayerId?: string @@ -27,54 +27,69 @@ interface PageWithNavProps { export function PageWithNav({ navTitle, navEmoji, + gameName, emphasizeGameContext = false, onExitSession, onSetup, onNewGame, - canModifyPlayers = true, children, currentPlayerId, playerScores, playerStreaks, }: PageWithNavProps) { const { players, activePlayers, setActive, activePlayerCount } = useGameMode() - const { hasActiveSession, activeSession } = useArcadeGuard({ - enabled: false, - }) // Don't redirect, just get info - const { roomData, isInRoom } = useRoomData() + const { roomData, isInRoom, moderationEvent, clearModerationEvent } = useRoomData() const { data: viewerId } = useViewerId() const [mounted, setMounted] = React.useState(false) const [configurePlayerId, setConfigurePlayerId] = React.useState(null) + // Lift AddPlayerButton popover state here to survive GameContextNav remounts + const [showPopover, setShowPopover] = React.useState(false) + const [activeTab, setActiveTab] = React.useState<'add' | 'invite'>('add') + // Delay mounting animation slightly for smooth transition React.useEffect(() => { const timer = setTimeout(() => setMounted(true), 50) return () => clearTimeout(timer) }, []) - const handleRemovePlayer = (playerId: string) => { - if (!canModifyPlayers) return - setActive(playerId, false) - } + const handleRemovePlayer = React.useCallback( + (playerId: string) => { + setActive(playerId, false) + }, + [setActive] + ) - const handleAddPlayer = (playerId: string) => { - if (!canModifyPlayers) return - setActive(playerId, true) - } + const handleAddPlayer = React.useCallback( + (playerId: string) => { + setActive(playerId, true) + }, + [setActive] + ) - const handleConfigurePlayer = (playerId: string) => { - setConfigurePlayerId(playerId) - } + const handleConfigurePlayer = React.useCallback( + (playerId: string) => { + setConfigurePlayerId(playerId) + }, + [setConfigurePlayerId] + ) // Get active and inactive players as arrays // Only show LOCAL players in the active/inactive lists (remote players shown separately in networkPlayers) - const activePlayerList = Array.from(activePlayers) - .map((id) => players.get(id)) - .filter((p): p is NonNullable => p !== undefined && p.isLocal !== false) // Filter out remote players + // Memoized to prevent unnecessary re-renders + const activePlayerList = React.useMemo( + () => + Array.from(activePlayers) + .map((id) => players.get(id)) + .filter((p): p is NonNullable => p !== undefined && p.isLocal !== false), + [activePlayers, players] + ) - const inactivePlayerList = Array.from(players.values()).filter( - (p) => !activePlayers.has(p.id) && p.isLocal !== false - ) // Filter out remote players + const inactivePlayerList = React.useMemo( + () => + Array.from(players.values()).filter((p) => !activePlayers.has(p.id) && p.isLocal !== false), + [players, activePlayers] + ) // Compute game mode from active player count const gameMode = @@ -92,49 +107,51 @@ export function PageWithNav({ const showFullscreenSelection = shouldEmphasize && activePlayerCount === 0 // Compute arcade session info for display - const roomInfo = - isInRoom && roomData - ? { - roomName: roomData.name, - gameName: roomData.gameName, - playerCount: roomData.members.length, - joinCode: roomData.code, - } - : hasActiveSession && activeSession + // Memoized to prevent unnecessary re-renders + const roomInfo = React.useMemo( + () => + isInRoom && roomData ? { - gameName: activeSession.currentGame, - playerCount: activePlayerCount, + roomId: roomData.id, + roomName: roomData.name, + gameName: roomData.gameName, + playerCount: roomData.members?.length ?? 0, + joinCode: roomData.code, } - : undefined + : undefined, + [isInRoom, roomData] + ) // Compute network players (other players in the room, excluding current user) - const networkPlayers: Array<{ - id: string - emoji?: string - name?: string - color?: string - memberName?: string - }> = - isInRoom && roomData - ? roomData.members - .filter((member) => member.userId !== viewerId) - .flatMap((member) => { - const memberPlayerList = roomData.memberPlayers[member.userId] || [] - return memberPlayerList.map((player) => ({ - id: player.id, - emoji: player.emoji, - name: player.name, - color: player.color, - memberName: member.displayName, - })) - }) - : [] + // Memoized to prevent unnecessary re-renders + const networkPlayers = React.useMemo(() => { + if (!isInRoom || !roomData?.members || !roomData?.memberPlayers) { + return [] + } + + return roomData.members + .filter((member) => member.userId !== viewerId) + .flatMap((member) => { + const memberPlayerList = roomData.memberPlayers[member.userId] || [] + return memberPlayerList.map((player) => ({ + id: player.id, + emoji: player.emoji, + name: player.name, + color: player.color, + memberName: member.displayName, + userId: member.userId, // Add userId for moderation + isOnline: member.isOnline, + })) + }) + }, [isInRoom, roomData, viewerId]) // Create nav content if title is provided + // Pass lifted state to preserve popover state across remounts const navContent = navTitle ? ( ) : null @@ -165,6 +185,7 @@ export function PageWithNav({ onClose={() => setConfigurePlayerId(null)} /> )} + ) }