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:
@@ -33,7 +33,6 @@ import { gamesListingRoutes } from "@/lib/api/games-listing"
|
||||
import { steamgridProxyRoutes } from "@/lib/api/steamgrid-proxy"
|
||||
import { dashboardRoutes } from "@/lib/api/dashboard"
|
||||
import { dashboardPublicRoutes } from "@/lib/api/dashboard-public"
|
||||
import { sitemapRegenerateRoutes } from "@/lib/api/sitemap-regenerate"
|
||||
import { savedFilterRoutes } from "@/lib/api/saved-filters"
|
||||
import { cronRoutes } from "@/lib/api/cron"
|
||||
import { profilePhotoRoutes } from "@/lib/api/profile-photo"
|
||||
@@ -125,7 +124,6 @@ export const app = new Elysia({ prefix: "/api" })
|
||||
// Dashboard
|
||||
.use(dashboardRoutes)
|
||||
.use(dashboardPublicRoutes)
|
||||
.use(sitemapRegenerateRoutes)
|
||||
// Root
|
||||
.get("/", () => ({
|
||||
name: "DeckyVault API",
|
||||
|
||||
+1
-16
@@ -1,7 +1,7 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
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"
|
||||
|
||||
// ── 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 ──────────────────────────────────────────────────────
|
||||
export const cronRoutes = new Elysia({ prefix: "/cron" }).post(
|
||||
"/daily",
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { Elysia } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
games,
|
||||
gameVersions,
|
||||
performanceEntries,
|
||||
gameComments,
|
||||
reports,
|
||||
} from "@/lib/db/schema"
|
||||
import { eq, and, gte, sql, desc, count } from "drizzle-orm"
|
||||
import { sql } from "drizzle-orm"
|
||||
|
||||
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" })
|
||||
|
||||
|
||||
@@ -23,6 +23,5 @@ export { communitySuggestionRoutes } from "./community-suggestions"
|
||||
export { savedFilterRoutes } from "./saved-filters"
|
||||
export { dashboardRoutes } from "./dashboard"
|
||||
export { dashboardPublicRoutes } from "./dashboard-public"
|
||||
export { sitemapRegenerateRoutes } from "./sitemap-regenerate"
|
||||
export { cronRoutes } from "./cron"
|
||||
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 { user } from "@/lib/db/schema"
|
||||
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 MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
const AVATAR_SIZE = 256
|
||||
|
||||
// Magic byte signatures for file type validation
|
||||
const MAGIC_BYTES: Record<string, number[]> = {
|
||||
@@ -78,10 +77,6 @@ export const profilePhotoRoutes = new Elysia({ prefix: "/user" })
|
||||
|
||||
// Generate unique key
|
||||
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 actualKey = `avatars/${guard.user.id}-${timestamp}.${ext}`
|
||||
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 ──
|
||||
const filteredLocalGames = localGames.filter((g) => filteredGameIds.has(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
|
||||
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 ──
|
||||
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 ──
|
||||
function createDbMock() {
|
||||
const state = {
|
||||
selectQueue: [] as any[][],
|
||||
insertResults: [] as any[],
|
||||
selectQueue: [] as unknown[][],
|
||||
insertResults: [] as unknown[],
|
||||
selectIdx: 0,
|
||||
}
|
||||
|
||||
return {
|
||||
setSelectQueue: (q: any[][]) => { state.selectQueue = q; state.selectIdx = 0 },
|
||||
setInsertResults: (r: any[]) => { state.insertResults = r },
|
||||
setSelectQueue: (q: unknown[][]) => { state.selectQueue = q; state.selectIdx = 0 },
|
||||
setInsertResults: (r: unknown[]) => { state.insertResults = r },
|
||||
|
||||
select: 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 ?? ""
|
||||
|
||||
let _client: S3Client | null = null
|
||||
let _configured = false
|
||||
|
||||
function getR2ConfigStatus(): { configured: boolean; reason?: string } {
|
||||
if (!R2_ACCOUNT_ID || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY) {
|
||||
@@ -37,7 +36,6 @@ function getClient(): S3Client {
|
||||
secretAccessKey: R2_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
})
|
||||
_configured = true
|
||||
return _client
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user