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:
2026-05-05 16:20:28 +08:00
parent 9be64359be
commit dcf6066b2b
25 changed files with 1396 additions and 489 deletions
+2 -1
View File
@@ -41,7 +41,8 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
certificates # serwist build artifact
public/sw.js
/docs/superpowers /docs/superpowers
+30
View File
@@ -2,6 +2,36 @@
All notable changes to DeckyVault will be documented in this file. 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 ## [2026.0.96] - 2026-05-01
### Added ### Added
-54
View File
@@ -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 },
)
}
}
+91 -20
View File
@@ -1,6 +1,7 @@
"use client" "use client"
import { useState, useEffect, useRef, useCallback } from "react" import { useState, useEffect, useRef, useCallback } from "react"
import { useRouter } from "next/navigation"
import Image from "next/image" import Image from "next/image"
import Link from "next/link" import Link from "next/link"
import { motion } from "motion/react" import { motion } from "motion/react"
@@ -11,6 +12,7 @@ import {
XIcon, XIcon,
Loader2Icon, Loader2Icon,
} from "lucide-react" } from "lucide-react"
import { useGamepadNavigation } from "@/lib/hooks/use-gamepad-navigation"
import { AntiCheatBadge } from "@/components/anti-cheat-badge" import { AntiCheatBadge } from "@/components/anti-cheat-badge"
import { PlayabilityBadge } from "@/components/playability-badge" import { PlayabilityBadge } from "@/components/playability-badge"
import { SavedFilters } from "@/components/saved-filters" import { SavedFilters } from "@/components/saved-filters"
@@ -90,9 +92,54 @@ export function GamesPageClient({
const [hasMultiplayer, setHasMultiplayer] = useState<boolean>(false) const [hasMultiplayer, setHasMultiplayer] = useState<boolean>(false)
const observerRef = useRef<IntersectionObserver | null>(null) const observerRef = useRef<IntersectionObserver | null>(null)
const sentinelRef = useRef<HTMLDivElement>(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 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( const buildUrl = useCallback(
(offset: number) => { (offset: number) => {
const params = new URLSearchParams() const params = new URLSearchParams()
@@ -102,7 +149,7 @@ export function GamesPageClient({
params.set("order", sortDirection) params.set("order", sortDirection)
if (search) params.set("search", search) if (search) params.set("search", search)
if (selectedDevice) params.set("device", selectedDevice) 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 (minFps) params.set("minFps", minFps)
if (maxFps) params.set("maxFps", maxFps) if (maxFps) params.set("maxFps", maxFps)
if (fsrSupport) params.set("fsrSupport", "true") if (fsrSupport) params.set("fsrSupport", "true")
@@ -141,6 +188,29 @@ export function GamesPageClient({
} }
}, [loading, hasMore, games.length, buildUrl]) }, [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 // Full reload when filters/sort change
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -199,7 +269,7 @@ export function GamesPageClient({
} }
return ( 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 */} {/* Header */}
<motion.div <motion.div
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
@@ -225,7 +295,7 @@ export function GamesPageClient({
<div className="max-w-7xl mx-auto flex flex-col gap-3"> <div className="max-w-7xl mx-auto flex flex-col gap-3">
{/* Search + Sort Row */} {/* Search + Sort Row */}
<div className="flex flex-row items-center gap-3"> <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" /> <SearchIcon className="h-4 w-4 text-text/40 shrink-0" />
<input <input
type="text" type="text"
@@ -256,14 +326,14 @@ export function GamesPageClient({
</select> </select>
<button <button
onClick={() => setSortDirection(prev => prev === "asc" ? "desc" : "asc")} 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"} title={sortDirection === "asc" ? "Sort ascending" : "Sort descending"}
> >
{sortDirection === "asc" ? "↑" : "↓"} {sortDirection === "asc" ? "↑" : "↓"}
</button> </button>
<button <button
onClick={() => setShowFilters(!showFilters)} 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 || showFilters ||
selectedDevice || selectedDevice ||
selectedGenres.length > 0 || selectedGenres.length > 0 ||
@@ -325,7 +395,7 @@ export function GamesPageClient({
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
<button <button
onClick={() => setSelectedDevice("")} 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 === "" selectedDevice === ""
? "bg-primary/10 text-primary border border-primary/30" ? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent" : "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
@@ -339,7 +409,7 @@ export function GamesPageClient({
onClick={() => onClick={() =>
setSelectedDevice(selectedDevice === device.slug ? "" : device.slug) 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 selectedDevice === device.slug
? "bg-primary/10 text-primary border border-primary/30" ? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent" : "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
@@ -361,7 +431,7 @@ export function GamesPageClient({
<button <button
key={genre} key={genre}
onClick={() => toggleGenre(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) selectedGenres.includes(genre)
? "bg-primary/10 text-primary border border-primary/30" ? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent" : "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
@@ -382,20 +452,20 @@ export function GamesPageClient({
placeholder="Min FPS" placeholder="Min FPS"
value={minFps} value={minFps}
onChange={(e) => setMinFps(e.target.value)} 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 <input
type="number" type="number"
placeholder="Max FPS" placeholder="Max FPS"
value={maxFps} value={maxFps}
onChange={(e) => setMaxFps(e.target.value)} 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> </div>
<select <select
value={playabilityStatus} value={playabilityStatus}
onChange={(e) => setPlayabilityStatus(e.target.value)} 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="">Any Playability</option>
<option value="great">Plays Great</option> <option value="great">Plays Great</option>
@@ -411,7 +481,7 @@ export function GamesPageClient({
<select <select
value={protonNative} value={protonNative}
onChange={(e) => setProtonNative(e.target.value)} 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="any">Any Runtime</option>
<option value="native">Native</option> <option value="native">Native</option>
@@ -420,14 +490,14 @@ export function GamesPageClient({
<select <select
value={antiCheatStatus} value={antiCheatStatus}
onChange={(e) => setAntiCheatStatus(e.target.value)} 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="any">Any Anti-Cheat</option>
<option value="supported">AC Supported</option> <option value="supported">AC Supported</option>
<option value="unsupported">AC Unsupported</option> <option value="unsupported">AC Unsupported</option>
<option value="unknown">AC Unknown</option> <option value="unknown">AC Unknown</option>
</select> </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 <input
type="checkbox" type="checkbox"
checked={fsrSupport} checked={fsrSupport}
@@ -441,7 +511,7 @@ export function GamesPageClient({
{/* Other Filters */} {/* Other Filters */}
<div className="space-y-2"> <div className="space-y-2">
<h4 className="text-sm font-medium text-zinc-300">Other</h4> <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 <input
type="checkbox" type="checkbox"
checked={isFree} checked={isFree}
@@ -450,7 +520,7 @@ export function GamesPageClient({
/> />
Free to Play Free to Play
</label> </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 <input
type="checkbox" type="checkbox"
checked={hasMultiplayer} checked={hasMultiplayer}
@@ -494,11 +564,12 @@ export function GamesPageClient({
setSteamReviewMin((filters.steamReviewMin as string) || "") setSteamReviewMin((filters.steamReviewMin as string) || "")
setIsFree((filters.isFree as boolean) || false) setIsFree((filters.isFree as boolean) || false)
setHasMultiplayer((filters.hasMultiplayer 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([]) else setSelectedGenres([])
if (filters.device) setSelectedDevice(filters.device as string) if (filters.device) setSelectedDevice(filters.device as string)
else setSelectedDevice("") else setSelectedDevice("")
if (filters.sortBy) setSort(filters.sortBy as SortOption) if (filters.sortBy) setSort(filters.sortBy as SortOption)
setShowFilters(false)
}} }}
/> />
@@ -528,7 +599,7 @@ export function GamesPageClient({
setIsFree(false) setIsFree(false)
setHasMultiplayer(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 Clear all filters
</button> </button>
@@ -544,7 +615,7 @@ export function GamesPageClient({
<p className="text-red-400 text-sm">{error}</p> <p className="text-red-400 text-sm">{error}</p>
<button <button
onClick={() => window.location.reload()} 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 Try again
</button> </button>
@@ -611,7 +682,7 @@ export function GamesPageClient({
setIsFree(false) setIsFree(false)
setHasMultiplayer(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 Clear filters
</button> </button>
+14
View File
@@ -21,4 +21,18 @@
/* Fonts */ /* Fonts */
--font-sans: var(--font-lexend); --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
View File
@@ -1,4 +1,4 @@
import type { Metadata } from "next" import type { Metadata, Viewport } from "next"
import { Lexend } from "next/font/google" import { Lexend } from "next/font/google"
import "./globals.css" import "./globals.css"
import Script from "next/script" import Script from "next/script"
@@ -10,6 +10,15 @@ const font = Lexend({
subsets: ["latin"], subsets: ["latin"],
}) })
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
maximumScale: 1,
userScalable: false,
themeColor: "#eb3779",
}
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL("https://deckyvault.xyz"), metadataBase: new URL("https://deckyvault.xyz"),
title: { title: {
@@ -82,6 +91,10 @@ export default function RootLayout({
lang='en' lang='en'
className={`${font.variable} bg-background text-text antialiased overscroll-none`} 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'> <body className='min-h-full w-dvw flex flex-col relative'>
<Suspense> <Suspense>
<Navbar /> <Navbar />
+12
View File
@@ -11,6 +11,18 @@ export default function manifest(): MetadataRoute.Manifest {
background_color: "#100b14", background_color: "#100b14",
theme_color: "#eb3779", theme_color: "#eb3779",
icons: [ 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", src: "/icon.png",
sizes: "any", sizes: "any",
+108 -24
View File
@@ -1,30 +1,114 @@
import type { MetadataRoute } from "next" import type { MetadataRoute } from "next"
import { buildStaticEntries } from "@/lib/sitemap/build-static-entries" import { db } from "@/lib/db/index"
import { fetchDynamicEntries } from "@/lib/sitemap/fetch-dynamic-entries" import { games, hardware } from "@/lib/db/schema"
import { or, ne, isNull } from "drizzle-orm"
/** export const dynamic = "force-dynamic"
* ISR-style revalidation window in seconds.
* const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://deckyvault.xyz"
* Next.js caches the sitemap and regenerates it at most once per
* revalidation window. Between regenerations, the cached response function buildStaticEntries(): MetadataRoute.Sitemap {
* is served instantly from memory (and from Cloudflare's edge via return [
* the `s-maxage` directive). {
* url: BASE_URL,
* On-demand purging is handled by `/api/revalidate-sitemap` which lastModified: new Date(),
* calls `revalidatePath("/sitemap.xml")`. changeFrequency: "weekly" as const,
*/ priority: 1,
export const revalidate = 3600 },
{
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> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const staticEntries = buildStaticEntries() 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
}
} }
+80
View File
@@ -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();
+84 -7
View File
@@ -9,6 +9,12 @@
"@better-auth/drizzle-adapter": "^1.6.9", "@better-auth/drizzle-adapter": "^1.6.9",
"@better-auth/passkey": "^1.6.9", "@better-auth/passkey": "^1.6.9",
"@elysia/eden": "^1.4.10", "@elysia/eden": "^1.4.10",
"@serwist/expiration": "^9.5.11",
"@serwist/next": "^9.5.11",
"@serwist/precaching": "^9.5.11",
"@serwist/routing": "^9.5.11",
"@serwist/strategies": "^9.5.11",
"@serwist/sw": "^9.5.11",
"@tiptap/core": "^3.22.4", "@tiptap/core": "^3.22.4",
"@tiptap/extension-link": "^3.22.4", "@tiptap/extension-link": "^3.22.4",
"@tiptap/extension-placeholder": "^3.22.4", "@tiptap/extension-placeholder": "^3.22.4",
@@ -35,6 +41,7 @@
"remark": "^15.0.1", "remark": "^15.0.1",
"remark-rehype": "^11.1.2", "remark-rehype": "^11.1.2",
"resend": "^6.12.2", "resend": "^6.12.2",
"serwist": "^9.5.11",
"web-haptics": "^0.0.6", "web-haptics": "^0.0.6",
}, },
"devDependencies": { "devDependencies": {
@@ -458,6 +465,26 @@
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
"@serwist/build": ["@serwist/build@9.5.11", "", { "dependencies": { "@serwist/utils": "9.5.11", "common-tags": "1.8.2", "glob": "13.0.6", "pretty-bytes": "6.1.1", "source-map": "0.8.0-beta.0", "type-fest": "5.6.0", "zod": "4.4.1" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-PQfW+LhADYFOOp0PhEnjlgJCyKor6cYa06d3rID1OpiKzkmCApJV1WYfdTBB96jXaWv6OWcWSbSV4tqDLxvaVA=="],
"@serwist/expiration": ["@serwist/expiration@9.5.11", "", { "dependencies": { "serwist": "9.5.11" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-eMLOJECHgceubSIwic+AHz3Lfk3uWZrRtwUgxbh90AYMsI7MXLLlXVLgNxwLz0Fx1fJHxdBHua4aRS3YPnGz0w=="],
"@serwist/next": ["@serwist/next@9.5.11", "", { "dependencies": { "@serwist/build": "9.5.11", "@serwist/utils": "9.5.11", "@serwist/webpack-plugin": "9.5.11", "@serwist/window": "9.5.11", "browserslist": "4.28.2", "glob": "13.0.6", "kolorist": "1.8.0", "semver": "7.7.4", "serwist": "9.5.11", "zod": "4.4.1" }, "peerDependencies": { "@serwist/cli": "^9.5.11", "next": ">=14.0.0", "react": ">=18.0.0", "typescript": ">=5.0.0" }, "optionalPeers": ["@serwist/cli", "typescript"] }, "sha512-omT32H7U21ihCymSvOG9QeRJBuOEomJx4JdzKhUoqOW3DR10tH3m84VOHj3BvK0OcA7av3qj5FsyNFBB+f0n8A=="],
"@serwist/precaching": ["@serwist/precaching@9.5.11", "", { "dependencies": { "serwist": "9.5.11" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-ZWA8ebNvFmnmZFKPaG75nMXyB3tahMDT/OBqsFyGWzmDGeaihlCe5GL8H2U1dAcvPVfLBFAoQKShhh9lDcJ5HA=="],
"@serwist/routing": ["@serwist/routing@9.5.11", "", { "dependencies": { "serwist": "9.5.11" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-45rieNdlvfpr0W0Spgb0l1PAAFW37/o5gfZLTo5qHekOWi6U7DQgsiHi+5eZ/Ukd1XrHqH5kNROwtQA2DuD1Uw=="],
"@serwist/strategies": ["@serwist/strategies@9.5.11", "", { "dependencies": { "serwist": "9.5.11" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-+ftLTn5QCtrl8sfHBK0gsT0CQkN9MSTDb/pmU96uHM1w4XaO0r/MN5MjaBSdl6DR1ujjWasrVqh7f4BSCKwT0Q=="],
"@serwist/sw": ["@serwist/sw@9.5.11", "", { "dependencies": { "serwist": "9.5.11" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-hcSuiqqwhlV3nV4f1kHuusdx+fKIBAUFTnxUFA5Wb5GfeUyDsUVt8V08xI2H83cXpTsKJBHlsA3HpsIEWIrB0A=="],
"@serwist/utils": ["@serwist/utils@9.5.11", "", { "peerDependencies": { "browserslist": ">=4" }, "optionalPeers": ["browserslist"] }, "sha512-zqxmwuHqWA3OwN82Wo8gFZ9QBemygJP3cap5JWAOG4UyJZgUZfmBXAXj+IMaD4eKZ/6pqrxHHDZ9uSWZmJ1mXA=="],
"@serwist/webpack-plugin": ["@serwist/webpack-plugin@9.5.11", "", { "dependencies": { "@serwist/build": "9.5.11", "@serwist/utils": "9.5.11", "pretty-bytes": "6.1.1", "zod": "4.4.1" }, "peerDependencies": { "typescript": ">=5.0.0", "webpack": "4.4.0 || ^5.9.0" }, "optionalPeers": ["typescript", "webpack"] }, "sha512-SlvO3A1UMcc1htCzMtLCtPQK6yISCO7B859ixLv7EiY/yayXjVxGm9vHqkJYpQ768PWyjEZXRY/X6EGRMA6wJQ=="],
"@serwist/window": ["@serwist/window@9.5.11", "", { "dependencies": { "@types/trusted-types": "2.0.7", "serwist": "9.5.11" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-OrH9srhmifUvY36NuukHSZby24XTEk4pHh3pfY0GBQzA9ouU1fYh+ORWhKxH7/wkVHRr3sc4YAhjtpfL14PjjQ=="],
"@simplewebauthn/browser": ["@simplewebauthn/browser@13.3.0", "", {}, "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ=="], "@simplewebauthn/browser": ["@simplewebauthn/browser@13.3.0", "", {}, "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ=="],
"@simplewebauthn/server": ["@simplewebauthn/server@13.3.0", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/x509": "^1.14.3" } }, "sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ=="], "@simplewebauthn/server": ["@simplewebauthn/server@13.3.0", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/x509": "^1.14.3" } }, "sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ=="],
@@ -692,6 +719,8 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
@@ -862,6 +891,8 @@
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"common-tags": ["common-tags@1.8.2", "", {}, "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
@@ -1050,6 +1081,8 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@16.4.0", "", {}, "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="], "globals": ["globals@16.4.0", "", {}, "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="],
@@ -1092,6 +1125,8 @@
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -1188,6 +1223,8 @@
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
"kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="],
"kysely": ["kysely@0.28.16", "", {}, "sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww=="], "kysely": ["kysely@0.28.16", "", {}, "sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww=="],
"language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="],
@@ -1226,11 +1263,13 @@
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="],
"lucide-react": ["lucide-react@1.11.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g=="], "lucide-react": ["lucide-react@1.11.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g=="],
@@ -1300,6 +1339,8 @@
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="], "motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="],
"motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="], "motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="],
@@ -1362,6 +1403,8 @@
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pg": ["pg@8.20.0", "", { "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA=="], "pg": ["pg@8.20.0", "", { "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA=="],
@@ -1402,6 +1445,8 @@
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"pretty-bytes": ["pretty-bytes@6.1.1", "", {}, "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
@@ -1494,7 +1539,9 @@
"section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"serwist": ["serwist@9.5.11", "", { "dependencies": { "@serwist/utils": "9.5.11", "idb": "8.0.3" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-Bq6uwJFd4ET60BWI77v3VbazKHv6k7lECOiiCFwKyBu/slaCn0GHJ5L5RfsuJUKrnbD9lYUCDo6sqaKRM5M2vA=="],
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
@@ -1522,7 +1569,7 @@
"size-sensor": ["size-sensor@1.0.3", "", {}, "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A=="], "size-sensor": ["size-sensor@1.0.3", "", {}, "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@@ -1576,6 +1623,8 @@
"svix": ["svix@1.90.0", "", { "dependencies": { "standardwebhooks": "1.0.0", "uuid": "^10.0.0" } }, "sha512-ljkZuyy2+IBEoESkIpn8sLM+sxJHQcPxlZFxU+nVDhltNfUMisMBzWX/UR8SjEnzoI28ZjCzMbmYAPwSTucoMw=="], "svix": ["svix@1.90.0", "", { "dependencies": { "standardwebhooks": "1.0.0", "uuid": "^10.0.0" } }, "sha512-ljkZuyy2+IBEoESkIpn8sLM+sxJHQcPxlZFxU+nVDhltNfUMisMBzWX/UR8SjEnzoI28ZjCzMbmYAPwSTucoMw=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="], "tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
@@ -1592,6 +1641,8 @@
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
"tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
@@ -1608,6 +1659,8 @@
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="],
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
"typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="],
@@ -1662,6 +1715,10 @@
"web-haptics": ["web-haptics@0.0.6", "", { "peerDependencies": { "react": ">=18", "react-dom": ">=18", "svelte": ">=4", "vue": ">=3" }, "optionalPeers": ["react", "react-dom", "svelte", "vue"] }, "sha512-eCzcf1LDi20+Fr0x9V3OkX92k0gxEQXaHajmhXHitsnk6SxPeshv8TBtBRqxyst8HI1uf2FyFVE7QS3jo1gkrw=="], "web-haptics": ["web-haptics@0.0.6", "", { "peerDependencies": { "react": ">=18", "react-dom": ">=18", "svelte": ">=4", "vue": ">=3" }, "optionalPeers": ["react", "react-dom", "svelte", "vue"] }, "sha512-eCzcf1LDi20+Fr0x9V3OkX92k0gxEQXaHajmhXHitsnk6SxPeshv8TBtBRqxyst8HI1uf2FyFVE7QS3jo1gkrw=="],
"webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
"whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
@@ -1698,6 +1755,12 @@
"@babel/core/json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "@babel/core/json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
@@ -1708,6 +1771,12 @@
"@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@serwist/build/zod": ["zod@4.4.1", "", {}, "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q=="],
"@serwist/next/zod": ["zod@4.4.1", "", {}, "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q=="],
"@serwist/webpack-plugin/zod": ["zod@4.4.1", "", {}, "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
@@ -1724,8 +1793,6 @@
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"echarts/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="], "echarts/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],
@@ -1736,15 +1803,21 @@
"eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"eslint-plugin-import/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"is-bun-module/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"tsx/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], "tsx/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
@@ -1806,6 +1879,8 @@
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"glob/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
@@ -1865,5 +1940,7 @@
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
} }
} }
@@ -0,0 +1,619 @@
# Architectural Spec: Sitemap Remediation, PWA Activation & Search Refinements
**Date:** 2026-05-05
**Status:** Final — Awaiting Tactical Planning
**Author:** Autonomous Architect
**Depends On:** `docs/2025-05-05-sync-pwa-architectural-spec.md` (existing PWA constraints — extended, not replaced)
---
## 1. Problem Statement
Three objectives converge on site reliability and user experience for the primary audience: Steam Deck handheld users. The sitemap is a critical SEO signal that is currently failing silently; the PWA foundation exists as a manifest but lacks the service-worker layer that enables offline use; the advanced search/filter system shipped in v2026.0.95 needs hardening for edge cases and Steam Deck touch ergonomics.
---
## 2. Objective 1 — Sitemap Remediation
### 2.1 Observed Symptoms
| Symptom | Evidence |
|---|---|
| Google Search Console reports sitemap unreadable | User report — no indexed games despite manual load working |
| Sitemap loads at `https://deckyvault.xyz/sitemap.xml` but shows no games | User report — confirmed manually |
| No Google indexing for game pages | Implied by Search Console failure |
### 2.2 Root Cause Analysis
A comparison against a known-working reference sitemap (Next.js 15 + Drizzle + PostgreSQL, same stack) reveals **two structural anti-patterns** and one SQL semantics bug that together explain the symptoms.
#### Root Cause A: ISR Caching with `revalidate = 3600` (PRIMARY CAUSE)
```typescript
// app/sitemap.ts — CURRENT (broken)
export const revalidate = 3600
export default async function sitemap() { ... }
```
The sitemap uses ISR (Incremental Static Regeneration) with a 1-hour revalidation window. This is fundamentally wrong for sitemaps because:
1. **Cache poisoning**: If ANY generation event produces an empty or incomplete sitemap (cold start, DB hiccup, deployment restart), that broken response is cached and served to ALL consumers — including Googlebot — for up to an hour.
2. **No diagnostic signal**: There is no way to know what Googlebot received. You manually visit and see a populated sitemap, but Google may have hit the cache during a poisoned window.
3. **Anti-pattern**: ISR is designed for content pages that are expensive to compute and tolerate staleness. A sitemap is a machine-readable inventory that MUST be authoritative at the moment Googlebot reads it.
**The reference project** (same stack, works correctly) uses:
```typescript
export const dynamic = "force-dynamic"
```
This tells Next.js to never cache the route. Every request generates a fresh sitemap from live DB queries. No cache poisoning possible.
**Compounding factor**: `SITEMAP_REVALIDATE_SECONDS` is defined in `.env.example` but **never read** by `app/sitemap.ts`. The revalidation interval is hardcoded to 3600. There is no way to tune this even if ISR were appropriate.
#### Root Cause B: Silent Error Suppression in `fetchGameEntries()`
```typescript
// lib/sitemap/fetch-dynamic-entries.ts (lines 10-28)
async function fetchGameEntries(): Promise<MetadataRoute.Sitemap> {
try {
const rows = await db.select(...).from(games).where(ne(games.syncStatus, "failed")).limit(MAX_GAME_ENTRIES)
return rows.map(...)
} catch {
return [] // ← SILENTLY RETURNS EMPTY ARRAY ON ANY DB ERROR
}
}
```
If the PostgreSQL connection times out, is exhausted, or throws any error, the game entries array is silently emptied. Combined with ISR caching (Root Cause A), this one-two punch means: DB hiccup → empty response → cache it for 1 hour → Googlebot reads empty sitemap → zero games indexed.
**The reference project** does not wrap its DB queries in try/catch — it lets errors propagate naturally, which at least returns HTTP 500, prompting Googlebot to retry later rather than consuming a false empty sitemap.
**Trigger conditions:**
- Cold start with connection pool not yet warmed up
- Deployment restart during Googlebot's crawl window
- Connection pool exhaustion from concurrent requests
- PostgreSQL maintenance window or brief outage
#### Root Cause C: NULL `syncStatus` Exclusion (SQL Semantics Bug)
```typescript
.where(ne(games.syncStatus, "failed"))
```
`ne(column, "failed")` generates `"sync_status" <> 'failed'`, which in PostgreSQL returns `NULL` (not `TRUE`) when the column is `NULL`. Rows with NULL `syncStatus` are silently excluded from the sitemap — even when ISR caching is bypassed and the DB query succeeds.
**Impact**: Any game where `syncStatus IS NULL` is invisible to crawlers. This could happen if:
- The column default was added after initial data insertion
- A direct DB manipulation bypassed the application layer
- A migration introduced the column without backfilling existing rows
**Verification query** (run against production DB):
```sql
SELECT COUNT(*) FROM games WHERE sync_status IS NULL;
```
#### Root Cause D: Cloudflare / CDN Cache Interference (Secondary)
If Cloudflare sits in front of the origin, it may independently cache the sitemap XML response. Even with `force-dynamic` at the origin, a Cloudflare Cache Rule with non-zero TTL on `*.xml` would serve a stale copy to Googlebot.
**Verification**: Check Cloudflare dashboard for cached `/sitemap.xml` status and any Page Rules or Cache Rules matching `*.xml`.
### 2.3 Mandatory Fixes
#### Fix 1: Replace ISR with `force-dynamic` (CRITICAL — ONE LINE CHANGE)
**Constraint**: `app/sitemap.ts` MUST use `export const dynamic = "force-dynamic"` instead of `export const revalidate = 3600`. No caching whatsoever. Every request generates a fresh, authoritative sitemap.
**Before:**
```typescript
export const revalidate = 3600
```
**After:**
```typescript
export const dynamic = "force-dynamic"
```
This matches the proven reference pattern. The sitemap is lightweight enough (DB query + JSON serialization of ~50K rows) that per-request generation is acceptable. If this becomes a performance concern at scale (>100K games), the correct approach is a sitemap index with paginated child sitemaps — not ISR caching.
**Note**: The `SITEMAP_REVALIDATE_SECONDS` env var becomes unused and should be cleaned up (or left for future sitemap-index implementation).
#### Fix 2: Remove Silent Error Suppression
**Constraint**: `fetchGameEntries()` and `fetchDeviceEntries()` MUST NOT silently return empty arrays on failure. Errors must propagate so they're observable.
**Design**: Remove the `try/catch` wrappers from both functions. Let DB errors surface naturally. The sitemap route will return HTTP 500 on DB failure, which is the correct behavior — Googlebot retries on 5xx and the error is logged in Vercel/Cloudflare observability.
If partial resilience is desired (static entries even when DB is down), implement it at the `app/sitemap.ts` level with explicit structured logging:
```typescript
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const staticEntries = buildStaticEntries()
try {
const { gameEntries, deviceEntries } = await fetchDynamicEntries()
console.info("[Sitemap] Generated", { games: gameEntries.length, devices: deviceEntries.length })
return [...staticEntries, ...gameEntries, ...deviceEntries]
} catch (err) {
console.error("[Sitemap] DB query failed, returning static entries only", err)
return staticEntries
}
}
```
This way a DB failure still returns a partial sitemap (not 500), but the error is logged with full context rather than silently swallowed.
#### Fix 3: Handle NULL `syncStatus` Explicitly
**Constraint**: The sitemap query MUST include rows where `syncStatus IS NULL`. Use an explicit `OR` condition.
```sql
-- Current (buggy): WHERE "sync_status" <> 'failed'
-- Correct: WHERE ("sync_status" <> 'failed' OR "sync_status" IS NULL)
```
In Drizzle:
```typescript
import { or, ne, isNull } from "drizzle-orm"
// ...
.where(or(ne(games.syncStatus, "failed"), isNull(games.syncStatus)))
```
#### Fix 4: Add Structured Logging for Monitoring
**Constraint**: The sitemap generator MUST log a structured metrics object on each successful generation so operations can monitor sitemap health over time.
```typescript
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(),
}))
```
This integrates with existing observability (Vercel logs, Cloudflare Logpush, or a logging drain) without requiring custom HTTP headers.
#### Fix 5: Cloudflare Cache Rule Audit
**Constraint**: If Cloudflare is in use, verify there is NO cache rule matching `/sitemap.xml` with a non-zero TTL. The sitemap must be fetched fresh from origin on every request.
- Check: Page Rules, Cache Rules, and Transform Rules
- Verify: `Content-Type` is not being transformed (must remain `application/xml`)
- Verify: Cloudflare's "Always Online" feature is not serving a stale copy from its own cache
### 2.4 Architecture: Flattened Sitemap Design
Following the reference pattern, the sitemap generator should be restructured as a single flat function in `app/sitemap.ts` rather than delegating to separate files with independent error handling.
**Design principles** (derived from reference):
1. **Single responsibility**: `app/sitemap.ts` owns the entire sitemap generation — DB queries, mapping, and error handling. No delegation to helper files that can fail independently.
2. **Explicit inclusion filter**: Instead of `WHERE syncStatus <> 'failed'` (exclusion), use a positive inclusion filter like `WHERE syncStatus IN ('pending', 'synced')` or omit the filter entirely and let all games appear. The reference project uses `eq(status, 'published')` — a positive assertion of what SHOULD be included.
3. **Inline mapping**: Game rows are mapped to sitemap entries directly in the sitemap function, not in a separate module. This keeps the transformation logic visible and testable in one place.
4. **No premature optimization**: Don't add limits, pagination, or caching until measurements prove they're needed. The reference project queries all matching rows without LIMIT and without caching.
**Recommended structure:**
```typescript
// app/sitemap.ts
export const dynamic = "force-dynamic"
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://deckyvault.xyz"
const staticEntries = [/* inline static pages */]
try {
const gameRows = await db.select({ id: games.id, updatedAt: games.updatedAt }).from(games)
const gameEntries = gameRows.map((g) => ({
url: `${baseUrl}/game/${g.id}`,
lastModified: g.updatedAt ?? new Date(),
changeFrequency: "weekly" as const,
priority: 0.7,
}))
console.info(JSON.stringify({
event: "sitemap_generated", games: gameEntries.length, generatedAt: new Date().toISOString()
}))
return [...staticEntries, ...gameEntries]
} catch (err) {
console.error(JSON.stringify({ event: "sitemap_db_error", error: String(err) }))
return staticEntries
}
}
```
### 2.5 Sitemap Security & Rate Limiting
- **No auth required**: Sitemap is public by design
- **No rate limiting on sitemap endpoint**: Crawlers must access it freely
- **DB query cost**: With `force-dynamic`, the sitemap hits the DB on every request. A `SELECT id, updated_at FROM games` with ~50K rows is sub-millisecond on modern PostgreSQL. If this becomes measurable, add a materialized view or a `WHERE` clause on an indexed status column.
---
## 3. Objective 2 — PWA & Steam Deck UX
### 3.1 Current State Baseline
| Feature | Status | Location |
|---|---|---|
| Web manifest (`manifest.json`) | ✅ Present | `app/manifest.ts``display: "standalone"`, theme color `#eb3779`, bg `#100b14` |
| Service Worker | ❌ Missing | No SW file exists |
| Offline fallback | ❌ Missing | No offline UI |
| Touch targets (44×44px WCAG) | ⚠️ Partial | Game cards use full-card `Link` wrapping ✅; filter chips and select inputs are undersized ❌ |
| Gamepad navigation | ❌ Missing | No Gamepad API integration |
| `viewport-fit=cover` | ❌ Missing | `layout.tsx` uses `overscroll-none` class but no `viewport` meta |
| App icon | ⚠️ Partial | `icon.png` exists but manifest declares `"any"` size — should declare explicit 192px and 512px maskable icons |
| Offline caching strategy | ❌ Missing | No cache-first or stale-while-revalidate strategy |
### 3.2 PWA Architecture Constraints
These extend the existing PWA spec at `docs/2025-05-05-sync-pwa-architectural-spec.md` §5. The original decisions remain binding unless explicitly overridden here.
#### 3.2.1 Service Worker Generation
**Constraint**: Use `@serwist/next` (v9+) — NOT `next-pwa` (unmaintained). `@serwist/next` is the successor maintained by the same community and supports Next.js 15/16 App Router with Turbopack.
**Why `@serwist/next` over alternatives:**
- `next-pwa`: Unmaintained since 2023; broken on Next.js 14+
- `workbox-webpack-plugin`: Requires manual webpack config injection; doesn't work with Turbopack
- `@serwist/next`: Actively maintained, supports App Router, generates SW with TypeScript types, compatible with Next.js `instrumentation.ts` hook
**SW Caching Strategy:**
| Resource Type | Strategy | TTL | Rationale |
|---|---|---|---|
| App shell (HTML layout, CSS, fonts) | **Precache** | N/A (cache-first after install) | Must load instantly offline |
| Game detail pages (`/game/:id`) | **Stale-while-revalidate** | 24h | Show cached content immediately, refresh in background |
| Game listing pages (`/games`) | **Network-first** | 5min | Filters change results; stale data is misleading |
| Images (Steam CDN, SteamGridDB) | **Cache-first** | 30 days | Images rarely change; huge bandwidth savings |
| API responses (`/api/games/*`) | **Network-first** | N/A | Live data is critical for benchmarks |
| Static assets (`/_next/static/*`) | **Precache** | N/A (immutable hashes) | Standard Next.js behavior |
#### 3.2.2 Offline Fallback UI
**Constraint**: An `offline.html` page MUST be served for navigation requests when the network is unavailable and the requested page isn't cached. This is a static page in `public/offline.html` that displays:
- DeckyVault branding
- "You're offline" message
- List of previously viewed games (from SW cache metadata)
- "Go back" button (uses `history.back()`)
**Design**: The SW intercepts all navigation requests. If the network is unavailable and the page isn't in cache, respond with `offline.html` instead of the browser's default offline dinosaur. This provides a branded experience consistent with the installed PWA feel.
#### 3.2.3 Touch Targets
**Constraint**: All interactive elements in the `/games` filter panel, the `/search` results, and the game detail page MUST meet a **minimum 44×44px touch target**. This is WCAG 2.1 AA Level.
**Affected components:**
| Component | Current State | Required Fix |
|---|---|---|
| Filter chips (genre, device) | ~28px height | Increase to 44px with `min-h-[44px]` |
| Select dropdowns (sort, proton, anti-cheat) | ~34px | Increase to 44px |
| Checkbox labels (FSR, free, multiplayer) | Standard text | Add `py-2` for 44px vertical target |
| Search input | ~36px | Increase to 44px |
| Number inputs (min/max FPS, review %) | ~28px | Increase to 44px |
| "Clear all filters" text link | ~16px | Increase to 44px clickable area |
| Navbar links | Varies | Ensure 44px tap area with padding |
**Implementation approach**: Apply touch-optimized sizing via a CSS class `.touch-target` that sets `min-h-[44px] min-w-[44px]`. Use media queries **NOT** to restrict these to touch devices (impossible to detect reliably), but to apply them universally. The visual density trade-off is acceptable given the primary audience is handheld device users.
#### 3.2.4 Gamepad Navigation
**Constraint**: Use the Gamepad API with the following mapping:
| Input | Action | Scope |
|---|---|---|
| D-pad / Left Stick | Move focus between focusable elements (roving tabindex) | Global |
| A button (index 0) | Activate focused element (`click()`) | Global |
| B button (index 1) | Browser back (`history.back()`) | Global |
| X button (index 2) | Open search (`/search`) | Global |
| Y button (index 3) | Toggle filters panel (on `/games`) | Context-aware |
| L1 bumper (index 4) | Previous tab (on game detail page) | Context-aware |
| R1 bumper (index 5) | Next tab (on game detail page) | Context-aware |
**Activation rules:**
- Gamepad mode activates **only on first gamepad input detected** (any button press or axis movement beyond deadzone)
- Gamepad mode deactivates on any mouse movement or keyboard input
- While active, a subtle indicator (small icon in corner) shows gamepad mode is active
- Focus ring uses a custom CSS class `.gamepad-focus` (distinct from `:focus-visible`) — this prevents showing gamepad-style focus rings to mouse users
- All focusable elements get `tabindex="-1"` when gamepad mode is active, except the currently focused one which gets `tabindex="0"` (roving tabindex pattern)
**Dead zone**: Apply 0.15 deadzone to analog stick axes to prevent drift-induced focus movement.
**Implementation boundary**: A single custom hook `useGamepadNavigation(containerRef)` exported from `lib/hooks/use-gamepad-navigation.ts`. This hook manages all Gamepad API lifecycle, focus management, and activation detection. Individual pages consume it via a wrapper component.
#### 3.2.5 Viewport and PWA Meta Tags
**Constraint**: `app/layout.tsx` MUST include a `<meta name="viewport">` tag:
```html
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1, user-scalable=no" />
```
- `viewport-fit=cover`: Extends content into the notch/safe areas on modern devices
- `maximum-scale=1, user-scalable=no`: Prevents accidental zoom on double-tap during gameplay on Steam Deck touchscreen
**Additional required meta tags:**
```html
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#eb3779" />
```
**Manifest icon update:** `app/manifest.ts` must declare explicit sizes:
```json
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
]
```
### 3.3 PWA Security Constraints
- **Service Worker MUST NOT cache**:
- `/manage/*` (admin dashboard)
- `/profile/*` (authenticated user data)
- `/api/auth/*` (authentication endpoints)
- Any API response containing a `Set-Cookie` header
- POST, PATCH, DELETE, PUT API responses
- **Service Worker scope**: `/` (root) — standard PWA scope
- **CSP considerations**: The SW must not break Content-Security-Policy headers. Test with strict CSP.
- **HSTS**: Ensure HSTS headers are set at the reverse proxy/Cloudflare level to enforce HTTPS for SW registration (SW requires HTTPS).
---
## 4. Objective 3 — Advanced Search & Filtering
### 4.1 State Assessment
Per CHANGELOG v2026.0.95, the advanced search and filtering system was shipped. The following audit identifies gaps that require attention.
### 4.2 Implemented Features (Verification)
| Feature | Implementation | File | Verified |
|---|---|---|---|
| FPS range (min/max) | Query param → DB subquery | `lib/api/games-listing.ts` | ✅ |
| Device filter | Subquery on `gamePlatformSupport` + `performanceEntries` | `lib/api/games-listing.ts` | ✅ |
| FSR support filter | Subquery on `performanceEntries.upscalerType = 'fsr'` | `lib/api/games-listing.ts` | ✅ |
| Proton/Native filter | Subquery on `gamePlatformSupport.protonStatus` | `lib/api/games-listing.ts` | ✅ |
| Anti-cheat filter | Subquery on `gamePlatformSupport.antiCheatStatus` | `lib/api/games-listing.ts` | ✅ |
| Playability filter | Direct `WHERE` on `games.playabilityStatus` | `lib/api/games-listing.ts` | ✅ |
| Steam review min % | Direct `WHERE gte(games.steamReviewScore)` | `lib/api/games-listing.ts` | ✅ |
| Free-to-play filter | Direct `WHERE eq(games.isFree, true)` | `lib/api/games-listing.ts` | ✅ |
| Multiplayer filter | Direct `WHERE` on `onlineMultiplayerStatus` | `lib/api/games-listing.ts` | ✅ |
| Saved/bookmarked filters | CRUD API + `SavedFilters` component | `lib/api/saved-filters.ts` + `components/saved-filters.tsx` | ✅ |
| Sort: Recent, Name, Benchmarks, Performance, Popularity, Release Date, Steam Reviews | 7 sort dimensions | `games-page-client.tsx` + `games-listing.ts` | ✅ |
| Infinite scroll with IntersectionObserver | Client-side | `games-page-client.tsx` | ✅ |
| Genre multi-select | Client-side filter chips | `games-page-client.tsx` | ✅ |
### 4.3 Gaps & Refinements
#### Gap 1: Genre Multi-Select Limited to Single Genre
**Current behavior**: `games-listing.ts` only accepts a single `genre` parameter (`query.genre`). The client-side UI allows selecting multiple genres, but `buildUrl` only sends the first one:
```typescript
// games-page-client.tsx (lines ~108)
if (selectedGenres.length === 1) params.set("genre", selectedGenres[0])
```
**Impact**: Selecting multiple genres in the filter panel silently ignores all but the first. The user sees 2+ active genre chips but only gets results for one.
**Fix**: The API must support a comma-separated genre list (or repeated `genre` params) and use `OR` logic:
```sql
-- Current: WHERE genres @> '["Action"]'::jsonb
-- Needed: WHERE genres @> '["Action"]'::jsonb OR genres @> '["RPG"]'::jsonb
```
**Constraint**: Genre multi-select uses **OR** logic (game matches ANY selected genre), not AND (game must match ALL). This is the standard UX pattern for discovery filters.
#### Gap 2: Search/Title Search on `/games` Doesn't Use Fuzzy Matching
**Current behavior**: The `/games` page search uses `ilike(games.title, titleTerm)` where `titleTerm = fuzzySearchTerm(search)`. The fuzzy helper converts spaces to `%` wildcards for multi-word matching but doesn't handle typos.
**Constraint**: This is acceptable for a filter page (users know what they're looking for). The dedicated `/search` page (`search-unified.ts`) handles broader discovery including Steam API fallback. No change required.
#### Gap 3: FPS Filter Performance
**Current behavior**: FPS range filter uses a subquery:
```sql
WHERE games.id IN (SELECT gv.game_id FROM performance_entries pe JOIN game_versions gv ON pe.version_id = gv.id WHERE pe.fps_avg BETWEEN min AND max GROUP BY gv.game_id)
```
This scans `performance_entries` without device filtering. A game with a 60fps benchmark on a high-end desktop (but 15fps on Steam Deck) would pass the FPS filter, misleading Deck users.
**Constraint**: When a device filter is active concurrently with FPS range, the FPS subquery MUST scope to that device:
```sql
WHERE pe.hardware_slug = '<device>' AND pe.fps_avg BETWEEN min AND max
```
When no device filter is active, the current behavior (any device) is acceptable as a coarse filter.
#### Gap 4: No Search Within Results
**Current behavior**: Search and filters operate independently. There's no way to "search within filtered results" — the search text always searches the full games table.
**Constraint**: This is acceptable for the current scope. The search bar + filter panel on `/games` provides sufficient narrowing. Adding "search within results" would require combining text search conditions with all filter subquery conditions, which is a query complexity concern. Mark as a future enhancement.
#### Gap 5: Saved Filters — No Visual Feedback on Load
**Current behavior**: When loading a saved filter, the filter state updates but there's no visual confirmation (toast, animation, or active state on the loaded filter pill).
**Constraint**: Add a brief highlight animation to the loaded filter pill and auto-collapse the filter panel after load. This provides immediate visual feedback that the action succeeded.
#### Gap 6: Missing "Search Intent" URL Parameter Tracking
**Current behavior**: Filter state is purely client-side. Refreshing the `/games` page resets all filters. The URL never reflects active filters (no query params in the address bar).
**Impact**: Users can't share filtered views or bookmark specific filter combinations (beyond the saved-filters feature which requires auth).
**Constraint**: Filter state SHOULD be reflected in the URL as query parameters. This enables:
- Sharing filtered views via link
- Browser back/forward through filter changes
- Bookmarking filtered views without auth
- Better analytics tracking
**Implementation approach**: Use `nuqs` or manual `useSearchParams` + `router.replace` to sync filter state to URL.
---
## 5. Objective 4 — Changelog
### 5.1 Changelog Procedure
**Constraint**: The CHANGELOG.md follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format. Each version entry uses:
- `## [version] - YYYY-MM-DD`
- `### Added`, `### Changed`, `### Fixed`, `### Technical` subsections
**Pending entries for next version (v2026.0.97):**
Based on the work scoped in this spec, the following entries should be appended:
```
## [2026.0.97] - 2026-05-XX
### 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-Steam-Deck devices; now scoped to active 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 support (D-pad/left stick focus, A/B/X/Y buttons, L1/R1 tab switching)
- Structured logging for sitemap generation metrics (games count, timestamps) via `console.info` JSON
- Filter state synchronized to URL query parameters for shareable/bookmarkable filtered views
### Changed
- All interactive elements on `/games` and `/search` pages now meet 44×44px minimum touch targets (WCAG 2.1 AA)
- 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
- Loading a saved filter now highlights the filter pill and collapses the panel for visual feedback
- Sitemap generation flattened into a single function in `app/sitemap.ts` (removed delegation to `lib/sitemap/`)
```
### 5.2 Version Bump
**Constraint**: The version in `package.json` (`"version": "2026.0.96"`) must be bumped to `"2026.0.97"` when any of the above work is shipped. The changelog date `2026-05-XX` must be replaced with the actual release date.
---
## 6. Architecture Boundaries — Cross-Cutting
### 6.1 Performance Budget
| Metric | Budget | Rationale |
|---|---|---|
| Service Worker size | < 50KB (compressed) | Large SW delays registration and first paint |
| Offline page size | < 10KB (total, including inline CSS) | Must load instantly on slow connections |
| Gamepad hook bundle impact | < 3KB (gzipped) | Added to every page that imports it |
| Sitemap generation time | < 5s (p95) | Prevents request timeout during Googlebot crawl |
| Sitemap response size | < 50MB (uncompressed) or 50,000 URLs | Google's sitemap limit. Beyond this, use sitemap index |
### 6.2 Dependency Additions
| Package | Purpose | Version Constraint |
|---|---|---|
| `@serwist/next` | Service worker generation for Next.js App Router | `^9.0.0` |
| `@serwist/precaching` | Precaching utilities | `^9.0.0` (peer) |
| `@serwist/sw` | Service worker runtime | `^9.0.0` (peer) |
| `nuqs` | URL query state management (optional, alternative to manual `useSearchParams`) | `^2.0.0` |
**No other new dependencies are required.** Gamepad API is browser-native. Touch targets are CSS-only. Sitemap fixes are logic changes to existing code.
### 6.3 Files Affected (Summary)
| File | Change Type | Objective |
|---|---|---|
| `app/sitemap.ts` | **Rewrite** — flatten to single function, `force-dynamic`, inline DB queries, structured logging, NULL-safe filter | Sitemap |
| `lib/sitemap/fetch-dynamic-entries.ts` | **Mark deprecated** (or delete if no other consumers) — logic moves into `app/sitemap.ts` | Sitemap |
| `lib/api/games-listing.ts` | Modify — multi-genre support, device-scoped FPS filter | Search |
| `app/games/games-page-client.tsx` | Modify — multi-genre URL params, touch targets, URL sync | Search + PWA |
| `app/manifest.ts` | Modify — explicit icon sizes (192px maskable, 512px any) | PWA |
| `app/layout.tsx` | Modify — viewport meta, PWA meta tags (`apple-mobile-web-app-capable`, `theme-color`) | PWA |
| `components/saved-filters.tsx` | Modify — load highlight animation, auto-collapse panel | Search |
| `public/offline.html` | **Create** — offline fallback page with branding + previously-viewed games list | PWA |
| `lib/hooks/use-gamepad-navigation.ts` | **Create** — Gamepad API hook with activation detection, roving tabindex, focus ring | PWA |
| `instrumentation.ts` or `app/sw.ts` | **Create** — Service worker entry point (via @serwist/next) | PWA |
| `CHANGELOG.md` | Modify — append v2026.0.97 entries | Changelog |
| `package.json` | Modify — version bump to 2026.0.97, add @serwist deps | Changelog + PWA |
### 6.4 Files NOT Affected
- `lib/steam/sync.ts` — No changes (sync unification was completed in previous spec)
- `lib/db/schema/*` — No schema changes
- `app/api/*` — No API route changes beyond games-listing
- `components/navbar.tsx` — No layout changes
- `app/search/page.tsx` — Search page uses unified search API, already complete
---
## 7. Risk Register
| # | Risk | Probability | Impact | Mitigation |
|---|---|---|---|---|
| R1 | `@serwist/next` incompatible with Next.js 16 + Turbopack | Medium | High (PWA blocked entirely) | Verify compatibility in a branch before full implementation. Fallback: manual `workbox-build` integration as a `postbuild` script. |
| R2 | Service worker caches stale game data shown to users | Medium | Medium | Stale-while-revalidate with 24h max TTL. Service worker update flow triggers refresh on new version detection. |
| R3 | Gamepad API unavailable on some Steam Deck firmware versions | Low | Medium | Feature-detect `navigator.getGamepads`. Degrade silently with no gamepad functionality. SteamOS 3.5+ ships Chromium 114+ which has Gamepad API. |
| R4 | Touch target increases break the visual design on desktop | Low | Low | Desktop users benefit from larger click targets too. Current design uses generous spacing already. |
| R5 | `force-dynamic` sitemap causes excessive DB load under crawler traffic | Low | Low | Googlebot fetches sitemap ~once daily. `SELECT id, updated_at FROM games` on ~50K rows is single-digit ms on PostgreSQL. |
| R6 | URL-based filter state causes excessive history entries | Medium | Low | Use `router.replace` instead of `router.push` to avoid polluting browser history with every filter change. |
---
## 8. Out of Scope (Explicitly)
- **Steam Deck OLED vs LCD device-specific filtering**: Current device filter already supports this via hardware slugs
- **Steam Machine-specific optimizations**: Already in the hardware table
- **Native mobile app packaging**: PWA-only approach is the stated objective
- **ProtonDB integration beyond existing links**: Already on search results
- **Review snippet rich results**: Structured data for reviews — separate SEO project
- **Dark mode toggle**: Already dark-mode-only by design (`bg-background text-text`)
- **Internationalization / i18n**: English-only for current scope
- **Performance budget enforcement in CI**: Separate devops project
- **Removing `/app/updates/` directory**: Cosmetic, deferred per original spec
---
## 9. Validation Checklist (Post-Implementation)
### Sitemap
- [ ] Sitemap uses `export const dynamic = "force-dynamic"` (NOT `revalidate`)
- [ ] Games with NULL `syncStatus` are included (verified via `sync_status IS NULL` check)
- [ ] DB errors are logged as structured JSON (`console.info`/`console.error` with `event` field)
- [ ] DB errors return partial sitemap (static entries only), not HTTP 500
- [ ] Google Search Console successfully parses and indexes sitemap
- [ ] Cloudflare is NOT caching `/sitemap.xml` (verified in dashboard)
- [ ] `lib/sitemap/` directory is either cleaned up or the old `fetchDynamicEntries` function is deprecated
### PWA
- [ ] Service worker registers without errors in Chrome DevTools
- [ ] Game pages are available offline after first visit
- [ ] `offline.html` served for uncached pages when offline
- [ ] Images from Steam CDN served from cache on revisit
- [ ] Authenticated pages are NOT cached by service worker
- [ ] PWA install prompt appears on Chrome for Android / Steam Deck
- [ ] Manifest icons display correctly on home screen
### Touch & Gamepad
- [ ] All filter controls on `/games` have ≥44×44px touch targets (verified via DevTools element inspection)
- [ ] Gamepad D-pad navigates through game cards on `/games`
- [ ] A button activates/opens focused game
- [ ] B button navigates back
- [ ] Gamepad focus ring appears only after gamepad input
- [ ] Gamepad focus ring disappears on mouse input
### Search & Filters
- [ ] Multi-genre selection filters correctly (OR logic)
- [ ] FPS filter respects device when device filter is active
- [ ] Saved filter load shows visual feedback (highlight + panel collapse)
- [ ] Filter state is reflected in URL query parameters
- [ ] Browser back/forward changes filter state correctly
- [ ] Direct URL with filter params applies filters on load
### Changelog & Version
- [ ] CHANGELOG.md has v2026.0.97 entry
- [ ] `package.json` version is `2026.0.97`
+13 -2
View File
@@ -51,9 +51,18 @@ export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
) )
} }
// Genre filter // Genre filter (supports comma-separated list with OR logic)
if (genre) { if (genre) {
conditions.push(sql`${games.genres} @> ${JSON.stringify([genre])}::jsonb`) const genres = genre.split(",").filter(Boolean)
if (genres.length === 1) {
conditions.push(sql`${games.genres} @> ${JSON.stringify([genres[0]])}::jsonb`)
} else if (genres.length > 1) {
// OR logic: game matches ANY of the selected genres
const genreConditions = genres.map(
(g) => sql`${games.genres} @> ${JSON.stringify([g])}::jsonb`,
)
conditions.push(or(...genreConditions)!)
}
} }
// Device filter // Device filter
@@ -96,6 +105,8 @@ export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
eq(performanceEntries.isRemoved, false), eq(performanceEntries.isRemoved, false),
minFps ? gte(performanceEntries.fpsAvg, Number(minFps)) : undefined, minFps ? gte(performanceEntries.fpsAvg, Number(minFps)) : undefined,
maxFps ? lte(performanceEntries.fpsAvg, Number(maxFps)) : undefined, maxFps ? lte(performanceEntries.fpsAvg, Number(maxFps)) : undefined,
// Scope to active device when device filter is set
device ? eq(performanceEntries.hardwareSlug, device) : undefined,
].filter((c): c is SQL => c !== undefined) ].filter((c): c is SQL => c !== undefined)
const fpsSubquery = db const fpsSubquery = db
+211
View File
@@ -0,0 +1,211 @@
"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 }
}
@@ -1,171 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { fetchDynamicEntries } from "@/lib/sitemap/fetch-dynamic-entries"
vi.mock("@/lib/db/index", () => ({
db: {
select: vi.fn(),
},
}))
vi.mock("@/lib/db/schema", () => ({
games: {
id: "id",
updatedAt: "updatedAt",
capsuleImage: "capsuleImage",
syncStatus: "syncStatus",
},
hardware: {
slug: "slug",
createdAt: "createdAt",
},
}))
function mockDrizzleQuery(rows: Record<string, unknown>[]) {
const limit = vi.fn().mockResolvedValue(rows)
const where = vi.fn().mockReturnValue({ limit })
const from = vi.fn().mockReturnValue({ where, limit })
return { select: vi.fn().mockReturnValue({ from, where, limit }) }
}
function mockDrizzleFailingQuery() {
const limit = vi.fn().mockRejectedValue(new Error("DB error"))
const where = vi.fn().mockReturnValue({ limit })
const from = vi.fn().mockReturnValue({ where, limit })
return { select: vi.fn().mockReturnValue({ from, where, limit }) }
}
describe("fetchDynamicEntries", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("returns game entries with validated images", async () => {
const { db } = await import("@/lib/db/index")
const mockDb = db as unknown as { select: ReturnType<typeof vi.fn> }
const gameRows = [
{ id: "game-1", updatedAt: new Date("2024-01-01"), capsuleImage: "https://cdn.example.com/img1.jpg", syncStatus: "synced" },
{ id: "game-2", updatedAt: new Date("2024-02-01"), capsuleImage: null, syncStatus: "synced" },
]
const deviceRows = [
{ slug: "steam-deck", createdAt: new Date("2023-06-01") },
]
const gamesChain = mockDrizzleQuery(gameRows)
const devicesChain = mockDrizzleQuery(deviceRows)
let selectCallIndex = 0
mockDb.select.mockImplementation(() => {
selectCallIndex++
if (selectCallIndex === 1) return gamesChain.select()
return devicesChain.select()
})
const result = await fetchDynamicEntries()
expect(result.gameEntries).toHaveLength(2)
expect(result.gameEntries[0].url).toBe("https://deckyvault.xyz/game/game-1")
expect(result.gameEntries[0].images).toEqual(["https://cdn.example.com/img1.jpg"])
expect(result.gameEntries[1].url).toBe("https://deckyvault.xyz/game/game-2")
expect(result.gameEntries[1].images).toBeUndefined()
expect(result.deviceEntries).toHaveLength(1)
})
it("returns device entries from hardware table", async () => {
const { db } = await import("@/lib/db/index")
const mockDb = db as unknown as { select: ReturnType<typeof vi.fn> }
const deviceRows = [
{ slug: "steam-deck-oled", createdAt: new Date("2023-11-01") },
{ slug: "rog-ally", createdAt: new Date("2024-01-15") },
]
const gamesChain = mockDrizzleQuery([])
const devicesChain = mockDrizzleQuery(deviceRows)
let selectCallIndex = 0
mockDb.select.mockImplementation(() => {
selectCallIndex++
if (selectCallIndex === 1) return gamesChain.select()
return devicesChain.select()
})
const result = await fetchDynamicEntries()
expect(result.deviceEntries).toHaveLength(2)
expect(result.deviceEntries[0].url).toBe("https://deckyvault.xyz/devices/steam-deck-oled")
expect(result.deviceEntries[1].url).toBe("https://deckyvault.xyz/devices/rog-ally")
})
it("returns empty arrays when DB query fails", async () => {
const { db } = await import("@/lib/db/index")
const mockDb = db as unknown as { select: ReturnType<typeof vi.fn> }
const gamesChain = mockDrizzleFailingQuery()
const devicesChain = mockDrizzleFailingQuery()
let selectCallIndex = 0
mockDb.select.mockImplementation(() => {
selectCallIndex++
if (selectCallIndex === 1) return gamesChain.select()
return devicesChain.select()
})
const result = await fetchDynamicEntries()
expect(result.gameEntries).toEqual([])
expect(result.deviceEntries).toEqual([])
})
it("excludes invalid image URLs", async () => {
const { db } = await import("@/lib/db/index")
const mockDb = db as unknown as { select: ReturnType<typeof vi.fn> }
const gameRows = [
{ id: "game-a", updatedAt: new Date("2024-01-01"), capsuleImage: "", syncStatus: "synced" },
{ id: "game-b", updatedAt: new Date("2024-02-01"), capsuleImage: "http://cdn.example.com/img.jpg", syncStatus: "synced" },
{ id: "game-c", updatedAt: new Date("2024-03-01"), capsuleImage: "https://cdn.example.com/valid.jpg", syncStatus: "synced" },
]
const gamesChain = mockDrizzleQuery(gameRows)
const devicesChain = mockDrizzleQuery([])
let selectCallIndex = 0
mockDb.select.mockImplementation(() => {
selectCallIndex++
if (selectCallIndex === 1) return gamesChain.select()
return devicesChain.select()
})
const result = await fetchDynamicEntries()
expect(result.gameEntries).toHaveLength(3)
expect(result.gameEntries[0].images).toBeUndefined()
expect(result.gameEntries[1].images).toBeUndefined()
expect(result.gameEntries[2].images).toEqual(["https://cdn.example.com/valid.jpg"])
})
it("returns partial data when one query succeeds and the other fails", async () => {
const { db } = await import("@/lib/db/index")
const mockDb = db as unknown as { select: ReturnType<typeof vi.fn> }
const gamesChain = mockDrizzleFailingQuery()
const deviceRows = [
{ slug: "lenovo-legion-go", createdAt: new Date("2024-03-01") },
]
const devicesChain = mockDrizzleQuery(deviceRows)
let selectCallIndex = 0
mockDb.select.mockImplementation(() => {
selectCallIndex++
if (selectCallIndex === 1) return gamesChain.select()
return devicesChain.select()
})
const result = await fetchDynamicEntries()
expect(result.gameEntries).toEqual([])
expect(result.deviceEntries).toHaveLength(1)
expect(result.deviceEntries[0].url).toBe("https://deckyvault.xyz/devices/lenovo-legion-go")
})
})
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect, vi } from "vitest"
vi.mock("@/lib/db/index", () => ({
db: {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
}),
},
}))
vi.mock("@/lib/db/schema", () => ({
games: { id: "id", updatedAt: "updatedAt", capsuleImage: "capsuleImage", syncStatus: "syncStatus" },
hardware: { slug: "slug", createdAt: "createdAt" },
}))
vi.mock("drizzle-orm", () => ({
or: vi.fn((...args) => args[0]),
ne: vi.fn((col) => col),
isNull: vi.fn((col) => col),
}))
describe("Sitemap Generator", () => {
it("exports dynamic = force-dynamic", async () => {
const mod = await import("@/app/sitemap")
expect(mod.dynamic).toBe("force-dynamic")
})
it("default export is a function", async () => {
const mod = await import("@/app/sitemap")
expect(typeof mod.default).toBe("function")
})
})
@@ -1,69 +0,0 @@
import { describe, it, expect } from "vitest"
import { validateImageUrl } from "@/lib/sitemap/validate-image-url"
describe("validateImageUrl", () => {
it("returns null for null input", () => {
expect(validateImageUrl(null)).toBeNull()
})
it("returns null for undefined input", () => {
expect(validateImageUrl(undefined)).toBeNull()
})
it("returns null for empty string", () => {
expect(validateImageUrl("")).toBeNull()
})
it("returns null for whitespace-only string", () => {
expect(validateImageUrl(" ")).toBeNull()
})
it("returns null for http:// URL", () => {
expect(validateImageUrl("http://example.com/image.png")).toBeNull()
})
it("returns null for relative URL", () => {
expect(validateImageUrl("/images/hero.png")).toBeNull()
})
it("returns null for protocol-less URL", () => {
expect(validateImageUrl("example.com/image.png")).toBeNull()
})
it("returns null for URL exceeding 2048 characters", () => {
const longUrl = "https://example.com/" + "a".repeat(2040)
expect(longUrl.length).toBeGreaterThan(2048)
expect(validateImageUrl(longUrl)).toBeNull()
})
it("returns the same URL for a valid HTTPS URL", () => {
const url = "https://example.com/image.png"
expect(validateImageUrl(url)).toBe(url)
})
it("trims whitespace from a valid URL", () => {
const url = "https://example.com/image.png"
expect(validateImageUrl(` ${url} `)).toBe(url)
})
it("returns URL when exactly 2048 characters", () => {
const url = "https://example.com/" + "a".repeat(2048 - "https://example.com/".length)
expect(url.length).toBe(2048)
expect(validateImageUrl(url)).toBe(url)
})
it("returns null for URL at 2049 characters", () => {
const url = "https://example.com/" + "a".repeat(2049 - "https://example.com/".length)
expect(url.length).toBe(2049)
expect(validateImageUrl(url)).toBeNull()
})
it("returns realistic Steam capsule and SteamGridDB URLs unchanged", () => {
const steamCapsule =
"https://cdn.akamai.steamstatic.com/steam/apps/1245620/capsule_616x353.jpg"
const steamGridDb =
"https://www.steamgriddb.com/api/v2/images/grid/12345-abcdef.png"
expect(validateImageUrl(steamCapsule)).toBe(steamCapsule)
expect(validateImageUrl(steamGridDb)).toBe(steamGridDb)
})
})
-50
View File
@@ -1,50 +0,0 @@
import type { MetadataRoute } from "next"
const BASE_URL = "https://deckyvault.xyz"
/**
* Builds static sitemap entries for publicly-indexable pages.
*
* Auth pages (`/login`, `/signup`) are intentionally excluded because
* they provide no indexable content. Search engines should not surface
* authentication flows as standalone results.
*
* Also excluded: `/manage` (admin dashboard, noindex), `/search`
* (parameterized results, no canonical representation), `/profile`
* (user-specific), `/compare` (parameterized tool), and any `/api/*`
* routes (robots.txt disallow).
*/
export 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,
},
]
}
-70
View File
@@ -1,70 +0,0 @@
import type { MetadataRoute } from "next"
import { db } from "@/lib/db/index"
import { games, hardware } from "@/lib/db/schema"
import { ne } from "drizzle-orm"
import { validateImageUrl } from "./validate-image-url"
const BASE_URL = "https://deckyvault.xyz"
const MAX_GAME_ENTRIES = 49_700
const MAX_DEVICE_ENTRIES = 200
async function fetchGameEntries(): Promise<MetadataRoute.Sitemap> {
try {
const rows = await db
.select({
id: games.id,
updatedAt: games.updatedAt,
capsuleImage: games.capsuleImage,
syncStatus: games.syncStatus,
})
.from(games)
.where(ne(games.syncStatus, "failed"))
.limit(MAX_GAME_ENTRIES)
return rows.map((row) => {
const validatedImage = validateImageUrl(row.capsuleImage)
return {
url: `${BASE_URL}/game/${row.id}`,
lastModified: row.updatedAt,
changeFrequency: "weekly" as const,
priority: 0.7,
...(validatedImage ? { images: [validatedImage] } : {}),
}
})
} catch {
return []
}
}
async function fetchDeviceEntries(): Promise<MetadataRoute.Sitemap> {
try {
const rows = await db
.select({
slug: hardware.slug,
createdAt: hardware.createdAt,
})
.from(hardware)
.limit(MAX_DEVICE_ENTRIES)
return rows.map((row) => ({
url: `${BASE_URL}/devices/${row.slug}`,
lastModified: row.createdAt,
changeFrequency: "monthly" as const,
priority: 0.5,
}))
} catch {
return []
}
}
export async function fetchDynamicEntries(): Promise<{
gameEntries: MetadataRoute.Sitemap
deviceEntries: MetadataRoute.Sitemap
}> {
const [gameEntries, deviceEntries] = await Promise.all([
fetchGameEntries(),
fetchDeviceEntries(),
])
return { gameEntries, deviceEntries }
}
-16
View File
@@ -1,16 +0,0 @@
/**
* Validates an image URL for sitemap use.
* Returns `null` for invalid inputs, otherwise returns the trimmed HTTPS URL.
*/
export function validateImageUrl(url: string | null | undefined): string | null {
if (!url || typeof url !== "string") return null
const trimmed = url.trim()
if (trimmed.length === 0) return null
if (!trimmed.startsWith("https://")) return null
if (trimmed.length > 2048) return null
return trimmed
}
+6 -1
View File
@@ -1,4 +1,5 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
import withSerwist from "@serwist/next";
import { version } from "./package.json"; import { version } from "./package.json";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
@@ -43,4 +44,8 @@ const nextConfig: NextConfig = {
}, },
}; };
export default nextConfig; export default withSerwist({
swSrc: "app/sw.ts",
swDest: "public/sw.js",
disable: process.env.NODE_ENV === "development",
})(nextConfig);
+10 -3
View File
@@ -1,10 +1,10 @@
{ {
"name": "deckyvault", "name": "deckyvault",
"version": "2026.0.96", "version": "2026.0.97",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --experimental-https", "dev": "next dev --experimental-https --webpack",
"build": "next build", "build": "next build --webpack",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
@@ -20,6 +20,12 @@
"@better-auth/drizzle-adapter": "^1.6.9", "@better-auth/drizzle-adapter": "^1.6.9",
"@better-auth/passkey": "^1.6.9", "@better-auth/passkey": "^1.6.9",
"@elysia/eden": "^1.4.10", "@elysia/eden": "^1.4.10",
"@serwist/expiration": "^9.5.11",
"@serwist/next": "^9.5.11",
"@serwist/precaching": "^9.5.11",
"@serwist/routing": "^9.5.11",
"@serwist/strategies": "^9.5.11",
"@serwist/sw": "^9.5.11",
"@tiptap/core": "^3.22.4", "@tiptap/core": "^3.22.4",
"@tiptap/extension-link": "^3.22.4", "@tiptap/extension-link": "^3.22.4",
"@tiptap/extension-placeholder": "^3.22.4", "@tiptap/extension-placeholder": "^3.22.4",
@@ -46,6 +52,7 @@
"remark": "^15.0.1", "remark": "^15.0.1",
"remark-rehype": "^11.1.2", "remark-rehype": "^11.1.2",
"resend": "^6.12.2", "resend": "^6.12.2",
"serwist": "^9.5.11",
"web-haptics": "^0.0.6" "web-haptics": "^0.0.6"
}, },
"devDependencies": { "devDependencies": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

+68
View File
@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Offline — DeckyVault</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font-lexend, 'Lexend', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
background-color: #100b14;
color: #ebe4f1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
text-align: center;
}
.logo {
font-size: 1.5rem;
font-weight: 700;
color: #eb3779;
margin-bottom: 2rem;
letter-spacing: -0.02em;
}
h1 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
p {
font-size: 0.875rem;
color: #ebe4f1aa;
max-width: 24rem;
line-height: 1.6;
margin-bottom: 2rem;
}
.back-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #eb3779;
color: #100b14;
font-weight: 600;
font-size: 0.875rem;
border: none;
border-radius: 0.5rem;
cursor: pointer;
text-decoration: none;
transition: background 0.2s;
}
.back-btn:hover { background: #d42d6a; }
.back-btn:active { background: #b8255c; }
</style>
</head>
<body>
<div class="logo">DeckyVault</div>
<h1>You're offline</h1>
<p>
This page isn't available right now. Check your internet connection and try again.
Previously visited game pages may still be available.
</p>
<button class="back-btn" onclick="history.back()">← Go Back</button>
</body>
</html>