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:
@@ -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) {
|
||||
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
|
||||
@@ -96,6 +105,8 @@ export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
minFps ? gte(performanceEntries.fpsAvg, Number(minFps)) : 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)
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user