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:
+2
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import { revalidatePath } from "next/cache"
|
|
||||||
import { NextRequest, NextResponse } from "next/server"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* On-demand sitemap revalidation webhook.
|
|
||||||
*
|
|
||||||
* What this endpoint does:
|
|
||||||
* Revalidates the `/sitemap.xml` path so Next.js regenerates the sitemap
|
|
||||||
* at the edge instead of waiting for the next ISR interval.
|
|
||||||
*
|
|
||||||
* When to call it:
|
|
||||||
* - After adding, updating, or removing games
|
|
||||||
* - After adding, updating, or removing hardware
|
|
||||||
* - After any bulk import or migration that affects public-facing URLs
|
|
||||||
*
|
|
||||||
* How to call it:
|
|
||||||
* ```bash
|
|
||||||
* curl -X POST https://<your-domain>/api/revalidate-sitemap \
|
|
||||||
* -H "Authorization: Bearer $REVALIDATE_SECRET"
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @see https://nextjs.org/docs/app/building-your-application/caching#on-demand-revalidation
|
|
||||||
*/
|
|
||||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
||||||
const authHeader = request.headers.get("authorization")
|
|
||||||
const secret = process.env.REVALIDATE_SECRET
|
|
||||||
|
|
||||||
if (!secret) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Revalidation not configured. Set REVALIDATE_SECRET env var." },
|
|
||||||
{ status: 503 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = authHeader?.replace("Bearer ", "")
|
|
||||||
if (token !== secret) {
|
|
||||||
return NextResponse.json({ error: "Invalid secret" }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
revalidatePath("/sitemap.xml")
|
|
||||||
return NextResponse.json({
|
|
||||||
revalidated: true,
|
|
||||||
path: "/sitemap.xml",
|
|
||||||
now: Date.now(),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Revalidate Sitemap] Failed:", error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Revalidation failed" },
|
|
||||||
{ status: 500 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"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>
|
||||||
|
|||||||
@@ -22,3 +22,17 @@
|
|||||||
/* 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
@@ -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 />
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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`
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
@@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
@@ -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>
|
||||||
Reference in New Issue
Block a user