From 4ba545b77bb63a849a95cc64d2a0dc82e7fd6bcb Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Fri, 28 Aug 2026 18:19:52 -0400 Subject: [PATCH] feat(responsive): enhance Minesweeper mobile experience with viewport adjustments and sidebar behavior --- src/components/frontend/eod/EodArena.tsx | 17 +++- .../frontend/eod/MinesweeperBoard.tsx | 97 ++++++++++++++++--- 2 files changed, 101 insertions(+), 13 deletions(-) diff --git a/src/components/frontend/eod/EodArena.tsx b/src/components/frontend/eod/EodArena.tsx index ea39db9..6c67de2 100644 --- a/src/components/frontend/eod/EodArena.tsx +++ b/src/components/frontend/eod/EodArena.tsx @@ -1,8 +1,9 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import type { EodDifficulty, EodStats } from "@/app/(frontend)/eod/actions"; import { useRealtimePresence } from "@/hooks/useRealtimePresence"; +import { useSidebar } from "@/components/ui/sidebar"; import { EodLeaderboard } from "./EodLeaderboard"; import { MinesweeperBoard } from "./MinesweeperBoard"; @@ -16,11 +17,25 @@ export function EodArena({ const [refreshKey, setRefreshKey] = useState(0); const [playing, setPlaying] = useState(false); const [selectedDifficulty, setSelectedDifficulty] = useState("training"); + const { isMobile, setOpen, setOpenMobile } = useSidebar(); const activeTeams = useRealtimePresence("eod", playing, true); const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => { setPlaying(phase === "playing"); }, []); + useEffect(() => { + const landscapeQuery = window.matchMedia("(orientation: landscape)"); + const hideSidebarForLandscapeGame = () => { + if (!playing || !landscapeQuery.matches) return; + if (isMobile) setOpenMobile(false); + else setOpen(false); + }; + + hideSidebarForLandscapeGame(); + landscapeQuery.addEventListener("change", hideSidebarForLandscapeGame); + return () => landscapeQuery.removeEventListener("change", hideSidebarForLandscapeGame); + }, [isMobile, playing, setOpen, setOpenMobile]); + return (
> = { 8: "text-gray-400", }; +function getMobileConfig(config: GameConfig, landscape: boolean): GameConfig { + if (landscape) return config; + + const rows = Math.max(1, Math.ceil((config.rows * config.cols) / MOBILE_COLUMN_COUNT)); + const cells = rows * MOBILE_COLUMN_COUNT; + return { + ...config, + rows, + cols: MOBILE_COLUMN_COUNT, + mines: Math.min(config.mines, cells - CUSTOM_MAX_SAFE_MINES_BUFFER), + }; +} + function emptyBoard(size: number): Board { return Array.from({ length: size }, () => ({ mine: false, @@ -199,12 +213,17 @@ export function MinesweeperBoard({ const [stats, setStats] = useState(serverStats); const [lastRun, setLastRun] = useState(null); const [submitting, setSubmitting] = useState(false); + const [isMobile, setIsMobile] = useState(false); + const [isLandscape, setIsLandscape] = useState(false); + const [isNarrow, setIsNarrow] = useState(false); const generatedRef = useRef(false); + const previousViewportRef = useRef(null); const submittingRef = useRef(false); const statsRef = useRef(serverStats); const longPressRef = useRef(false); const longPressTimerRef = useRef(null); - const config: GameConfig = difficulty === "custom" ? customConfig : DIFFICULTIES[difficulty]; + const baseConfig: GameConfig = difficulty === "custom" ? customConfig : DIFFICULTIES[difficulty]; + const config = isMobile ? getMobileConfig(baseConfig, isLandscape) : baseConfig; const customUnlocked = stats.bestTrainingTime > 0 && stats.bestPerimeterTime > 0 && stats.bestSupplyRoadTime > 0; const isLargeCustomGame = @@ -223,6 +242,27 @@ export function MinesweeperBoard({ onPhaseChange("ready"); }, [onPhaseChange]); + useEffect(() => { + const mobileQuery = window.matchMedia("(max-width: 639px)"); + const landscapeQuery = window.matchMedia("(orientation: landscape)"); + const narrowQuery = window.matchMedia("(max-width: 1023px)"); + const updateViewport = () => { + setIsMobile(mobileQuery.matches); + setIsLandscape(landscapeQuery.matches); + setIsNarrow(narrowQuery.matches); + }; + + updateViewport(); + mobileQuery.addEventListener("change", updateViewport); + landscapeQuery.addEventListener("change", updateViewport); + narrowQuery.addEventListener("change", updateViewport); + return () => { + mobileQuery.removeEventListener("change", updateViewport); + landscapeQuery.removeEventListener("change", updateViewport); + narrowQuery.removeEventListener("change", updateViewport); + }; + }, []); + useEffect(() => { if (phase !== "playing") return; const timer = window.setInterval(() => { @@ -240,7 +280,11 @@ export function MinesweeperBoard({ const reset = useCallback( (nextDifficulty: GameMode = difficulty) => { if (submittingRef.current) return; - const nextConfig = nextDifficulty === "custom" ? customConfig : DIFFICULTIES[nextDifficulty]; + const selectedConfig = + nextDifficulty === "custom" ? customConfig : DIFFICULTIES[nextDifficulty]; + const nextConfig = isMobile + ? getMobileConfig(selectedConfig, isLandscape) + : selectedConfig; setDifficulty(nextDifficulty); if (nextDifficulty !== "custom") onDifficultyChange(nextDifficulty); setBoard(emptyBoard(nextConfig.rows * nextConfig.cols)); @@ -249,9 +293,20 @@ export function MinesweeperBoard({ setLastRun(null); setPhaseSafe("ready"); }, - [customConfig, difficulty, onDifficultyChange, setPhaseSafe], + [customConfig, difficulty, isLandscape, isMobile, onDifficultyChange, setPhaseSafe], ); + useEffect(() => { + const viewportKey = `${isMobile}-${isLandscape}`; + if (previousViewportRef.current === null) { + previousViewportRef.current = viewportKey; + return; + } + if (previousViewportRef.current === viewportKey) return; + previousViewportRef.current = viewportKey; + reset(); + }, [isLandscape, isMobile, reset]); + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if ( @@ -426,15 +481,23 @@ export function MinesweeperBoard({ const renderGrid = (modal = false) => (
@@ -452,7 +515,7 @@ export function MinesweeperBoard({ aria-colindex={col + 1} disabled={phase === "over" || cell.revealed} className={cn( - "flex size-7 items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + "flex size-[var(--cell-size)] items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", cell.revealed && !cell.mine && (highContrast @@ -740,10 +803,20 @@ export function MinesweeperBoard({
{!isLargeCustomGame && ( -
+
@@ -761,7 +834,7 @@ export function MinesweeperBoard({ aria-colindex={col + 1} disabled={phase === "over" || cell.revealed} className={cn( - "flex size-7 items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + "flex size-[var(--cell-size)] items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", cell.revealed && !cell.mine && (highContrast