feat: sitemap remediation, PWA activation, search refinements, Steam Deck UX

Sitemap:
- Switch from ISR (revalidate=3600) to force-dynamic for per-request generation
- Flatten into single app/sitemap.ts with inlined static entries and DB queries
- NULL-safe syncStatus filter: ne(games.syncStatus, 'failed') OR isNull(games.syncStatus)
- Structured JSON logging for generated URLs and DB errors
- Delete lib/sitemap/* helpers and app/api/revalidate-sitemap route
- Add basic vitest coverage for sitemap exports

PWA & Offline:
- Add @serwist/next service worker (webpack build) with runtime caching
- Cache strategies: stale-while-revalidate for game pages, network-first for listings/API,
  cache-first for Steam CDN images
- Offline fallback page (public/offline.html)
- Manifest icons: 192px maskable + 512px any
- Viewport meta with viewport-fit=cover, user-scalable=no
- Apple mobile web app meta tags

Search / Filter Refinements:
- Multi-genre OR support in listing API (comma-separated genres)
- Device-scoped FPS filter (min/max FPS constrained to selected device)
- Client-side URL state sync via router.replace for shareable filtered views
- Initialize filter state from URL params on mount
- Auto-collapse filter panel on saved-filter load
- Fix multi-genre saved filter parsing (comma-separated)

Steam Deck / Touch / Gamepad UX:
- WCAG 2.1 AA touch targets (44x44px) on all filter controls
- .gamepad-focus CSS focus ring for controller navigation
- useGamepadNavigation hook: D-pad/left-stick roving tabindex, A/B/X/Y actions
- Integrate gamepad hook into games page (X=search, Y=toggle filters)

Chore:
- Bump version to 2026.0.97
This commit is contained in:
2026-05-05 16:20:28 +08:00
parent 9be64359be
commit dcf6066b2b
25 changed files with 1396 additions and 489 deletions
@@ -1,171 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { fetchDynamicEntries } from "@/lib/sitemap/fetch-dynamic-entries"
vi.mock("@/lib/db/index", () => ({
db: {
select: vi.fn(),
},
}))
vi.mock("@/lib/db/schema", () => ({
games: {
id: "id",
updatedAt: "updatedAt",
capsuleImage: "capsuleImage",
syncStatus: "syncStatus",
},
hardware: {
slug: "slug",
createdAt: "createdAt",
},
}))
function mockDrizzleQuery(rows: Record<string, unknown>[]) {
const limit = vi.fn().mockResolvedValue(rows)
const where = vi.fn().mockReturnValue({ limit })
const from = vi.fn().mockReturnValue({ where, limit })
return { select: vi.fn().mockReturnValue({ from, where, limit }) }
}
function mockDrizzleFailingQuery() {
const limit = vi.fn().mockRejectedValue(new Error("DB error"))
const where = vi.fn().mockReturnValue({ limit })
const from = vi.fn().mockReturnValue({ where, limit })
return { select: vi.fn().mockReturnValue({ from, where, limit }) }
}
describe("fetchDynamicEntries", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("returns game entries with validated images", async () => {
const { db } = await import("@/lib/db/index")
const mockDb = db as unknown as { select: ReturnType<typeof vi.fn> }
const gameRows = [
{ id: "game-1", updatedAt: new Date("2024-01-01"), capsuleImage: "https://cdn.example.com/img1.jpg", syncStatus: "synced" },
{ id: "game-2", updatedAt: new Date("2024-02-01"), capsuleImage: null, syncStatus: "synced" },
]
const deviceRows = [
{ slug: "steam-deck", createdAt: new Date("2023-06-01") },
]
const gamesChain = mockDrizzleQuery(gameRows)
const devicesChain = mockDrizzleQuery(deviceRows)
let selectCallIndex = 0
mockDb.select.mockImplementation(() => {
selectCallIndex++
if (selectCallIndex === 1) return gamesChain.select()
return devicesChain.select()
})
const result = await fetchDynamicEntries()
expect(result.gameEntries).toHaveLength(2)
expect(result.gameEntries[0].url).toBe("https://deckyvault.xyz/game/game-1")
expect(result.gameEntries[0].images).toEqual(["https://cdn.example.com/img1.jpg"])
expect(result.gameEntries[1].url).toBe("https://deckyvault.xyz/game/game-2")
expect(result.gameEntries[1].images).toBeUndefined()
expect(result.deviceEntries).toHaveLength(1)
})
it("returns device entries from hardware table", async () => {
const { db } = await import("@/lib/db/index")
const mockDb = db as unknown as { select: ReturnType<typeof vi.fn> }
const deviceRows = [
{ slug: "steam-deck-oled", createdAt: new Date("2023-11-01") },
{ slug: "rog-ally", createdAt: new Date("2024-01-15") },
]
const gamesChain = mockDrizzleQuery([])
const devicesChain = mockDrizzleQuery(deviceRows)
let selectCallIndex = 0
mockDb.select.mockImplementation(() => {
selectCallIndex++
if (selectCallIndex === 1) return gamesChain.select()
return devicesChain.select()
})
const result = await fetchDynamicEntries()
expect(result.deviceEntries).toHaveLength(2)
expect(result.deviceEntries[0].url).toBe("https://deckyvault.xyz/devices/steam-deck-oled")
expect(result.deviceEntries[1].url).toBe("https://deckyvault.xyz/devices/rog-ally")
})
it("returns empty arrays when DB query fails", async () => {
const { db } = await import("@/lib/db/index")
const mockDb = db as unknown as { select: ReturnType<typeof vi.fn> }
const gamesChain = mockDrizzleFailingQuery()
const devicesChain = mockDrizzleFailingQuery()
let selectCallIndex = 0
mockDb.select.mockImplementation(() => {
selectCallIndex++
if (selectCallIndex === 1) return gamesChain.select()
return devicesChain.select()
})
const result = await fetchDynamicEntries()
expect(result.gameEntries).toEqual([])
expect(result.deviceEntries).toEqual([])
})
it("excludes invalid image URLs", async () => {
const { db } = await import("@/lib/db/index")
const mockDb = db as unknown as { select: ReturnType<typeof vi.fn> }
const gameRows = [
{ id: "game-a", updatedAt: new Date("2024-01-01"), capsuleImage: "", syncStatus: "synced" },
{ id: "game-b", updatedAt: new Date("2024-02-01"), capsuleImage: "http://cdn.example.com/img.jpg", syncStatus: "synced" },
{ id: "game-c", updatedAt: new Date("2024-03-01"), capsuleImage: "https://cdn.example.com/valid.jpg", syncStatus: "synced" },
]
const gamesChain = mockDrizzleQuery(gameRows)
const devicesChain = mockDrizzleQuery([])
let selectCallIndex = 0
mockDb.select.mockImplementation(() => {
selectCallIndex++
if (selectCallIndex === 1) return gamesChain.select()
return devicesChain.select()
})
const result = await fetchDynamicEntries()
expect(result.gameEntries).toHaveLength(3)
expect(result.gameEntries[0].images).toBeUndefined()
expect(result.gameEntries[1].images).toBeUndefined()
expect(result.gameEntries[2].images).toEqual(["https://cdn.example.com/valid.jpg"])
})
it("returns partial data when one query succeeds and the other fails", async () => {
const { db } = await import("@/lib/db/index")
const mockDb = db as unknown as { select: ReturnType<typeof vi.fn> }
const gamesChain = mockDrizzleFailingQuery()
const deviceRows = [
{ slug: "lenovo-legion-go", createdAt: new Date("2024-03-01") },
]
const devicesChain = mockDrizzleQuery(deviceRows)
let selectCallIndex = 0
mockDb.select.mockImplementation(() => {
selectCallIndex++
if (selectCallIndex === 1) return gamesChain.select()
return devicesChain.select()
})
const result = await fetchDynamicEntries()
expect(result.gameEntries).toEqual([])
expect(result.deviceEntries).toHaveLength(1)
expect(result.deviceEntries[0].url).toBe("https://deckyvault.xyz/devices/lenovo-legion-go")
})
})
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect, vi } from "vitest"
vi.mock("@/lib/db/index", () => ({
db: {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
}),
},
}))
vi.mock("@/lib/db/schema", () => ({
games: { id: "id", updatedAt: "updatedAt", capsuleImage: "capsuleImage", syncStatus: "syncStatus" },
hardware: { slug: "slug", createdAt: "createdAt" },
}))
vi.mock("drizzle-orm", () => ({
or: vi.fn((...args) => args[0]),
ne: vi.fn((col) => col),
isNull: vi.fn((col) => col),
}))
describe("Sitemap Generator", () => {
it("exports dynamic = force-dynamic", async () => {
const mod = await import("@/app/sitemap")
expect(mod.dynamic).toBe("force-dynamic")
})
it("default export is a function", async () => {
const mod = await import("@/app/sitemap")
expect(typeof mod.default).toBe("function")
})
})
@@ -1,69 +0,0 @@
import { describe, it, expect } from "vitest"
import { validateImageUrl } from "@/lib/sitemap/validate-image-url"
describe("validateImageUrl", () => {
it("returns null for null input", () => {
expect(validateImageUrl(null)).toBeNull()
})
it("returns null for undefined input", () => {
expect(validateImageUrl(undefined)).toBeNull()
})
it("returns null for empty string", () => {
expect(validateImageUrl("")).toBeNull()
})
it("returns null for whitespace-only string", () => {
expect(validateImageUrl(" ")).toBeNull()
})
it("returns null for http:// URL", () => {
expect(validateImageUrl("http://example.com/image.png")).toBeNull()
})
it("returns null for relative URL", () => {
expect(validateImageUrl("/images/hero.png")).toBeNull()
})
it("returns null for protocol-less URL", () => {
expect(validateImageUrl("example.com/image.png")).toBeNull()
})
it("returns null for URL exceeding 2048 characters", () => {
const longUrl = "https://example.com/" + "a".repeat(2040)
expect(longUrl.length).toBeGreaterThan(2048)
expect(validateImageUrl(longUrl)).toBeNull()
})
it("returns the same URL for a valid HTTPS URL", () => {
const url = "https://example.com/image.png"
expect(validateImageUrl(url)).toBe(url)
})
it("trims whitespace from a valid URL", () => {
const url = "https://example.com/image.png"
expect(validateImageUrl(` ${url} `)).toBe(url)
})
it("returns URL when exactly 2048 characters", () => {
const url = "https://example.com/" + "a".repeat(2048 - "https://example.com/".length)
expect(url.length).toBe(2048)
expect(validateImageUrl(url)).toBe(url)
})
it("returns null for URL at 2049 characters", () => {
const url = "https://example.com/" + "a".repeat(2049 - "https://example.com/".length)
expect(url.length).toBe(2049)
expect(validateImageUrl(url)).toBeNull()
})
it("returns realistic Steam capsule and SteamGridDB URLs unchanged", () => {
const steamCapsule =
"https://cdn.akamai.steamstatic.com/steam/apps/1245620/capsule_616x353.jpg"
const steamGridDb =
"https://www.steamgriddb.com/api/v2/images/grid/12345-abcdef.png"
expect(validateImageUrl(steamCapsule)).toBe(steamCapsule)
expect(validateImageUrl(steamGridDb)).toBe(steamGridDb)
})
})