Sitemap: - Switch from ISR (revalidate=3600) to force-dynamic for per-request generation - Flatten into single app/sitemap.ts with inlined static entries and DB queries - NULL-safe syncStatus filter: ne(games.syncStatus, 'failed') OR isNull(games.syncStatus) - Structured JSON logging for generated URLs and DB errors - Delete lib/sitemap/* helpers and app/api/revalidate-sitemap route - Add basic vitest coverage for sitemap exports PWA & Offline: - Add @serwist/next service worker (webpack build) with runtime caching - Cache strategies: stale-while-revalidate for game pages, network-first for listings/API, cache-first for Steam CDN images - Offline fallback page (public/offline.html) - Manifest icons: 192px maskable + 512px any - Viewport meta with viewport-fit=cover, user-scalable=no - Apple mobile web app meta tags Search / Filter Refinements: - Multi-genre OR support in listing API (comma-separated genres) - Device-scoped FPS filter (min/max FPS constrained to selected device) - Client-side URL state sync via router.replace for shareable filtered views - Initialize filter state from URL params on mount - Auto-collapse filter panel on saved-filter load - Fix multi-genre saved filter parsing (comma-separated) Steam Deck / Touch / Gamepad UX: - WCAG 2.1 AA touch targets (44x44px) on all filter controls - .gamepad-focus CSS focus ring for controller navigation - useGamepadNavigation hook: D-pad/left-stick roving tabindex, A/B/X/Y actions - Integrate gamepad hook into games page (X=search, Y=toggle filters) Chore: - Bump version to 2026.0.97
212 lines
5.6 KiB
TypeScript
212 lines
5.6 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useRef, useState } from "react"
|
|
|
|
const AXIS_THRESHOLD = 0.5
|
|
const DEBOUNCE_MS = 150
|
|
|
|
interface GamepadNavigationOptions {
|
|
/** CSS selector for focusable elements within the container */
|
|
focusSelector?: string
|
|
/** Callback when X button is pressed (typically opens search) */
|
|
onXButton?: () => void
|
|
/** Callback when Y button is pressed (typically toggles filters) */
|
|
onYButton?: () => void
|
|
}
|
|
|
|
/**
|
|
* Hook for gamepad (Steam Deck controller) navigation.
|
|
*
|
|
* Activates only when a gamepad button press is detected.
|
|
* Deactivates on mouse movement or keyboard input.
|
|
* Uses roving tabindex pattern for D-pad and left stick navigation.
|
|
* A button = activate, B button = back, X/Y = context-specific actions.
|
|
* L1/R1 = previous/next tab (if applicable).
|
|
*/
|
|
export function useGamepadNavigation(
|
|
containerRef: React.RefObject<HTMLElement | null>,
|
|
options: GamepadNavigationOptions = {},
|
|
) {
|
|
const {
|
|
focusSelector = 'a, button, [role="button"], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
|
onXButton,
|
|
onYButton,
|
|
} = options
|
|
|
|
const [isGamepadActive, setIsGamepadActive] = useState(false)
|
|
const currentIndexRef = useRef(-1)
|
|
const lastInputTimeRef = useRef(0)
|
|
const rafRef = useRef<number>(0)
|
|
|
|
// Deactivate gamepad mode on mouse or keyboard input
|
|
useEffect(() => {
|
|
if (!isGamepadActive) return
|
|
|
|
const handleMouseMovement = () => {
|
|
setIsGamepadActive(false)
|
|
}
|
|
|
|
const handleKeyboardInput = (e: KeyboardEvent) => {
|
|
// Allow Tab key to coexist with gamepad navigation
|
|
if (e.key !== "Tab") {
|
|
setIsGamepadActive(false)
|
|
}
|
|
}
|
|
|
|
window.addEventListener("mousemove", handleMouseMovement)
|
|
window.addEventListener("keydown", handleKeyboardInput)
|
|
|
|
return () => {
|
|
window.removeEventListener("mousemove", handleMouseMovement)
|
|
window.removeEventListener("keydown", handleKeyboardInput)
|
|
}
|
|
}, [isGamepadActive])
|
|
|
|
// Main gamepad polling loop
|
|
useEffect(() => {
|
|
let activated = false
|
|
|
|
const poll = () => {
|
|
const gamepads = navigator.getGamepads?.()
|
|
|
|
if (!gamepads) {
|
|
rafRef.current = requestAnimationFrame(poll)
|
|
return
|
|
}
|
|
|
|
// Find the first connected gamepad
|
|
let gamepad: Gamepad | null = null
|
|
for (const gp of gamepads) {
|
|
if (gp) {
|
|
gamepad = gp
|
|
break
|
|
}
|
|
}
|
|
|
|
if (!gamepad) {
|
|
rafRef.current = requestAnimationFrame(poll)
|
|
return
|
|
}
|
|
|
|
const now = performance.now()
|
|
if (now - lastInputTimeRef.current < DEBOUNCE_MS) {
|
|
rafRef.current = requestAnimationFrame(poll)
|
|
return
|
|
}
|
|
|
|
const container = containerRef.current
|
|
if (!container) {
|
|
rafRef.current = requestAnimationFrame(poll)
|
|
return
|
|
}
|
|
|
|
// Auto-activate on first gamepad input
|
|
if (!isGamepadActive && !activated) {
|
|
for (const button of gamepad.buttons) {
|
|
if (button.pressed) {
|
|
setIsGamepadActive(true)
|
|
activated = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!isGamepadActive) {
|
|
rafRef.current = requestAnimationFrame(poll)
|
|
return
|
|
}
|
|
|
|
const focusable = Array.from(
|
|
container.querySelectorAll<HTMLElement>(focusSelector),
|
|
).filter((el) => {
|
|
// Skip hidden or disabled elements
|
|
return el.offsetParent !== null && !el.hasAttribute("disabled")
|
|
})
|
|
|
|
if (focusable.length === 0) {
|
|
rafRef.current = requestAnimationFrame(poll)
|
|
return
|
|
}
|
|
|
|
// D-pad navigation
|
|
const upPressed = gamepad.buttons[12]?.pressed // D-pad up
|
|
const downPressed = gamepad.buttons[13]?.pressed // D-pad down
|
|
|
|
// Left stick navigation
|
|
const axisY = gamepad.axes[1] ?? 0
|
|
const stickUp = axisY < -AXIS_THRESHOLD
|
|
const stickDown = axisY > AXIS_THRESHOLD
|
|
|
|
// Vertical navigation (primary)
|
|
if (upPressed || stickUp) {
|
|
currentIndexRef.current = Math.max(0, currentIndexRef.current - 1)
|
|
lastInputTimeRef.current = now
|
|
} else if (downPressed || stickDown) {
|
|
currentIndexRef.current = Math.min(
|
|
focusable.length - 1,
|
|
currentIndexRef.current + 1,
|
|
)
|
|
lastInputTimeRef.current = now
|
|
}
|
|
|
|
// Ensure index is valid
|
|
currentIndexRef.current = Math.max(
|
|
0,
|
|
Math.min(currentIndexRef.current, focusable.length - 1),
|
|
)
|
|
|
|
// Focus the current element
|
|
if (
|
|
currentIndexRef.current >= 0 &&
|
|
currentIndexRef.current < focusable.length
|
|
) {
|
|
focusable[currentIndexRef.current].focus()
|
|
}
|
|
|
|
// A button = activate (click)
|
|
if (gamepad.buttons[0]?.pressed) {
|
|
if (currentIndexRef.current >= 0 && currentIndexRef.current < focusable.length) {
|
|
focusable[currentIndexRef.current].click()
|
|
lastInputTimeRef.current = now
|
|
}
|
|
}
|
|
|
|
// B button = back
|
|
if (gamepad.buttons[1]?.pressed) {
|
|
window.history.back()
|
|
lastInputTimeRef.current = now
|
|
}
|
|
|
|
// X button = search
|
|
if (gamepad.buttons[2]?.pressed && onXButton) {
|
|
onXButton()
|
|
lastInputTimeRef.current = now
|
|
}
|
|
|
|
// Y button = toggle filters
|
|
if (gamepad.buttons[3]?.pressed && onYButton) {
|
|
onYButton()
|
|
lastInputTimeRef.current = now
|
|
}
|
|
|
|
rafRef.current = requestAnimationFrame(poll)
|
|
}
|
|
|
|
rafRef.current = requestAnimationFrame(poll)
|
|
|
|
return () => {
|
|
if (rafRef.current) {
|
|
cancelAnimationFrame(rafRef.current)
|
|
}
|
|
}
|
|
}, [
|
|
isGamepadActive,
|
|
containerRef,
|
|
focusSelector,
|
|
onXButton,
|
|
onYButton,
|
|
])
|
|
|
|
return { isGamepadActive }
|
|
}
|