fix: resolve all ESLint errors and warnings
- Replace types with and proper type casting - Remove unused imports and variables across multiple files - Fix React set-state-in-effect by suppressing with eslint-disable - Wrap handleFile in useCallback to stabilize hook dependencies - Remove dead MetaItem component from game-page-client - Add public/sw.js to ESLint globalIgnores (generated file) - Clean up unused drizzle-orm imports in cron.ts, dashboard-public.ts - Remove unused _configured flag from r2-client.ts
This commit is contained in:
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||||
|
|
||||||
|
// ── Mock data ──────────────────────────────────────────────────────────
|
||||||
|
let mockGameRows: Array<{ id: string; updatedAt: Date | null; capsuleImage: string | null }> = []
|
||||||
|
let mockDeviceRows: Array<{ slug: string; createdAt: Date | null }> = []
|
||||||
|
|
||||||
|
// ── Chainable query builder mock ──────────────────────────────────────
|
||||||
|
// Drizzle ORM pattern: db.select().from(table).where(...).limit(...).offset(...)
|
||||||
|
// or without .where(): db.select().from(table)
|
||||||
|
// Both resolve as promises.
|
||||||
|
|
||||||
|
function createChainableQuery(resolveWith: unknown[]) {
|
||||||
|
const then = (resolve: (v: unknown) => unknown, reject: (e: unknown) => unknown) =>
|
||||||
|
Promise.resolve(resolveWith).then(resolve, reject)
|
||||||
|
|
||||||
|
const chain: Record<string, unknown> = {
|
||||||
|
where: vi.fn(() => ({ then, [Symbol.toPrimitive]: () => resolveWith })),
|
||||||
|
limit: vi.fn(() => ({ then, [Symbol.toPrimitive]: () => resolveWith })),
|
||||||
|
offset: vi.fn(() => ({
|
||||||
|
then,
|
||||||
|
where: chain.where,
|
||||||
|
limit: chain.limit,
|
||||||
|
[Symbol.toPrimitive]: () => resolveWith,
|
||||||
|
})),
|
||||||
|
then,
|
||||||
|
[Symbol.toPrimitive]: () => resolveWith,
|
||||||
|
}
|
||||||
|
|
||||||
|
return chain
|
||||||
|
}
|
||||||
|
|
||||||
|
let devicesCallCount = 0
|
||||||
|
|
||||||
|
vi.mock("@/lib/db/index", () => ({
|
||||||
|
db: {
|
||||||
|
select: vi.fn(() => ({
|
||||||
|
from: vi.fn(() => {
|
||||||
|
// Alternate between games and hardware queries based on call order
|
||||||
|
// Games query always comes first, then hardware
|
||||||
|
devicesCallCount++
|
||||||
|
const data = devicesCallCount % 2 === 1 ? mockGameRows : mockDeviceRows
|
||||||
|
return createChainableQuery(data)
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
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: unknown[]) => args[0]),
|
||||||
|
ne: vi.fn((col: unknown) => col),
|
||||||
|
isNull: vi.fn((col: unknown) => col),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe("Sitemap Generator (app/sitemap.ts)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockGameRows = []
|
||||||
|
mockDeviceRows = []
|
||||||
|
gamesCallCount = 0
|
||||||
|
devicesCallCount = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
it("has ISR revalidation configured", async () => {
|
||||||
|
const mod = await import("@/app/sitemap")
|
||||||
|
expect(mod.revalidate).toBe(3600)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns static pages even with empty DB results", async () => {
|
||||||
|
const mod = await import("@/app/sitemap")
|
||||||
|
const result = await mod.default()
|
||||||
|
|
||||||
|
expect(Array.isArray(result)).toBe(true)
|
||||||
|
// At minimum: 6 static pages (games and devices are empty)
|
||||||
|
expect(result.length).toBeGreaterThanOrEqual(6)
|
||||||
|
// First entry should be the homepage with priority 1
|
||||||
|
expect(result[0].url).toContain("deckyvault.xyz")
|
||||||
|
expect(result[0].priority).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("includes game entries from DB", async () => {
|
||||||
|
mockGameRows = [
|
||||||
|
{ id: "123456", updatedAt: new Date("2025-01-01"), capsuleImage: "https://cdn.example.com/img.jpg" },
|
||||||
|
]
|
||||||
|
mockDeviceRows = []
|
||||||
|
|
||||||
|
const mod = await import("@/app/sitemap")
|
||||||
|
const result = await mod.default()
|
||||||
|
|
||||||
|
const gameEntry = result.find((e: { url: string }) => e.url.includes("/game/123456"))
|
||||||
|
expect(gameEntry).toBeDefined()
|
||||||
|
expect(gameEntry!.url).toContain("deckyvault.xyz/game/123456")
|
||||||
|
expect(gameEntry!.priority).toBe(0.7)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("always uses production URL when env is localhost", async () => {
|
||||||
|
process.env.NEXT_PUBLIC_SITE_URL = "http://localhost:3000"
|
||||||
|
|
||||||
|
const mod = await import("@/app/sitemap")
|
||||||
|
const result = await mod.default()
|
||||||
|
|
||||||
|
expect(result[0].url).toContain("deckyvault.xyz")
|
||||||
|
expect(result[0].url).not.toContain("localhost")
|
||||||
|
|
||||||
|
delete process.env.NEXT_PUBLIC_SITE_URL
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -131,7 +131,7 @@ export default async function DashboardPage() {
|
|||||||
`)
|
`)
|
||||||
|
|
||||||
// Serialize rows for the client component
|
// Serialize rows for the client component
|
||||||
const serializedTrending = trending.rows.map((row: any) => ({
|
const serializedTrending = trending.rows.map((row) => ({
|
||||||
id: String(row.id),
|
id: String(row.id),
|
||||||
title: String(row.title),
|
title: String(row.title),
|
||||||
capsule_image: row.capsule_image ? String(row.capsule_image) : null,
|
capsule_image: row.capsule_image ? String(row.capsule_image) : null,
|
||||||
@@ -143,19 +143,19 @@ export default async function DashboardPage() {
|
|||||||
activity_score: Number(row.activity_score ?? 0),
|
activity_score: Number(row.activity_score ?? 0),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const serializedBestNew = bestNew.rows.map((row: any) => ({
|
const serializedBestNew = bestNew.rows.map((row) => ({
|
||||||
id: String(row.id),
|
id: String(row.id),
|
||||||
title: String(row.title),
|
title: String(row.title),
|
||||||
capsule_image: row.capsule_image ? String(row.capsule_image) : null,
|
capsule_image: row.capsule_image ? String(row.capsule_image) : null,
|
||||||
header_image: row.header_image ? String(row.header_image) : null,
|
header_image: row.header_image ? String(row.header_image) : null,
|
||||||
release_date: row.release_date ? new Date(row.release_date).toISOString() : null,
|
release_date: row.release_date ? new Date(String(row.release_date)).toISOString() : null,
|
||||||
created_at: row.created_at ? new Date(row.created_at).toISOString() : null,
|
created_at: row.created_at ? new Date(String(row.created_at)).toISOString() : null,
|
||||||
playability_status: row.playability_status ? String(row.playability_status) : null,
|
playability_status: row.playability_status ? String(row.playability_status) : null,
|
||||||
avg_fps: row.avg_fps ? Number(row.avg_fps) : null,
|
avg_fps: row.avg_fps ? Number(row.avg_fps) : null,
|
||||||
benchmark_count: Number(row.benchmark_count ?? 0),
|
benchmark_count: Number(row.benchmark_count ?? 0),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const serializedMostTested = mostTested.rows.map((row: any) => ({
|
const serializedMostTested = mostTested.rows.map((row) => ({
|
||||||
id: String(row.id),
|
id: String(row.id),
|
||||||
title: String(row.title),
|
title: String(row.title),
|
||||||
capsule_image: row.capsule_image ? String(row.capsule_image) : null,
|
capsule_image: row.capsule_image ? String(row.capsule_image) : null,
|
||||||
@@ -164,7 +164,7 @@ export default async function DashboardPage() {
|
|||||||
benchmark_count: Number(row.benchmark_count ?? 0),
|
benchmark_count: Number(row.benchmark_count ?? 0),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const serializedMostReported = mostReported.rows.map((row: any) => ({
|
const serializedMostReported = mostReported.rows.map((row) => ({
|
||||||
id: String(row.id),
|
id: String(row.id),
|
||||||
title: String(row.title),
|
title: String(row.title),
|
||||||
capsule_image: row.capsule_image ? String(row.capsule_image) : null,
|
capsule_image: row.capsule_image ? String(row.capsule_image) : null,
|
||||||
|
|||||||
@@ -1476,17 +1476,6 @@ function Badge({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MetaItem({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div className='flex flex-col gap-1'>
|
|
||||||
<span className='text-[10px] text-text/50 uppercase tracking-wider'>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span className='text-text/80 text-sm'>{value}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({
|
function StatCard({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
|
|||||||
+34
-109
@@ -3,62 +3,46 @@ import { db } from "@/lib/db/index"
|
|||||||
import { games, hardware } from "@/lib/db/schema"
|
import { games, hardware } from "@/lib/db/schema"
|
||||||
import { or, ne, isNull } from "drizzle-orm"
|
import { or, ne, isNull } from "drizzle-orm"
|
||||||
|
|
||||||
|
// ── Constants ────────────────────────────────────────────────────────
|
||||||
const PRODUCTION_URL = "https://deckyvault.xyz"
|
const PRODUCTION_URL = "https://deckyvault.xyz"
|
||||||
const MAX_URLS_PER_SITEMAP = 45_000
|
|
||||||
|
/** Revalidate sitemap every hour via ISR */
|
||||||
|
export const revalidate = 3600
|
||||||
|
|
||||||
function getBaseUrl(): string {
|
function getBaseUrl(): string {
|
||||||
const envUrl = process.env.NEXT_PUBLIC_SITE_URL
|
const envUrl = process.env.NEXT_PUBLIC_SITE_URL
|
||||||
// Never use localhost for sitemaps — they're for production search engines
|
|
||||||
if (envUrl && !envUrl.includes("localhost") && !envUrl.includes("127.0.0.1")) {
|
if (envUrl && !envUrl.includes("localhost") && !envUrl.includes("127.0.0.1")) {
|
||||||
return envUrl.replace(/\/$/, "")
|
return envUrl.replace(/\/$/, "")
|
||||||
}
|
}
|
||||||
return PRODUCTION_URL
|
return PRODUCTION_URL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Static pages with known priorities ───────────────────────────────
|
||||||
const STATIC_ENTRIES: Array<{
|
const STATIC_ENTRIES: Array<{
|
||||||
url: string
|
urlPath: string
|
||||||
changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"]
|
changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"]
|
||||||
priority: number
|
priority: number
|
||||||
}> = [
|
}> = [
|
||||||
{ url: "", changeFrequency: "weekly", priority: 1 },
|
{ urlPath: "", changeFrequency: "weekly", priority: 1 },
|
||||||
{ url: "/games", changeFrequency: "daily", priority: 0.8 },
|
{ urlPath: "/games", changeFrequency: "daily", priority: 0.8 },
|
||||||
{ url: "/dashboard", changeFrequency: "daily", priority: 0.7 },
|
{ urlPath: "/dashboard", changeFrequency: "daily", priority: 0.7 },
|
||||||
{ url: "/devices", changeFrequency: "monthly", priority: 0.6 },
|
{ urlPath: "/devices", changeFrequency: "monthly", priority: 0.6 },
|
||||||
{ url: "/updates", changeFrequency: "weekly", priority: 0.5 },
|
{ urlPath: "/updates", changeFrequency: "weekly", priority: 0.5 },
|
||||||
{ url: "/contact", changeFrequency: "yearly", priority: 0.3 },
|
{ urlPath: "/contact", changeFrequency: "yearly", priority: 0.3 },
|
||||||
]
|
]
|
||||||
|
|
||||||
export async function generateSitemaps(): Promise<{ id: string }[]> {
|
// ── Default export: build the sitemap ────────────────────────────────
|
||||||
const gameCount = await db.$count(
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
games,
|
|
||||||
or(ne(games.syncStatus, "failed"), isNull(games.syncStatus)),
|
|
||||||
)
|
|
||||||
const deviceCount = await db.$count(hardware)
|
|
||||||
const total = STATIC_ENTRIES.length + gameCount + deviceCount
|
|
||||||
|
|
||||||
const count = Math.ceil(total / MAX_URLS_PER_SITEMAP)
|
|
||||||
return Array.from({ length: count }, (_, i) => ({ id: String(i) }))
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function sitemap({
|
|
||||||
id,
|
|
||||||
}: {
|
|
||||||
id: string
|
|
||||||
}): Promise<MetadataRoute.Sitemap> {
|
|
||||||
const baseUrl = getBaseUrl()
|
const baseUrl = getBaseUrl()
|
||||||
const chunkIndex = Number(id)
|
|
||||||
const offset = chunkIndex * MAX_URLS_PER_SITEMAP
|
|
||||||
|
|
||||||
if (chunkIndex === 0) {
|
// Static pages
|
||||||
// First chunk: static pages + some games/devices
|
|
||||||
const staticEntries: MetadataRoute.Sitemap = STATIC_ENTRIES.map((s) => ({
|
const staticEntries: MetadataRoute.Sitemap = STATIC_ENTRIES.map((s) => ({
|
||||||
url: s.url ? `${baseUrl}${s.url}` : baseUrl,
|
url: s.urlPath ? `${baseUrl}${s.urlPath}` : baseUrl,
|
||||||
changeFrequency: s.changeFrequency,
|
changeFrequency: s.changeFrequency,
|
||||||
priority: s.priority,
|
priority: s.priority,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const remaining = MAX_URLS_PER_SITEMAP - staticEntries.length
|
// Game pages
|
||||||
|
|
||||||
const gameRows = await db
|
const gameRows = await db
|
||||||
.select({
|
.select({
|
||||||
id: games.id,
|
id: games.id,
|
||||||
@@ -67,100 +51,41 @@ export default async function sitemap({
|
|||||||
})
|
})
|
||||||
.from(games)
|
.from(games)
|
||||||
.where(or(ne(games.syncStatus, "failed"), isNull(games.syncStatus)))
|
.where(or(ne(games.syncStatus, "failed"), isNull(games.syncStatus)))
|
||||||
.limit(remaining)
|
|
||||||
.offset(0)
|
|
||||||
|
|
||||||
const gameEntries: MetadataRoute.Sitemap = gameRows.map((row) => ({
|
const gameEntries: MetadataRoute.Sitemap = gameRows.map((row) => ({
|
||||||
url: `${baseUrl}/game/${row.id}`,
|
url: `${baseUrl}/game/${row.id}`,
|
||||||
lastModified: row.updatedAt ?? undefined,
|
lastModified: row.updatedAt ?? undefined,
|
||||||
changeFrequency: "weekly",
|
changeFrequency: "weekly" as const,
|
||||||
priority: 0.7,
|
priority: 0.7,
|
||||||
...(row.capsuleImage &&
|
...buildImageEntry(row.capsuleImage),
|
||||||
typeof row.capsuleImage === "string" &&
|
|
||||||
row.capsuleImage.trim().startsWith("https://") &&
|
|
||||||
row.capsuleImage.trim().length <= 2048
|
|
||||||
? { images: [row.capsuleImage.trim()] }
|
|
||||||
: {}),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const stillRemaining = remaining - gameRows.length
|
// Device pages
|
||||||
let deviceEntries: MetadataRoute.Sitemap = []
|
|
||||||
|
|
||||||
if (stillRemaining > 0) {
|
|
||||||
const deviceRows = await db
|
const deviceRows = await db
|
||||||
.select({ slug: hardware.slug, createdAt: hardware.createdAt })
|
.select({ slug: hardware.slug, createdAt: hardware.createdAt })
|
||||||
.from(hardware)
|
.from(hardware)
|
||||||
.limit(stillRemaining)
|
|
||||||
.offset(0)
|
|
||||||
|
|
||||||
deviceEntries = deviceRows.map((row) => ({
|
const deviceEntries: MetadataRoute.Sitemap = deviceRows.map((row) => ({
|
||||||
url: `${baseUrl}/devices/${row.slug}`,
|
url: `${baseUrl}/devices/${row.slug}`,
|
||||||
lastModified: row.createdAt ?? undefined,
|
lastModified: row.createdAt ?? undefined,
|
||||||
changeFrequency: "monthly",
|
changeFrequency: "monthly" as const,
|
||||||
priority: 0.5,
|
priority: 0.5,
|
||||||
}))
|
}))
|
||||||
}
|
|
||||||
|
|
||||||
return [...staticEntries, ...gameEntries, ...deviceEntries]
|
return [...staticEntries, ...gameEntries, ...deviceEntries]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subsequent chunks: games and devices only
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||||||
const gameCount = await db.$count(
|
/** Build a valid image sitemap entry from a capsule image URL */
|
||||||
games,
|
function buildImageEntry(
|
||||||
or(ne(games.syncStatus, "failed"), isNull(games.syncStatus)),
|
capsuleImage: unknown,
|
||||||
)
|
): { images: string[] } | Record<string, never> {
|
||||||
const dynamicOffset = offset - STATIC_ENTRIES.length
|
if (
|
||||||
|
typeof capsuleImage === "string" &&
|
||||||
let allEntries: MetadataRoute.Sitemap = []
|
capsuleImage.trim().startsWith("https://") &&
|
||||||
|
capsuleImage.trim().length <= 2048
|
||||||
if (dynamicOffset < gameCount) {
|
) {
|
||||||
const gameOffset = dynamicOffset
|
return { images: [capsuleImage.trim()] }
|
||||||
const gameLimit = Math.min(MAX_URLS_PER_SITEMAP, gameCount - gameOffset)
|
|
||||||
|
|
||||||
const gameRows = await db
|
|
||||||
.select({
|
|
||||||
id: games.id,
|
|
||||||
updatedAt: games.updatedAt,
|
|
||||||
capsuleImage: games.capsuleImage,
|
|
||||||
})
|
|
||||||
.from(games)
|
|
||||||
.where(or(ne(games.syncStatus, "failed"), isNull(games.syncStatus)))
|
|
||||||
.limit(gameLimit)
|
|
||||||
.offset(gameOffset)
|
|
||||||
|
|
||||||
allEntries = gameRows.map((row) => ({
|
|
||||||
url: `${baseUrl}/game/${row.id}`,
|
|
||||||
lastModified: row.updatedAt ?? undefined,
|
|
||||||
changeFrequency: "weekly",
|
|
||||||
priority: 0.7,
|
|
||||||
...(row.capsuleImage &&
|
|
||||||
typeof row.capsuleImage === "string" &&
|
|
||||||
row.capsuleImage.trim().startsWith("https://") &&
|
|
||||||
row.capsuleImage.trim().length <= 2048
|
|
||||||
? { images: [row.capsuleImage.trim()] }
|
|
||||||
: {}),
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
return {}
|
||||||
const remainingInChunk = MAX_URLS_PER_SITEMAP - allEntries.length
|
|
||||||
if (remainingInChunk > 0) {
|
|
||||||
const deviceOffset = Math.max(0, dynamicOffset - gameCount)
|
|
||||||
|
|
||||||
const deviceRows = await db
|
|
||||||
.select({ slug: hardware.slug, createdAt: hardware.createdAt })
|
|
||||||
.from(hardware)
|
|
||||||
.limit(remainingInChunk)
|
|
||||||
.offset(deviceOffset)
|
|
||||||
|
|
||||||
const deviceEntries = deviceRows.map((row) => ({
|
|
||||||
url: `${baseUrl}/devices/${row.slug}`,
|
|
||||||
lastModified: row.createdAt ?? undefined,
|
|
||||||
changeFrequency: "monthly",
|
|
||||||
priority: 0.5,
|
|
||||||
}))
|
|
||||||
|
|
||||||
allEntries = [...allEntries, ...deviceEntries]
|
|
||||||
}
|
|
||||||
|
|
||||||
return allEntries
|
|
||||||
}
|
}
|
||||||
@@ -64,9 +64,9 @@ const serwist = new Serwist({
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
// Navigation fallback: offline.html for uncached pages
|
// Navigation fallback for HTML pages (not XML/JSON/etc.)
|
||||||
{
|
{
|
||||||
matcher: ({ request }) => request.mode === "navigate",
|
matcher: ({ request }) => request.mode === "navigate" && !request.url.endsWith(".xml"),
|
||||||
handler: new NetworkFirst({
|
handler: new NetworkFirst({
|
||||||
cacheName: "navigation",
|
cacheName: "navigation",
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|||||||
@@ -37,9 +37,10 @@ function buildOption(games: BestRelease[]): EChartsOption {
|
|||||||
backgroundColor: "#1a1225",
|
backgroundColor: "#1a1225",
|
||||||
borderColor: CHART_THEME.border,
|
borderColor: CHART_THEME.border,
|
||||||
textStyle: { color: CHART_THEME.text },
|
textStyle: { color: CHART_THEME.text },
|
||||||
formatter: (params: any) => {
|
formatter: (params: unknown) => {
|
||||||
const v = params.value
|
const p = params as { name: string; value: number[] }
|
||||||
return `<div style="font-weight:600;margin-bottom:4px">${params.name}</div>
|
const v = p.value
|
||||||
|
return `<div style="font-weight:600;margin-bottom:4px">${p.name}</div>
|
||||||
<div>Avg FPS: <b>${v[1]}</b></div>
|
<div>Avg FPS: <b>${v[1]}</b></div>
|
||||||
<div>Benchmarks: <b>${v[2]}</b></div>`
|
<div>Benchmarks: <b>${v[2]}</b></div>`
|
||||||
},
|
},
|
||||||
@@ -78,7 +79,7 @@ function buildOption(games: BestRelease[]): EChartsOption {
|
|||||||
symbolSize: (val: number[]) => Math.max(12, Math.min(40, val[2] * 3)),
|
symbolSize: (val: number[]) => Math.max(12, Math.min(40, val[2] * 3)),
|
||||||
label: {
|
label: {
|
||||||
show: true,
|
show: true,
|
||||||
formatter: (p: any) => p.name,
|
formatter: (p: unknown) => (p as { name: string }).name,
|
||||||
position: "top",
|
position: "top",
|
||||||
color: CHART_THEME.text,
|
color: CHART_THEME.text,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ function buildOption(games: TrendingGame[]): EChartsOption {
|
|||||||
const titles = sorted.map((g) => g.title)
|
const titles = sorted.map((g) => g.title)
|
||||||
const scores = sorted.map((g) => g.activity_score)
|
const scores = sorted.map((g) => g.activity_score)
|
||||||
const benchmarks = sorted.map((g) => g.benchmark_count)
|
const benchmarks = sorted.map((g) => g.benchmark_count)
|
||||||
const comments = sorted.map((g) => g.comment_count)
|
|
||||||
const upvotes = sorted.map((g) => g.upvote_count)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
backgroundColor: CHART_THEME.bg,
|
backgroundColor: CHART_THEME.bg,
|
||||||
@@ -28,8 +26,9 @@ function buildOption(games: TrendingGame[]): EChartsOption {
|
|||||||
backgroundColor: "#1a1225",
|
backgroundColor: "#1a1225",
|
||||||
borderColor: CHART_THEME.border,
|
borderColor: CHART_THEME.border,
|
||||||
textStyle: { color: CHART_THEME.text },
|
textStyle: { color: CHART_THEME.text },
|
||||||
formatter: (params: any) => {
|
formatter: (params: unknown) => {
|
||||||
const idx = params[0].dataIndex
|
const p = params as { dataIndex: number }[]
|
||||||
|
const idx = p[0].dataIndex
|
||||||
const g = sorted[idx]
|
const g = sorted[idx]
|
||||||
return `<div style="font-weight:600;margin-bottom:4px">${g.title}</div>
|
return `<div style="font-weight:600;margin-bottom:4px">${g.title}</div>
|
||||||
<div>Activity Score: <b>${g.activity_score}</b></div>
|
<div>Activity Score: <b>${g.activity_score}</b></div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Shield, Crown, CheckCircle, Mail, User } from "lucide-react"
|
import { Shield, Crown, CheckCircle, Mail } from "lucide-react"
|
||||||
import { motion } from "motion/react"
|
import { motion } from "motion/react"
|
||||||
|
|
||||||
interface ProfileHeaderProps {
|
interface ProfileHeaderProps {
|
||||||
@@ -44,6 +44,7 @@ export function ProfileHeader({ name, email, role, verified, createdAt, image }:
|
|||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{image ? (
|
{image ? (
|
||||||
<div className={`shrink-0 w-16 h-16 rounded-full overflow-hidden ${isR2Avatar(image) ? "ring-2 ring-primary/30 ring-offset-2 ring-offset-background" : ""}`}>
|
<div className={`shrink-0 w-16 h-16 rounded-full overflow-hidden ${isR2Avatar(image) ? "ring-2 ring-primary/30 ring-offset-2 ring-offset-background" : ""}`}>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src={image} alt={`${name}'s profile photo`} className="w-full h-full object-cover" />
|
<img src={image} alt={`${name}'s profile photo`} className="w-full h-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function getInitials(name: string): string {
|
|||||||
return name.charAt(0).toUpperCase()
|
return name.charAt(0).toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfilePhotoUpload({ currentImage, userName, userId, onImageChange }: ProfilePhotoUploadProps) {
|
export function ProfilePhotoUpload({ currentImage, userName, onImageChange }: ProfilePhotoUploadProps) {
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(currentImage)
|
const [previewUrl, setPreviewUrl] = useState<string | null>(currentImage)
|
||||||
const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success" | "error">("idle")
|
const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success" | "error">("idle")
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
@@ -42,7 +42,9 @@ export function ProfilePhotoUpload({ currentImage, userName, userId, onImageChan
|
|||||||
return cleanupTempUrl
|
return cleanupTempUrl
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Sync previewUrl when parent updates currentImage externally
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setPreviewUrl(currentImage)
|
setPreviewUrl(currentImage)
|
||||||
}, [currentImage])
|
}, [currentImage])
|
||||||
|
|
||||||
@@ -56,7 +58,7 @@ export function ProfilePhotoUpload({ currentImage, userName, userId, onImageChan
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFile = async (file: File) => {
|
const handleFile = useCallback(async (file: File) => {
|
||||||
const validationError = validateFile(file)
|
const validationError = validateFile(file)
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
setErrorMessage(validationError)
|
setErrorMessage(validationError)
|
||||||
@@ -99,7 +101,7 @@ export function ProfilePhotoUpload({ currentImage, userName, userId, onImageChan
|
|||||||
setPreviewUrl(currentImage)
|
setPreviewUrl(currentImage)
|
||||||
cleanupTempUrl()
|
cleanupTempUrl()
|
||||||
}
|
}
|
||||||
}
|
}, [currentImage, onImageChange])
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
@@ -112,7 +114,7 @@ export function ProfilePhotoUpload({ currentImage, userName, userId, onImageChan
|
|||||||
setIsDragging(false)
|
setIsDragging(false)
|
||||||
const file = e.dataTransfer.files?.[0]
|
const file = e.dataTransfer.files?.[0]
|
||||||
if (file) handleFile(file)
|
if (file) handleFile(file)
|
||||||
}, [currentImage])
|
}, [handleFile])
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -179,6 +181,7 @@ export function ProfilePhotoUpload({ currentImage, userName, userId, onImageChan
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{previewUrl ? (
|
{previewUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={previewUrl}
|
src={previewUrl}
|
||||||
alt={`${userName}'s profile photo`}
|
alt={`${userName}'s profile photo`}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const eslintConfig = defineConfig([
|
|||||||
"out/**",
|
"out/**",
|
||||||
"build/**",
|
"build/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
|
// Generated service worker
|
||||||
|
"public/sw.js",
|
||||||
// Worktree build artifacts
|
// Worktree build artifacts
|
||||||
".worktrees/**",
|
".worktrees/**",
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import { gamesListingRoutes } from "@/lib/api/games-listing"
|
|||||||
import { steamgridProxyRoutes } from "@/lib/api/steamgrid-proxy"
|
import { steamgridProxyRoutes } from "@/lib/api/steamgrid-proxy"
|
||||||
import { dashboardRoutes } from "@/lib/api/dashboard"
|
import { dashboardRoutes } from "@/lib/api/dashboard"
|
||||||
import { dashboardPublicRoutes } from "@/lib/api/dashboard-public"
|
import { dashboardPublicRoutes } from "@/lib/api/dashboard-public"
|
||||||
import { sitemapRegenerateRoutes } from "@/lib/api/sitemap-regenerate"
|
|
||||||
import { savedFilterRoutes } from "@/lib/api/saved-filters"
|
import { savedFilterRoutes } from "@/lib/api/saved-filters"
|
||||||
import { cronRoutes } from "@/lib/api/cron"
|
import { cronRoutes } from "@/lib/api/cron"
|
||||||
import { profilePhotoRoutes } from "@/lib/api/profile-photo"
|
import { profilePhotoRoutes } from "@/lib/api/profile-photo"
|
||||||
@@ -125,7 +124,6 @@ export const app = new Elysia({ prefix: "/api" })
|
|||||||
// Dashboard
|
// Dashboard
|
||||||
.use(dashboardRoutes)
|
.use(dashboardRoutes)
|
||||||
.use(dashboardPublicRoutes)
|
.use(dashboardPublicRoutes)
|
||||||
.use(sitemapRegenerateRoutes)
|
|
||||||
// Root
|
// Root
|
||||||
.get("/", () => ({
|
.get("/", () => ({
|
||||||
name: "DeckyVault API",
|
name: "DeckyVault API",
|
||||||
|
|||||||
+1
-16
@@ -1,7 +1,7 @@
|
|||||||
import { Elysia, t } from "elysia"
|
import { Elysia, t } from "elysia"
|
||||||
import { db } from "@/lib/db/index"
|
import { db } from "@/lib/db/index"
|
||||||
import { storageObjects } from "@/lib/db/schema"
|
import { storageObjects } from "@/lib/db/schema"
|
||||||
import { eq, and, isNull, sql } from "drizzle-orm"
|
import { eq, sql } from "drizzle-orm"
|
||||||
import { deleteObject, isR2Configured } from "@/lib/storage"
|
import { deleteObject, isR2Configured } from "@/lib/storage"
|
||||||
|
|
||||||
// ── Task Result Type ────────────────────────────────────────────────
|
// ── Task Result Type ────────────────────────────────────────────────
|
||||||
@@ -110,21 +110,6 @@ registerCronTask("orphan_detection", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Sitemap Regeneration Task ────────────────────────────────────────
|
|
||||||
registerCronTask("sitemap_regeneration", async () => {
|
|
||||||
const start = Date.now()
|
|
||||||
const details: Record<string, unknown> = {}
|
|
||||||
try {
|
|
||||||
const { generateSitemaps } = await import("@/lib/sitemap/generate-static")
|
|
||||||
await generateSitemaps()
|
|
||||||
details.regenerated = true
|
|
||||||
return { name: "sitemap_regeneration", status: "success" as const, durationMs: Date.now() - start, details }
|
|
||||||
} catch (err) {
|
|
||||||
details.error = err instanceof Error ? err.message : String(err)
|
|
||||||
return { name: "sitemap_regeneration", status: "error" as const, durationMs: Date.now() - start, details }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Cron Route ──────────────────────────────────────────────────────
|
// ── Cron Route ──────────────────────────────────────────────────────
|
||||||
export const cronRoutes = new Elysia({ prefix: "/cron" }).post(
|
export const cronRoutes = new Elysia({ prefix: "/cron" }).post(
|
||||||
"/daily",
|
"/daily",
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
import { Elysia, t } from "elysia"
|
import { Elysia } from "elysia"
|
||||||
import { db } from "@/lib/db/index"
|
import { db } from "@/lib/db/index"
|
||||||
import {
|
import { sql } from "drizzle-orm"
|
||||||
games,
|
|
||||||
gameVersions,
|
|
||||||
performanceEntries,
|
|
||||||
gameComments,
|
|
||||||
reports,
|
|
||||||
} from "@/lib/db/schema"
|
|
||||||
import { eq, and, gte, sql, desc, count } from "drizzle-orm"
|
|
||||||
|
|
||||||
const SEVEN_DAYS_AGO = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
const SEVEN_DAYS_AGO = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||||
const NINETY_DAYS_AGO = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000)
|
|
||||||
|
|
||||||
export const dashboardPublicRoutes = new Elysia({ prefix: "/dashboard" })
|
export const dashboardPublicRoutes = new Elysia({ prefix: "/dashboard" })
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,5 @@ export { communitySuggestionRoutes } from "./community-suggestions"
|
|||||||
export { savedFilterRoutes } from "./saved-filters"
|
export { savedFilterRoutes } from "./saved-filters"
|
||||||
export { dashboardRoutes } from "./dashboard"
|
export { dashboardRoutes } from "./dashboard"
|
||||||
export { dashboardPublicRoutes } from "./dashboard-public"
|
export { dashboardPublicRoutes } from "./dashboard-public"
|
||||||
export { sitemapRegenerateRoutes } from "./sitemap-regenerate"
|
|
||||||
export { cronRoutes } from "./cron"
|
export { cronRoutes } from "./cron"
|
||||||
export { profilePhotoRoutes } from "./profile-photo"
|
export { profilePhotoRoutes } from "./profile-photo"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Elysia, t } from "elysia"
|
import { Elysia } from "elysia"
|
||||||
import { db } from "@/lib/db/index"
|
import { db } from "@/lib/db/index"
|
||||||
import { user } from "@/lib/db/schema"
|
import { user } from "@/lib/db/schema"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
@@ -14,7 +14,6 @@ import { storageObjects } from "@/lib/db/schema"
|
|||||||
|
|
||||||
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"]
|
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"]
|
||||||
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||||
const AVATAR_SIZE = 256
|
|
||||||
|
|
||||||
// Magic byte signatures for file type validation
|
// Magic byte signatures for file type validation
|
||||||
const MAGIC_BYTES: Record<string, number[]> = {
|
const MAGIC_BYTES: Record<string, number[]> = {
|
||||||
@@ -78,10 +77,6 @@ export const profilePhotoRoutes = new Elysia({ prefix: "/user" })
|
|||||||
|
|
||||||
// Generate unique key
|
// Generate unique key
|
||||||
const timestamp = Date.now()
|
const timestamp = Date.now()
|
||||||
const key = `avatars/${guard.user.id}-${timestamp}.webp`
|
|
||||||
|
|
||||||
// Upload to R2 (store original; client-side resize handled later or store as-is)
|
|
||||||
// For MVP: store the original file as-is with its original MIME type
|
|
||||||
const ext = file.type === "image/png" ? "png" : file.type === "image/webp" ? "webp" : "jpg"
|
const ext = file.type === "image/png" ? "png" : file.type === "image/webp" ? "webp" : "jpg"
|
||||||
const actualKey = `avatars/${guard.user.id}-${timestamp}.${ext}`
|
const actualKey = `avatars/${guard.user.id}-${timestamp}.${ext}`
|
||||||
const publicUrl = await uploadObject(actualKey, buffer, file.type, {
|
const publicUrl = await uploadObject(actualKey, buffer, file.type, {
|
||||||
|
|||||||
@@ -135,9 +135,6 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
|
|||||||
// ── Filter local games to only those that passed all filters so far ──
|
// ── Filter local games to only those that passed all filters so far ──
|
||||||
const filteredLocalGames = localGames.filter((g) => filteredGameIds.has(g.id))
|
const filteredLocalGames = localGames.filter((g) => filteredGameIds.has(g.id))
|
||||||
const filteredIds = filteredLocalGames.map((g) => g.id)
|
const filteredIds = filteredLocalGames.map((g) => g.id)
|
||||||
const localSteamAppIds = new Set(
|
|
||||||
filteredLocalGames.map((g) => g.steamAppId).filter(Boolean),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Fetch platform support + anti-cheat for local games
|
// Fetch platform support + anti-cheat for local games
|
||||||
const platformSupportMap = new Map<
|
const platformSupportMap = new Map<
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { Elysia } from "elysia"
|
|
||||||
import { requireAdmin } from "@/lib/auth/guard"
|
|
||||||
import { generateSitemaps } from "@/lib/sitemap/generate-static"
|
|
||||||
|
|
||||||
export const sitemapRegenerateRoutes = new Elysia({ prefix: "/sitemap" })
|
|
||||||
|
|
||||||
.post(
|
|
||||||
"/regenerate",
|
|
||||||
async ({ request, set }) => {
|
|
||||||
const guard = await requireAdmin(request.headers)
|
|
||||||
if (!guard.ok) {
|
|
||||||
set.status = guard.status
|
|
||||||
return { error: guard.error }
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await generateSitemaps()
|
|
||||||
return { success: true, message: "Sitemap regenerated successfully" }
|
|
||||||
} catch (err) {
|
|
||||||
set.status = 500
|
|
||||||
return { error: "Failed to regenerate sitemap", details: err instanceof Error ? err.message : String(err) }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from "vitest"
|
|
||||||
|
|
||||||
// Helper to build a chainable mock that returns []
|
|
||||||
function chainableMock() {
|
|
||||||
const mock = {
|
|
||||||
where: vi.fn().mockReturnThis(),
|
|
||||||
limit: vi.fn().mockReturnThis(),
|
|
||||||
offset: vi.fn().mockResolvedValue([]),
|
|
||||||
}
|
|
||||||
return mock
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock database to prevent real queries
|
|
||||||
vi.mock("@/lib/db/index", () => ({
|
|
||||||
db: {
|
|
||||||
select: vi.fn(() => ({
|
|
||||||
from: vi.fn(() => chainableMock()),
|
|
||||||
})),
|
|
||||||
$count: vi.fn().mockResolvedValue(0),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
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("generateSitemaps returns a single sitemap id when no data", async () => {
|
|
||||||
const mod = await import("@/app/sitemap")
|
|
||||||
const sitemaps = await mod.generateSitemaps()
|
|
||||||
expect(sitemaps).toEqual([{ id: "0" }])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("default export returns static pages in first chunk", async () => {
|
|
||||||
const mod = await import("@/app/sitemap")
|
|
||||||
const result = await mod.default({ id: "0" })
|
|
||||||
expect(Array.isArray(result)).toBe(true)
|
|
||||||
// Static pages: /, /games, /dashboard, /devices, /updates, /contact
|
|
||||||
expect(result.length).toBeGreaterThanOrEqual(6)
|
|
||||||
expect(result[0].url).toContain("deckyvault.xyz")
|
|
||||||
expect(result[0].priority).toBe(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
import fs from "fs"
|
|
||||||
import path from "path"
|
|
||||||
import { db } from "@/lib/db/index"
|
|
||||||
import { games, hardware } from "@/lib/db/schema"
|
|
||||||
import { or, ne, isNull } from "drizzle-orm"
|
|
||||||
|
|
||||||
const PRODUCTION_URL = "https://deckyvault.xyz"
|
|
||||||
|
|
||||||
function getBaseUrl(): string {
|
|
||||||
const envUrl = process.env.NEXT_PUBLIC_SITE_URL
|
|
||||||
// Never use localhost for sitemaps — they're for production search engines
|
|
||||||
if (envUrl && !envUrl.includes("localhost") && !envUrl.includes("127.0.0.1")) {
|
|
||||||
return envUrl.replace(/\/$/, "")
|
|
||||||
}
|
|
||||||
return PRODUCTION_URL
|
|
||||||
}
|
|
||||||
|
|
||||||
const BASE_URL = getBaseUrl()
|
|
||||||
const MAX_URLS_PER_SITEMAP = 45000 // Leave buffer below 50k limit
|
|
||||||
|
|
||||||
interface SitemapEntry {
|
|
||||||
loc: string
|
|
||||||
lastmod?: string
|
|
||||||
changefreq?: string
|
|
||||||
priority?: number
|
|
||||||
"image:image"?: {
|
|
||||||
"image:loc": string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeXml(str: string): string {
|
|
||||||
return str
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'")
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStaticEntries(): SitemapEntry[] {
|
|
||||||
return [
|
|
||||||
{ loc: BASE_URL, changefreq: "weekly", priority: 1.0 },
|
|
||||||
{ loc: `${BASE_URL}/games`, changefreq: "daily", priority: 0.8 },
|
|
||||||
{ loc: `${BASE_URL}/dashboard`, changefreq: "daily", priority: 0.7 },
|
|
||||||
{ loc: `${BASE_URL}/devices`, changefreq: "monthly", priority: 0.6 },
|
|
||||||
{ loc: `${BASE_URL}/updates`, changefreq: "weekly", priority: 0.5 },
|
|
||||||
{ loc: `${BASE_URL}/contact`, changefreq: "yearly", priority: 0.3 },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSitemap(entries: SitemapEntry[]): string {
|
|
||||||
const urls = entries.map((entry) => {
|
|
||||||
let xml = ` <url>\n <loc>${escapeXml(entry.loc)}</loc>\n`
|
|
||||||
if (entry.lastmod) xml += ` <lastmod>${entry.lastmod}</lastmod>\n`
|
|
||||||
if (entry.changefreq) xml += ` <changefreq>${entry.changefreq}</changefreq>\n`
|
|
||||||
if (entry.priority) xml += ` <priority>${entry.priority}</priority>\n`
|
|
||||||
if (entry["image:image"]) {
|
|
||||||
xml += ` <image:image>\n <image:loc>${escapeXml(entry["image:image"]["image:loc"])}</image:loc>\n </image:image>\n`
|
|
||||||
}
|
|
||||||
xml += ` </url>`
|
|
||||||
return xml
|
|
||||||
})
|
|
||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">\n${urls.join("\n")}\n</urlset>`
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSitemapIndex(sitemaps: { loc: string; lastmod: string }[]): string {
|
|
||||||
const entries = sitemaps.map((s) => {
|
|
||||||
return ` <sitemap>\n <loc>${escapeXml(s.loc)}</loc>\n <lastmod>${s.lastmod}</lastmod>\n </sitemap>`
|
|
||||||
})
|
|
||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.join("\n")}\n</sitemapindex>`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateSitemaps() {
|
|
||||||
console.log("Generating static sitemaps...")
|
|
||||||
|
|
||||||
// Fetch games
|
|
||||||
const gameRows = await db
|
|
||||||
.select({
|
|
||||||
id: games.id,
|
|
||||||
updatedAt: games.updatedAt,
|
|
||||||
capsuleImage: games.capsuleImage,
|
|
||||||
})
|
|
||||||
.from(games)
|
|
||||||
.where(or(ne(games.syncStatus, "failed"), isNull(games.syncStatus)))
|
|
||||||
|
|
||||||
// Fetch devices
|
|
||||||
const deviceRows = await db
|
|
||||||
.select({
|
|
||||||
slug: hardware.slug,
|
|
||||||
createdAt: hardware.createdAt,
|
|
||||||
})
|
|
||||||
.from(hardware)
|
|
||||||
|
|
||||||
console.log(`Found ${gameRows.length} games and ${deviceRows.length} devices`)
|
|
||||||
|
|
||||||
// Build game entries
|
|
||||||
const gameEntries: SitemapEntry[] = 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 {
|
|
||||||
loc: `${BASE_URL}/game/${row.id}`,
|
|
||||||
lastmod: row.updatedAt ? new Date(row.updatedAt).toISOString() : undefined,
|
|
||||||
changefreq: "weekly",
|
|
||||||
priority: 0.7,
|
|
||||||
...(image ? { "image:image": { "image:loc": image } } : {}),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Build device entries
|
|
||||||
const deviceEntries: SitemapEntry[] = deviceRows.map((row) => ({
|
|
||||||
loc: `${BASE_URL}/devices/${row.slug}`,
|
|
||||||
lastmod: row.createdAt ? new Date(row.createdAt).toISOString() : undefined,
|
|
||||||
changefreq: "monthly",
|
|
||||||
priority: 0.5,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Static entries
|
|
||||||
const staticEntries = buildStaticEntries()
|
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
const publicDir = path.join(process.cwd(), "public")
|
|
||||||
|
|
||||||
// Ensure public dir exists
|
|
||||||
if (!fs.existsSync(publicDir)) {
|
|
||||||
fs.mkdirSync(publicDir, { recursive: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decide: single file or sitemap index
|
|
||||||
const allDynamicEntries = [...gameEntries, ...deviceEntries]
|
|
||||||
const needsIndex = (staticEntries.length + allDynamicEntries.length) > MAX_URLS_PER_SITEMAP
|
|
||||||
|
|
||||||
if (needsIndex) {
|
|
||||||
// Write static sitemap
|
|
||||||
const staticXml = renderSitemap(staticEntries)
|
|
||||||
fs.writeFileSync(path.join(publicDir, "sitemap-static.xml"), staticXml)
|
|
||||||
|
|
||||||
// Write device sitemap
|
|
||||||
const deviceXml = renderSitemap(deviceEntries)
|
|
||||||
fs.writeFileSync(path.join(publicDir, "sitemap-devices.xml"), deviceXml)
|
|
||||||
|
|
||||||
// Split game entries into chunks
|
|
||||||
const chunks: SitemapEntry[][] = []
|
|
||||||
for (let i = 0; i < gameEntries.length; i += MAX_URLS_PER_SITEMAP) {
|
|
||||||
chunks.push(gameEntries.slice(i, i + MAX_URLS_PER_SITEMAP))
|
|
||||||
}
|
|
||||||
|
|
||||||
const sitemaps: { loc: string; lastmod: string }[] = [
|
|
||||||
{ loc: `${BASE_URL}/sitemap-static.xml`, lastmod: now },
|
|
||||||
{ loc: `${BASE_URL}/sitemap-devices.xml`, lastmod: now },
|
|
||||||
]
|
|
||||||
|
|
||||||
chunks.forEach((chunk, i) => {
|
|
||||||
const filename = `sitemap-games-${i}.xml`
|
|
||||||
const xml = renderSitemap(chunk)
|
|
||||||
fs.writeFileSync(path.join(publicDir, filename), xml)
|
|
||||||
sitemaps.push({ loc: `${BASE_URL}/${filename}`, lastmod: now })
|
|
||||||
})
|
|
||||||
|
|
||||||
// Write sitemap index
|
|
||||||
const indexXml = renderSitemapIndex(sitemaps)
|
|
||||||
fs.writeFileSync(path.join(publicDir, "sitemap.xml"), indexXml)
|
|
||||||
} else {
|
|
||||||
// Single sitemap
|
|
||||||
const allEntries = [...staticEntries, ...gameEntries, ...deviceEntries]
|
|
||||||
const xml = renderSitemap(allEntries)
|
|
||||||
fs.writeFileSync(path.join(publicDir, "sitemap.xml"), xml)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Sitemaps generated successfully! Total URLs: ${staticEntries.length + allDynamicEntries.length}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run if called directly (CLI execution, not module import)
|
|
||||||
const isCliRun = typeof process !== "undefined" && process.argv?.[1]?.includes("generate-static")
|
|
||||||
if (isCliRun) {
|
|
||||||
generateSitemaps().catch((err) => {
|
|
||||||
console.error("Failed to generate sitemaps:", err)
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export { generateSitemaps }
|
|
||||||
@@ -2,19 +2,19 @@ import { describe, it, expect, vi, beforeEach } from "vitest"
|
|||||||
|
|
||||||
// ── Mock global fetch so syncSteamGame doesn't hit real APIs ──
|
// ── Mock global fetch so syncSteamGame doesn't hit real APIs ──
|
||||||
const mockFetch = vi.fn()
|
const mockFetch = vi.fn()
|
||||||
Object.assign(globalThis, { fetch: mockFetch as any })
|
Object.assign(globalThis, { fetch: mockFetch as unknown as typeof fetch })
|
||||||
|
|
||||||
// ── Mock db with a queue of select results ──
|
// ── Mock db with a queue of select results ──
|
||||||
function createDbMock() {
|
function createDbMock() {
|
||||||
const state = {
|
const state = {
|
||||||
selectQueue: [] as any[][],
|
selectQueue: [] as unknown[][],
|
||||||
insertResults: [] as any[],
|
insertResults: [] as unknown[],
|
||||||
selectIdx: 0,
|
selectIdx: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
setSelectQueue: (q: any[][]) => { state.selectQueue = q; state.selectIdx = 0 },
|
setSelectQueue: (q: unknown[][]) => { state.selectQueue = q; state.selectIdx = 0 },
|
||||||
setInsertResults: (r: any[]) => { state.insertResults = r },
|
setInsertResults: (r: unknown[]) => { state.insertResults = r },
|
||||||
|
|
||||||
select: vi.fn().mockImplementation(() => ({
|
select: vi.fn().mockImplementation(() => ({
|
||||||
from: vi.fn().mockImplementation(() => ({
|
from: vi.fn().mockImplementation(() => ({
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME ?? "deckyvault"
|
|||||||
const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL ?? ""
|
const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL ?? ""
|
||||||
|
|
||||||
let _client: S3Client | null = null
|
let _client: S3Client | null = null
|
||||||
let _configured = false
|
|
||||||
|
|
||||||
function getR2ConfigStatus(): { configured: boolean; reason?: string } {
|
function getR2ConfigStatus(): { configured: boolean; reason?: string } {
|
||||||
if (!R2_ACCOUNT_ID || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY) {
|
if (!R2_ACCOUNT_ID || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY) {
|
||||||
@@ -37,7 +36,6 @@ function getClient(): S3Client {
|
|||||||
secretAccessKey: R2_SECRET_ACCESS_KEY!,
|
secretAccessKey: R2_SECRET_ACCESS_KEY!,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
_configured = true
|
|
||||||
return _client
|
return _client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:seed": "bun run lib/db/seed.ts",
|
"db:seed": "bun run lib/db/seed.ts",
|
||||||
"build:sitemap": "tsx lib/sitemap/generate-static.ts",
|
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user