feat: sitemap remediation, PWA activation, search refinements, Steam Deck UX
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
This commit is contained in:
@@ -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://<your-domain>/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<NextResponse> {
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<boolean>(false)
|
||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
||||
const sentinelRef = useRef<HTMLDivElement>(null)
|
||||
const pageRef = useRef<HTMLElement>(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 (
|
||||
<section className="w-full flex flex-col gap-8 py-8">
|
||||
<section ref={pageRef} className={`w-full flex flex-col gap-8 py-8 ${isGamepadActive ? "gamepad-focus" : ""}`}>
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
@@ -225,7 +295,7 @@ export function GamesPageClient({
|
||||
<div className="max-w-7xl mx-auto flex flex-col gap-3">
|
||||
{/* Search + Sort Row */}
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<label className="flex-1 flex flex-row items-center gap-2 bg-text/5 px-3 py-2 rounded-md border border-border hover:border-border-active focus-within:border-primary/80 focus-within:ring-2 focus-within:ring-primary/50 focus-within:ring-offset-2 focus-within:ring-offset-background transition-colors cursor-text">
|
||||
<label className="flex-1 flex flex-row items-center gap-2 bg-text/5 px-3 py-2.5 rounded-md border border-border hover:border-border-active focus-within:border-primary/80 focus-within:ring-2 focus-within:ring-primary/50 focus-within:ring-offset-2 focus-within:ring-offset-background transition-colors cursor-text min-h-[44px]">
|
||||
<SearchIcon className="h-4 w-4 text-text/40 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
@@ -256,14 +326,14 @@ export function GamesPageClient({
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setSortDirection(prev => prev === "asc" ? "desc" : "asc")}
|
||||
className="px-2 py-2 rounded-md text-sm bg-text/5 border border-border hover:bg-text/10 transition-colors cursor-pointer"
|
||||
className="px-2 py-2 rounded-md text-sm bg-text/5 border border-border hover:bg-text/10 transition-colors cursor-pointer min-h-[44px]"
|
||||
title={sortDirection === "asc" ? "Sort ascending" : "Sort descending"}
|
||||
>
|
||||
{sortDirection === "asc" ? "↑" : "↓"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`px-3 py-2 rounded-md text-sm transition-colors cursor-pointer border ${
|
||||
className={`px-3 py-2 rounded-md text-sm transition-colors cursor-pointer border min-h-[44px] ${
|
||||
showFilters ||
|
||||
selectedDevice ||
|
||||
selectedGenres.length > 0 ||
|
||||
@@ -325,7 +395,7 @@ export function GamesPageClient({
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
onClick={() => setSelectedDevice("")}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors cursor-pointer ${
|
||||
className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-[44px] ${
|
||||
selectedDevice === ""
|
||||
? "bg-primary/10 text-primary border border-primary/30"
|
||||
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
|
||||
@@ -339,7 +409,7 @@ export function GamesPageClient({
|
||||
onClick={() =>
|
||||
setSelectedDevice(selectedDevice === device.slug ? "" : device.slug)
|
||||
}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors cursor-pointer ${
|
||||
className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-[44px] ${
|
||||
selectedDevice === device.slug
|
||||
? "bg-primary/10 text-primary border border-primary/30"
|
||||
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
|
||||
@@ -361,7 +431,7 @@ export function GamesPageClient({
|
||||
<button
|
||||
key={genre}
|
||||
onClick={() => toggleGenre(genre)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors cursor-pointer ${
|
||||
className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-[44px] ${
|
||||
selectedGenres.includes(genre)
|
||||
? "bg-primary/10 text-primary border border-primary/30"
|
||||
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
|
||||
@@ -382,20 +452,20 @@ export function GamesPageClient({
|
||||
placeholder="Min FPS"
|
||||
value={minFps}
|
||||
onChange={(e) => setMinFps(e.target.value)}
|
||||
className="w-24 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm"
|
||||
className="w-24 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Max FPS"
|
||||
value={maxFps}
|
||||
onChange={(e) => setMaxFps(e.target.value)}
|
||||
className="w-24 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm"
|
||||
className="w-24 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={playabilityStatus}
|
||||
onChange={(e) => setPlayabilityStatus(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]"
|
||||
>
|
||||
<option value="">Any Playability</option>
|
||||
<option value="great">Plays Great</option>
|
||||
@@ -411,7 +481,7 @@ export function GamesPageClient({
|
||||
<select
|
||||
value={protonNative}
|
||||
onChange={(e) => 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]"
|
||||
>
|
||||
<option value="any">Any Runtime</option>
|
||||
<option value="native">Native</option>
|
||||
@@ -420,14 +490,14 @@ export function GamesPageClient({
|
||||
<select
|
||||
value={antiCheatStatus}
|
||||
onChange={(e) => setAntiCheatStatus(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]"
|
||||
>
|
||||
<option value="any">Any Anti-Cheat</option>
|
||||
<option value="supported">AC Supported</option>
|
||||
<option value="unsupported">AC Unsupported</option>
|
||||
<option value="unknown">AC Unknown</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<label className="flex items-center gap-2 text-sm py-2 min-h-[44px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fsrSupport}
|
||||
@@ -441,7 +511,7 @@ export function GamesPageClient({
|
||||
{/* Other Filters */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-zinc-300">Other</h4>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<label className="flex items-center gap-2 text-sm py-2 min-h-[44px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isFree}
|
||||
@@ -450,7 +520,7 @@ export function GamesPageClient({
|
||||
/>
|
||||
Free to Play
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<label className="flex items-center gap-2 text-sm py-2 min-h-[44px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasMultiplayer}
|
||||
@@ -494,11 +564,12 @@ export function GamesPageClient({
|
||||
setSteamReviewMin((filters.steamReviewMin as string) || "")
|
||||
setIsFree((filters.isFree as boolean) || false)
|
||||
setHasMultiplayer((filters.hasMultiplayer as boolean) || false)
|
||||
if (filters.genre) setSelectedGenres([filters.genre as string])
|
||||
if (filters.genre) setSelectedGenres((filters.genre as string).split(",").filter(Boolean))
|
||||
else setSelectedGenres([])
|
||||
if (filters.device) setSelectedDevice(filters.device as string)
|
||||
else setSelectedDevice("")
|
||||
if (filters.sortBy) setSort(filters.sortBy as SortOption)
|
||||
setShowFilters(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -528,7 +599,7 @@ export function GamesPageClient({
|
||||
setIsFree(false)
|
||||
setHasMultiplayer(false)
|
||||
}}
|
||||
className="text-xs text-text/50 hover:text-primary transition-colors cursor-pointer self-start"
|
||||
className="text-xs text-text/50 hover:text-primary transition-colors cursor-pointer self-start min-h-[44px] py-2 flex items-center"
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
@@ -544,7 +615,7 @@ export function GamesPageClient({
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-2 text-xs text-text/50 hover:text-primary transition-colors cursor-pointer"
|
||||
className="mt-2 text-xs text-text/50 hover:text-primary transition-colors cursor-pointer min-h-[44px] py-2 flex items-center"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
@@ -611,7 +682,7 @@ export function GamesPageClient({
|
||||
setIsFree(false)
|
||||
setHasMultiplayer(false)
|
||||
}}
|
||||
className="text-xs text-primary hover:text-primary/80 transition-colors cursor-pointer"
|
||||
className="text-xs text-primary hover:text-primary/80 transition-colors cursor-pointer min-h-[44px]"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
|
||||
@@ -21,4 +21,18 @@
|
||||
|
||||
/* Fonts */
|
||||
--font-sans: var(--font-lexend);
|
||||
}
|
||||
|
||||
/* Touch targets — WCAG 2.1 AA: minimum 44×44px */
|
||||
.touch-target {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* Gamepad focus ring — visible only when gamepad navigation is active */
|
||||
.gamepad-focus :focus,
|
||||
.gamepad-focus:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
+14
-1
@@ -1,4 +1,4 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import { Lexend } from "next/font/google"
|
||||
import "./globals.css"
|
||||
import Script from "next/script"
|
||||
@@ -10,6 +10,15 @@ const font = Lexend({
|
||||
subsets: ["latin"],
|
||||
})
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
themeColor: "#eb3779",
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://deckyvault.xyz"),
|
||||
title: {
|
||||
@@ -82,6 +91,10 @@ export default function RootLayout({
|
||||
lang='en'
|
||||
className={`${font.variable} bg-background text-text antialiased overscroll-none`}
|
||||
>
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
</head>
|
||||
<body className='min-h-full w-dvw flex flex-col relative'>
|
||||
<Suspense>
|
||||
<Navbar />
|
||||
|
||||
@@ -11,6 +11,18 @@ export default function manifest(): MetadataRoute.Manifest {
|
||||
background_color: "#100b14",
|
||||
theme_color: "#eb3779",
|
||||
icons: [
|
||||
{
|
||||
src: "/icon-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
{
|
||||
src: "/icon-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/icon.png",
|
||||
sizes: "any",
|
||||
|
||||
+108
-24
@@ -1,30 +1,114 @@
|
||||
import type { MetadataRoute } from "next"
|
||||
import { buildStaticEntries } from "@/lib/sitemap/build-static-entries"
|
||||
import { fetchDynamicEntries } from "@/lib/sitemap/fetch-dynamic-entries"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { games, hardware } from "@/lib/db/schema"
|
||||
import { or, ne, isNull } from "drizzle-orm"
|
||||
|
||||
/**
|
||||
* ISR-style revalidation window in seconds.
|
||||
*
|
||||
* Next.js caches the sitemap and regenerates it at most once per
|
||||
* revalidation window. Between regenerations, the cached response
|
||||
* is served instantly from memory (and from Cloudflare's edge via
|
||||
* the `s-maxage` directive).
|
||||
*
|
||||
* On-demand purging is handled by `/api/revalidate-sitemap` which
|
||||
* calls `revalidatePath("/sitemap.xml")`.
|
||||
*/
|
||||
export const revalidate = 3600
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://deckyvault.xyz"
|
||||
|
||||
function buildStaticEntries(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: BASE_URL,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly" as const,
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/games`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "daily" as const,
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/devices`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: 0.6,
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/updates`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly" as const,
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/contact`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "yearly" as const,
|
||||
priority: 0.3,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the sitemap for deckyvault.xyz.
|
||||
*
|
||||
* Combines static pages with dynamic game and device entries from
|
||||
* the database. The result is cached by Next.js with ISR semantics
|
||||
* so crawlers always get a fast response even during cold starts
|
||||
* (the cache is persisted to disk in self-hosted Docker setups).
|
||||
*/
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const staticEntries = buildStaticEntries()
|
||||
const { gameEntries, deviceEntries } = await fetchDynamicEntries()
|
||||
return [...staticEntries, ...gameEntries, ...deviceEntries]
|
||||
|
||||
try {
|
||||
const [gameRows, deviceRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: games.id,
|
||||
updatedAt: games.updatedAt,
|
||||
capsuleImage: games.capsuleImage,
|
||||
})
|
||||
.from(games)
|
||||
.where(or(ne(games.syncStatus, "failed"), isNull(games.syncStatus))),
|
||||
db
|
||||
.select({
|
||||
slug: hardware.slug,
|
||||
createdAt: hardware.createdAt,
|
||||
})
|
||||
.from(hardware),
|
||||
])
|
||||
|
||||
const gameEntries: MetadataRoute.Sitemap = gameRows.map((row) => {
|
||||
const image =
|
||||
row.capsuleImage &&
|
||||
typeof row.capsuleImage === "string" &&
|
||||
row.capsuleImage.trim().startsWith("https://") &&
|
||||
row.capsuleImage.trim().length <= 2048
|
||||
? row.capsuleImage.trim()
|
||||
: undefined
|
||||
|
||||
return {
|
||||
url: `${BASE_URL}/game/${row.id}`,
|
||||
lastModified: row.updatedAt ?? undefined,
|
||||
changeFrequency: "weekly" as const,
|
||||
priority: 0.7,
|
||||
...(image ? { images: [image] } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
const deviceEntries: MetadataRoute.Sitemap = deviceRows.map((row) => ({
|
||||
url: `${BASE_URL}/devices/${row.slug}`,
|
||||
lastModified: row.createdAt ?? undefined,
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: 0.5,
|
||||
}))
|
||||
|
||||
console.info(
|
||||
JSON.stringify({
|
||||
event: "sitemap_generated",
|
||||
gameCount: gameEntries.length,
|
||||
deviceCount: deviceEntries.length,
|
||||
staticCount: staticEntries.length,
|
||||
totalUrls: staticEntries.length + gameEntries.length + deviceEntries.length,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}),
|
||||
)
|
||||
|
||||
return [...staticEntries, ...gameEntries, ...deviceEntries]
|
||||
} catch (err) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
event: "sitemap_db_error",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
stack: err instanceof Error ? err.stack : undefined,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}),
|
||||
)
|
||||
return staticEntries
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { PrecacheEntry } from "@serwist/precaching";
|
||||
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from "@serwist/strategies";
|
||||
import { ExpirationPlugin } from "@serwist/expiration";
|
||||
import { Serwist } from "serwist";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__SW_MANIFEST: (string | PrecacheEntry)[];
|
||||
}
|
||||
}
|
||||
|
||||
const serwist = new Serwist({
|
||||
precacheEntries: self.__SW_MANIFEST,
|
||||
skipWaiting: true,
|
||||
clientsClaim: true,
|
||||
runtimeCaching: [
|
||||
// Game detail pages: stale-while-revalidate, 24h max age
|
||||
{
|
||||
matcher: /\/game\/[\w-]+$/,
|
||||
handler: new StaleWhileRevalidate({
|
||||
cacheName: "game-pages",
|
||||
plugins: [
|
||||
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 24 * 60 * 60 }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
// Game listing page: network-first (filters matter)
|
||||
{
|
||||
matcher: /\/games(\?.*)?$/,
|
||||
handler: new NetworkFirst({
|
||||
cacheName: "games-listing",
|
||||
plugins: [
|
||||
new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 5 * 60 }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
// Steam CDN images: cache-first, 30 days
|
||||
{
|
||||
matcher: /^https:\/\/cdn\.akamai\.steamstatic\.com\//,
|
||||
handler: new CacheFirst({
|
||||
cacheName: "steam-images",
|
||||
plugins: [
|
||||
new ExpirationPlugin({ maxEntries: 500, maxAgeSeconds: 30 * 24 * 60 * 60 }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
// SteamGridDB images: cache-first, 30 days
|
||||
{
|
||||
matcher: /^https:\/\/cdn\d?\.steamgriddb\.com\//,
|
||||
handler: new CacheFirst({
|
||||
cacheName: "steamgrid-images",
|
||||
plugins: [
|
||||
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 30 * 24 * 60 * 60 }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
// API responses: network-first (live data critical)
|
||||
{
|
||||
matcher: /\/api\//,
|
||||
handler: new NetworkFirst({
|
||||
cacheName: "api-responses",
|
||||
plugins: [
|
||||
new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 5 * 60 }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
// Navigation fallback: offline.html for uncached pages
|
||||
{
|
||||
matcher: ({ request }) => request.mode === "navigate",
|
||||
handler: new NetworkFirst({
|
||||
cacheName: "navigation",
|
||||
plugins: [
|
||||
new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 24 * 60 * 60 }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
serwist.addEventListeners();
|
||||
Reference in New Issue
Block a user