1
0
Fork 0

feat(responsive): enhance Minesweeper mobile experience with viewport adjustments and sidebar behavior

This commit is contained in:
Jason Fraley 2026-08-28 18:19:52 -04:00
parent 67744767ec
commit 4ba545b77b
2 changed files with 101 additions and 13 deletions

View file

@ -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<EodDifficulty>("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 (
<div className="flex flex-col items-center gap-6">
<MinesweeperBoard

View file

@ -2,7 +2,7 @@
// allow: SIZE_OK — the board is a single interactive game state machine required by the route contract.
import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react";
import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import { Bomb, Flag, RotateCcw, ShieldCheck, Timer, X } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
@ -37,6 +37,7 @@ const CUSTOM_MIN_SIZE = 9;
const CUSTOM_MAX_SIZE = 500;
const CUSTOM_MAX_SAFE_MINES_BUFFER = 9;
const CUSTOM_FULLSCREEN_CELL_THRESHOLD = 900;
const MOBILE_COLUMN_COUNT = 10;
type Cell = {
readonly mine: boolean;
@ -91,6 +92,19 @@ const NUMBER_CLASSES: Readonly<Record<number, string>> = {
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<EodStats>(serverStats);
const [lastRun, setLastRun] = useState<LastRun | null>(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<string | null>(null);
const submittingRef = useRef(false);
const statsRef = useRef<EodStats>(serverStats);
const longPressRef = useRef(false);
const longPressTimerRef = useRef<number | null>(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) => (
<div
className={cn(
"overflow-auto rounded-lg border border-border bg-background",
"min-w-0 max-w-full overflow-auto rounded-lg border border-border bg-background",
modal
? "min-h-full min-w-full items-start justify-start p-2"
: "flex items-center justify-center p-2",
)}
style={{ containerType: "inline-size" }}
>
<div
className="grid min-w-max gap-1"
style={{ gridTemplateColumns: `repeat(${config.cols}, 1.75rem)` }}
className="grid min-w-max grid-cols-[repeat(var(--board-cols),var(--cell-size))] gap-1"
style={
{
"--board-cols": config.cols,
"--cell-size": isNarrow
? `min(1.75rem, calc((100cqw - ${(config.cols - 1) * 0.25}rem) / ${config.cols}))`
: "1.75rem",
} as CSSProperties
}
role="grid"
aria-label={`${config.label} minefield`}
>
@ -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({
</div>
{!isLargeCustomGame && (
<div className="overflow-x-auto rounded-lg border border-border bg-background p-2 items-center justify-center flex">
<div
className="flex min-w-0 max-w-full items-center justify-center overflow-auto rounded-lg border border-border bg-background p-2"
style={{ containerType: "inline-size" }}
>
<div
className="grid min-w-max gap-1"
style={{ gridTemplateColumns: `repeat(${config.cols}, 1.75rem)` }}
className="grid min-w-max grid-cols-[repeat(var(--board-cols),var(--cell-size))] gap-1"
style={
{
"--board-cols": config.cols,
"--cell-size": isNarrow
? `min(1.75rem, calc((100cqw - ${(config.cols - 1) * 0.25}rem) / ${config.cols}))`
: "1.75rem",
} as CSSProperties
}
role="grid"
aria-label={`${config.label} minefield`}
>
@ -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