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
115 lines
3.1 KiB
TypeScript
115 lines
3.1 KiB
TypeScript
import type { MetadataRoute } from "next"
|
|
import { db } from "@/lib/db/index"
|
|
import { games, hardware } from "@/lib/db/schema"
|
|
import { or, ne, isNull } from "drizzle-orm"
|
|
|
|
export const dynamic = "force-dynamic"
|
|
|
|
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://deckyvault.xyz"
|
|
|
|
function buildStaticEntries(): MetadataRoute.Sitemap {
|
|
return [
|
|
{
|
|
url: BASE_URL,
|
|
lastModified: new Date(),
|
|
changeFrequency: "weekly" as const,
|
|
priority: 1,
|
|
},
|
|
{
|
|
url: `${BASE_URL}/games`,
|
|
lastModified: new Date(),
|
|
changeFrequency: "daily" as const,
|
|
priority: 0.8,
|
|
},
|
|
{
|
|
url: `${BASE_URL}/devices`,
|
|
lastModified: new Date(),
|
|
changeFrequency: "monthly" as const,
|
|
priority: 0.6,
|
|
},
|
|
{
|
|
url: `${BASE_URL}/updates`,
|
|
lastModified: new Date(),
|
|
changeFrequency: "weekly" as const,
|
|
priority: 0.5,
|
|
},
|
|
{
|
|
url: `${BASE_URL}/contact`,
|
|
lastModified: new Date(),
|
|
changeFrequency: "yearly" as const,
|
|
priority: 0.3,
|
|
},
|
|
]
|
|
}
|
|
|
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
const staticEntries = buildStaticEntries()
|
|
|
|
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
|
|
}
|
|
}
|