diff --git a/.gitignore b/.gitignore index 009711b..439b764 100644 --- a/.gitignore +++ b/.gitignore @@ -41,7 +41,8 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -certificates +# serwist build artifact +public/sw.js /docs/superpowers diff --git a/CHANGELOG.md b/CHANGELOG.md index a48dbcf..2004d22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ All notable changes to DeckyVault will be documented in this file. +## [2026.0.97] - 2026-05-06 + +### Fixed +- Sitemap.xml used ISR caching (`revalidate = 3600`) which poisoned the cache with empty responses on DB hiccups; switched to `force-dynamic` for per-request fresh generation +- DB errors during sitemap generation were silently caught and returned as empty arrays (no games indexed); errors now propagate to observability with structured logging +- Games with NULL `syncStatus` were excluded from sitemap due to SQL `<> 'failed'` returning NULL (not TRUE) for NULL values +- Multi-genre selection in games filter panel only applied the first selected genre; now supports comma-separated OR-matching +- FPS range filter included results from non-active devices; now scoped to the selected device filter when present + +### Added +- PWA service worker with offline caching for game pages and images (stale-while-revalidate for HTML, cache-first for Steam CDN images) +- Offline fallback page (`offline.html`) when navigating without network +- Gamepad navigation hook (D-pad/left stick focus, A/B/X/Y buttons, context-aware actions) +- Structured logging for sitemap generation metrics (games, devices, timestamps) via `console.info` JSON +- Filter state synchronized to URL query parameters for shareable/bookmarkable filtered views +- WCAG 2.1 AA touch targets (44×44px) on all games page filter controls + +### Changed +- Web manifest icons now declare explicit 192px (maskable) and 512px (any) sizes +- Viewport meta tag added with `viewport-fit=cover` and `user-scalable=no` for installed PWA feel +- Apple mobile web app meta tags added for iOS home screen support +- Loading a saved filter now auto-collapses the filter panel for visual feedback +- Sitemap generation flattened into a single function in `app/sitemap.ts` (removed delegation to `lib/sitemap/`) + +### Technical +- Removed `lib/sitemap/fetch-dynamic-entries.ts`, `lib/sitemap/build-static-entries.ts`, `lib/sitemap/validate-image-url.ts` +- Removed `app/api/revalidate-sitemap/` route (no longer needed with `force-dynamic`) +- Added `@serwist/next`, `@serwist/precaching`, `@serwist/sw`, `@serwist/strategies`, `@serwist/expiration`, `@serwist/routing`, and `serwist` dependencies +- Build script updated to use `--webpack` flag for `@serwist/next` compatibility + ## [2026.0.96] - 2026-05-01 ### Added diff --git a/app/api/revalidate-sitemap/route.ts b/app/api/revalidate-sitemap/route.ts deleted file mode 100644 index 0b970c1..0000000 --- a/app/api/revalidate-sitemap/route.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { revalidatePath } from "next/cache" -import { NextRequest, NextResponse } from "next/server" - -/** - * On-demand sitemap revalidation webhook. - * - * What this endpoint does: - * Revalidates the `/sitemap.xml` path so Next.js regenerates the sitemap - * at the edge instead of waiting for the next ISR interval. - * - * When to call it: - * - After adding, updating, or removing games - * - After adding, updating, or removing hardware - * - After any bulk import or migration that affects public-facing URLs - * - * How to call it: - * ```bash - * curl -X POST https:///api/revalidate-sitemap \ - * -H "Authorization: Bearer $REVALIDATE_SECRET" - * ``` - * - * @see https://nextjs.org/docs/app/building-your-application/caching#on-demand-revalidation - */ -export async function POST(request: NextRequest): Promise { - const authHeader = request.headers.get("authorization") - const secret = process.env.REVALIDATE_SECRET - - if (!secret) { - return NextResponse.json( - { error: "Revalidation not configured. Set REVALIDATE_SECRET env var." }, - { status: 503 }, - ) - } - - const token = authHeader?.replace("Bearer ", "") - if (token !== secret) { - return NextResponse.json({ error: "Invalid secret" }, { status: 401 }) - } - - try { - revalidatePath("/sitemap.xml") - return NextResponse.json({ - revalidated: true, - path: "/sitemap.xml", - now: Date.now(), - }) - } catch (error) { - console.error("[Revalidate Sitemap] Failed:", error) - return NextResponse.json( - { error: "Revalidation failed" }, - { status: 500 }, - ) - } -} diff --git a/app/games/games-page-client.tsx b/app/games/games-page-client.tsx index 6cde261..abfbb6b 100644 --- a/app/games/games-page-client.tsx +++ b/app/games/games-page-client.tsx @@ -1,6 +1,7 @@ "use client" import { useState, useEffect, useRef, useCallback } from "react" +import { useRouter } from "next/navigation" import Image from "next/image" import Link from "next/link" import { motion } from "motion/react" @@ -11,6 +12,7 @@ import { XIcon, Loader2Icon, } from "lucide-react" +import { useGamepadNavigation } from "@/lib/hooks/use-gamepad-navigation" import { AntiCheatBadge } from "@/components/anti-cheat-badge" import { PlayabilityBadge } from "@/components/playability-badge" import { SavedFilters } from "@/components/saved-filters" @@ -90,9 +92,54 @@ export function GamesPageClient({ const [hasMultiplayer, setHasMultiplayer] = useState(false) const observerRef = useRef(null) const sentinelRef = useRef(null) + const pageRef = useRef(null) + + const { isGamepadActive } = useGamepadNavigation(pageRef, { + onXButton: () => { + // Navigate to search page + window.location.href = "/search" + }, + onYButton: () => { + // Toggle filter panel + setShowFilters((prev) => !prev) + }, + }) + + const router = useRouter() const hasMore = games.length < total + // ── Initialize filters from URL params on mount ──────────────── + /* eslint-disable react-hooks/set-state-in-effect */ + useEffect(() => { + const params = new URLSearchParams(window.location.search) + if (params.get("search")) setSearch(params.get("search")!) + if (params.get("device")) setSelectedDevice(params.get("device")!) + if (params.get("genre")) { + const genres = params.get("genre")!.split(",").filter(Boolean) + setSelectedGenres(genres) + } + if (params.get("minFps")) setMinFps(params.get("minFps")!) + if (params.get("maxFps")) setMaxFps(params.get("maxFps")!) + if (params.get("fsrSupport") === "true") setFsrSupport(true) + if (params.get("protonNative") && params.get("protonNative") !== "any") + setProtonNative(params.get("protonNative")!) + if (params.get("antiCheatStatus") && params.get("antiCheatStatus") !== "any") + setAntiCheatStatus(params.get("antiCheatStatus")!) + if (params.get("playabilityStatus")) setPlayabilityStatus(params.get("playabilityStatus")!) + if (params.get("steamReviewScore")) setSteamReviewMin(params.get("steamReviewScore")!) + if (params.get("isFree") === "true") setIsFree(true) + if (params.get("hasMultiplayer") === "true") setHasMultiplayer(true) + if (params.get("sort")) { + const s = params.get("sort")! + if (["recent", "name", "benchmarks", "performance", "popularity", "release_date", "steam_reviews"].includes(s)) { + setSort(s as SortOption) + } + } + if (params.get("order")) setSortDirection(params.get("order") as SortDirection) + }, []) + /* eslint-enable react-hooks/set-state-in-effect */ + const buildUrl = useCallback( (offset: number) => { const params = new URLSearchParams() @@ -102,7 +149,7 @@ export function GamesPageClient({ params.set("order", sortDirection) if (search) params.set("search", search) if (selectedDevice) params.set("device", selectedDevice) - if (selectedGenres.length === 1) params.set("genre", selectedGenres[0]) + if (selectedGenres.length > 0) params.set("genre", selectedGenres.join(",")) if (minFps) params.set("minFps", minFps) if (maxFps) params.set("maxFps", maxFps) if (fsrSupport) params.set("fsrSupport", "true") @@ -141,6 +188,29 @@ export function GamesPageClient({ } }, [loading, hasMore, games.length, buildUrl]) + // ── Sync filter state to URL (replace, not push) ─────────────── + useEffect(() => { + const params = new URLSearchParams() + if (search) params.set("search", search) + if (selectedDevice) params.set("device", selectedDevice) + if (selectedGenres.length > 0) params.set("genre", selectedGenres.join(",")) + if (minFps) params.set("minFps", minFps) + if (maxFps) params.set("maxFps", maxFps) + if (fsrSupport) params.set("fsrSupport", "true") + if (protonNative !== "any") params.set("protonNative", protonNative) + if (antiCheatStatus !== "any") params.set("antiCheatStatus", antiCheatStatus) + if (playabilityStatus) params.set("playabilityStatus", playabilityStatus) + if (steamReviewMin) params.set("steamReviewScore", steamReviewMin) + if (isFree) params.set("isFree", "true") + if (hasMultiplayer) params.set("hasMultiplayer", "true") + if (sort !== "recent") params.set("sort", sort) + if (sortDirection !== "desc") params.set("order", sortDirection) + + const qs = params.toString() + const url = qs ? `/games?${qs}` : "/games" + router.replace(url, { scroll: false }) + }, [search, selectedDevice, selectedGenres, minFps, maxFps, fsrSupport, protonNative, antiCheatStatus, playabilityStatus, steamReviewMin, isFree, hasMultiplayer, sort, sortDirection, router]) + // Full reload when filters/sort change useEffect(() => { let cancelled = false @@ -199,7 +269,7 @@ export function GamesPageClient({ } return ( -
+
{/* Header */} {/* Search + Sort Row */}
-
setProtonNative(e.target.value)} - className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm" + className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]" > @@ -420,14 +490,14 @@ export function GamesPageClient({ -