diff --git a/CHANGELOG.md b/CHANGELOG.md index 44f5e57..f8841d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to DeckyVault will be documented in this file. -## [Unreleased] - 2026-04-30 +## [2026.0.95] - 2026-04-30 ### Added diff --git a/app/(manage)/manage/games/games-client.tsx b/app/(manage)/manage/games/games-client.tsx index 1e05524..5155b72 100644 --- a/app/(manage)/manage/games/games-client.tsx +++ b/app/(manage)/manage/games/games-client.tsx @@ -326,7 +326,7 @@ export function GamesClient() { setLoading(true) try { const res = await fetch( - `/api/games?limit=${LIMIT}&offset=${offset}&search=${encodeURIComponent(search)}` + `/api/games?limit=${LIMIT}&offset=${offset}&search=${encodeURIComponent(search)}&sort=createdAt&order=desc` ) if (res.ok && !cancelled) { const json = (await res.json()) as GamesApiResponse diff --git a/app/game/[id]/game-page-client.tsx b/app/game/[id]/game-page-client.tsx index 95ffbfa..472a4d4 100644 --- a/app/game/[id]/game-page-client.tsx +++ b/app/game/[id]/game-page-client.tsx @@ -79,6 +79,7 @@ interface Game { playabilityStatus?: "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown" | null steamReviewScore?: number | null steamReviewSentiment?: string | null + steamReviewCount?: number | null } interface Counts { diff --git a/app/game/[id]/page.tsx b/app/game/[id]/page.tsx index c1d6158..03b5215 100644 --- a/app/game/[id]/page.tsx +++ b/app/game/[id]/page.tsx @@ -254,16 +254,25 @@ export default async function GamePage({ .orderBy(desc(performanceEntries.isPinned), desc(performanceEntries.upvotes)), ]) - // ── Sync logic: force or stale-while-revalidate ───────────────── + // ── Sync logic: force or stale-while-revalidate ──────────────── const shouldSync = game.source === "steam" && game.steamAppId && (forceSync || isSyncStale(game.lastSync)) if (shouldSync) { - after(async () => { + if (forceSync) { + // Block render on forced sync so user sees fresh data immediately await syncSteamGame(game.steamAppId!) - }) + // Re-fetch game after sync so serialized data is fresh + const refreshed = await resolveGame(game.steamAppId!.toString()) + if (refreshed) game = refreshed + } else { + // Stale sync happens after response so page isn't delayed + after(async () => { + await syncSteamGame(game.steamAppId!) + }) + } } // Serialize for client component (Dates → strings) @@ -293,6 +302,9 @@ export default async function GamePage({ releaseDate: game.releaseDate, categories: game.categories, platforms: game.platforms, + steamReviewScore: game.steamReviewScore, + steamReviewSentiment: game.steamReviewSentiment, + steamReviewCount: game.steamReviewCount, } const serializedPresets = presetRows.map((p) => ({ diff --git a/app/game/[id]/submit/page.tsx b/app/game/[id]/submit/page.tsx index 5b16f16..4dfa7ea 100644 --- a/app/game/[id]/submit/page.tsx +++ b/app/game/[id]/submit/page.tsx @@ -2,7 +2,7 @@ import { notFound } from "next/navigation" import { db } from "@/lib/db/index" import { games, gameVersions, performanceEntries, gamePlatformSupport } from "@/lib/db/schema" import { eq, sql } from "drizzle-orm" -import { GameEntryWizard } from "@/components/wizard/game-entry-wizard" +import { GameEntryWizard, type GameVersionInfo } from "@/components/wizard/game-entry-wizard" // This page needs live data — skip static generation at build time export const dynamic = "force-dynamic" @@ -47,37 +47,34 @@ export default async function SubmitBenchmarkPage({ notFound() } - // Get or create the latest game version - let [version] = await db - .select() + // Fetch all game versions for version selection + const allVersions = await db + .select({ + id: gameVersions.id, + versionString: gameVersions.versionString, + buildId: gameVersions.buildId, + isLatest: gameVersions.isLatest, + }) .from(gameVersions) - .where( - eq(gameVersions.gameId, game.id), - ) + .where(eq(gameVersions.gameId, game.id)) .orderBy(sql`${gameVersions.createdAt} DESC`) - .limit(1) // Create a default version if none exists - if (!version) { - [version] = await db + if (allVersions.length === 0) { + const [newVersion] = await db .insert(gameVersions) .values({ gameId: game.id, isLatest: true, }) .returning() - } - - // Fetch platform support for anti-cheat awareness - const platformSupport = await db - .select({ - hardwareSlug: gamePlatformSupport.hardwareSlug, - antiCheatRelevant: gamePlatformSupport.antiCheatRelevant, - antiCheatName: gamePlatformSupport.antiCheatName, - antiCheatStatus: gamePlatformSupport.antiCheatStatus, + allVersions.push({ + id: newVersion.id, + versionString: newVersion.versionString, + buildId: newVersion.buildId, + isLatest: newVersion.isLatest, }) - .from(gamePlatformSupport) - .where(eq(gamePlatformSupport.gameId, game.id)) + } // If editing, fetch the existing performance entry let editEntry = null @@ -90,6 +87,26 @@ export default async function SubmitBenchmarkPage({ editEntry = entry ?? null } + // Determine default version: when editing, use the entry's version; + // otherwise, use the latest (first in DESC order) + let defaultVersionId = allVersions[0].id + if (editEntry?.versionId) { + defaultVersionId = editEntry.versionId + } + + const gameVersionInfos: GameVersionInfo[] = allVersions + + // Fetch platform support for anti-cheat awareness + const platformSupport = await db + .select({ + hardwareSlug: gamePlatformSupport.hardwareSlug, + antiCheatRelevant: gamePlatformSupport.antiCheatRelevant, + antiCheatName: gamePlatformSupport.antiCheatName, + antiCheatStatus: gamePlatformSupport.antiCheatStatus, + }) + .from(gamePlatformSupport) + .where(eq(gamePlatformSupport.gameId, game.id)) + return (
@@ -104,7 +121,8 @@ export default async function SubmitBenchmarkPage({ diff --git a/app/games/games-page-client.tsx b/app/games/games-page-client.tsx index 611a142..8cd92e2 100644 --- a/app/games/games-page-client.tsx +++ b/app/games/games-page-client.tsx @@ -677,7 +677,7 @@ function GameCard({ game }: { game: GamesListItem }) {
{game.playabilityStatus && ( - + )} {game.antiCheatRelevant && game.antiCheatStatus === "unsupported" && ( g.source === "steam" && g.steamAppId && isSyncStale(g.lastSync)) + .map((g) => g.steamAppId!) + + if (staleSteamAppIds.length > 0) { + after(async () => { + // Sync stale games sequentially with a small delay to avoid rate-limiting + for (const appId of staleSteamAppIds) { + try { + await syncSteamGame(appId) + } catch { + // Stale sync failure is non-fatal — data will be refreshed on next visit + } + await new Promise((r) => setTimeout(r, 1500)) + } + }) + } + // JSON-LD ItemList const jsonLd = { "@context": "https://schema.org", diff --git a/app/search/page.tsx b/app/search/page.tsx index c0ffec6..681cb9d 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -245,7 +245,7 @@ function SearchResultCard({ transition={{ duration: 0.3, delay: index * 0.02 }} whileHover={{ scale: 1.005 }} whileTap={{ scale: 0.995 }} - className="group relative bg-text/[0.03] border border-border rounded-xl p-4 sm:p-5 transition-colors duration-200 hover:border-text/30 hover:bg-text/[0.06] cursor-pointer focus-within:outline-none focus-within:ring-2 focus-within:ring-text/20 focus-within:ring-offset-2 focus-within:ring-offset-background" + className="group relative bg-text/3 border border-border rounded-xl p-4 sm:p-5 transition-colors duration-200 hover:border-text/30 hover:bg-text/[0.06] cursor-pointer focus-within:outline-none focus-within:ring-2 focus-within:ring-text/20 focus-within:ring-offset-2 focus-within:ring-offset-background" title={isLocal ? "Click to view game details, benchmarks, and settings" : "Click to add this game to DeckyVault and view its page" diff --git a/components/comments/comment-section.tsx b/components/comments/comment-section.tsx index 33c728e..29a7478 100644 --- a/components/comments/comment-section.tsx +++ b/components/comments/comment-section.tsx @@ -21,6 +21,7 @@ interface CommentsApiResponse { export function CommentSection({ gameId, initialCount }: CommentSectionProps) { const { data: session } = useSession() + const [mounted, setMounted] = useState(false) const [comments, setComments] = useState([]) const [total, setTotal] = useState(initialCount) const [offset, setOffset] = useState(0) @@ -29,6 +30,10 @@ export function CommentSection({ gameId, initialCount }: CommentSectionProps) { const [submitting, setSubmitting] = useState(false) const limit = 20 + useEffect(() => { + setMounted(true) + }, []) + // Initial load useEffect(() => { let cancelled = false @@ -125,8 +130,8 @@ export function CommentSection({ gameId, initialCount }: CommentSectionProps) { ({total})
- {/* Compose */} - {session ? ( + {/* Compose — suppress until mounted to avoid hydration mismatch */} + {mounted && session ? (
- ) : ( + ) : mounted ? (

+ ) : ( +
)} {/* Comment list */} diff --git a/components/playability-badge.tsx b/components/playability-badge.tsx index 1665a38..f4fa228 100644 --- a/components/playability-badge.tsx +++ b/components/playability-badge.tsx @@ -68,7 +68,7 @@ export function PlayabilityBadge({ )} title={config.label} > - + {showLabel && config.label} ); diff --git a/components/wizard/game-entry-wizard.tsx b/components/wizard/game-entry-wizard.tsx index 5b03eca..806e6c8 100644 --- a/components/wizard/game-entry-wizard.tsx +++ b/components/wizard/game-entry-wizard.tsx @@ -4,8 +4,8 @@ import { useState, useCallback, useEffect } from "react" import { useRouter } from "next/navigation" import { motion, AnimatePresence } from "motion/react" import { StepIndicator } from "@/components/wizard/step-indicator" -import { HardwareStep } from "@/components/wizard/steps/hardware-step" -import { AntiCheatStep } from "@/components/wizard/steps/anti-cheat-step" +import { SetupStep, type GameVersionInfo } from "@/components/wizard/steps/setup-step" +import { AntiCheatStep, type AntiCheatData } from "@/components/wizard/steps/anti-cheat-step" import { PerformanceStep, type PerformanceData } from "@/components/wizard/steps/performance-step" import { SettingsStep } from "@/components/wizard/steps/settings-step" import { EnvironmentStep, type EnvironmentData } from "@/components/wizard/steps/environment-step" @@ -13,9 +13,11 @@ import { ReviewStep } from "@/components/wizard/steps/review-step" import type { SettingCategory } from "@/components/wizard/settings-editor" import { performanceEntries } from "@/lib/db/schema" +// Export GameVersionInfo so the server page can use it +export type { GameVersionInfo } + const STEPS = [ - { label: "Hardware", tooltip: "Choose the hardware you tested this game on" }, - { label: "Anti-Cheat", tooltip: "Review anti-cheat compatibility for your selected hardware" }, + { label: "Setup", tooltip: "Choose the hardware, game version, and anti-cheat status" }, { label: "Performance", tooltip: "Enter the performance metrics you observed. FPS Average is required." }, { label: "Settings", tooltip: "Configure the game settings you used. Add categories and settings to help others replicate your setup." }, { label: "Environment", tooltip: "Specify the software environment and any launch options used" }, @@ -25,30 +27,63 @@ const STEPS = [ interface PlatformSupportEntry { hardwareSlug: string antiCheatRelevant: boolean - antiCheatStatus: "none" | "supported" | "unsupported" | "unknown" antiCheatName: string | null + antiCheatStatus: "none" | "supported" | "unsupported" | "unknown" } interface GameEntryWizardProps { gameId: string - gameVersionId: string + gameVersions: GameVersionInfo[] + defaultVersionId: string editEntry?: typeof performanceEntries.$inferSelect | null platformSupport: PlatformSupportEntry[] } -export function GameEntryWizard({ gameId, gameVersionId, editEntry, platformSupport }: GameEntryWizardProps) { +export function GameEntryWizard({ gameId, gameVersions, defaultVersionId, editEntry, platformSupport }: GameEntryWizardProps) { const router = useRouter() const [currentStep, setCurrentStep] = useState(0) const [isSubmitting, setIsSubmitting] = useState(false) const [error, setError] = useState(null) const [success, setSuccess] = useState(false) - // Step 1: Hardware + // Step 0: Setup — Hardware const [hardwareSlug, setHardwareSlug] = useState(editEntry?.hardwareSlug ?? "") const [hardwareName, setHardwareName] = useState("") - // Step 2: Anti-Cheat (informational) - // Step 3: Performance + // Step 0: Setup — Game Version + const [selectedVersionId, setSelectedVersionId] = useState(defaultVersionId) + const [newVersionString, setNewVersionString] = useState("") + const [isCreatingVersion, setIsCreatingVersion] = useState(false) + + // Step 0: Setup — Anti-Cheat + const [antiCheat, setAntiCheat] = useState({ + antiCheatRelevant: false, + antiCheatName: "", + antiCheatStatus: "none", + }) + + // Initialize anti-cheat from existing platformSupport when editing + useEffect(() => { + const entry = platformSupport.find( + (p) => p.hardwareSlug === hardwareSlug && p.antiCheatRelevant + ) ?? platformSupport.find((p) => p.antiCheatRelevant) + + if (entry) { + setAntiCheat({ + antiCheatRelevant: entry.antiCheatRelevant, + antiCheatName: entry.antiCheatName ?? "", + antiCheatStatus: entry.antiCheatStatus, + }) + } else { + setAntiCheat({ + antiCheatRelevant: false, + antiCheatName: "", + antiCheatStatus: "none", + }) + } + }, [hardwareSlug, platformSupport]) + + // Step 1: Performance const [performance, setPerformance] = useState( editEntry ? { @@ -62,12 +97,12 @@ export function GameEntryWizard({ gameId, gameVersionId, editEntry, platformSupp : {}, ) - // Step 4: Settings + // Step 2: Settings const [settingsJson, setSettingsJson] = useState( editEntry?.settingsJson ?? [], ) - // Step 5: Environment + // Step 3: Environment const [environment, setEnvironment] = useState( editEntry ? { @@ -86,7 +121,7 @@ export function GameEntryWizard({ gameId, gameVersionId, editEntry, platformSupp }, ) - // Step 6: Notes + // Step 4: Notes const [userNotes, setUserNotes] = useState(editEntry?.userNotes ?? "") // Fetch hardware name when slug changes @@ -128,19 +163,30 @@ export function GameEntryWizard({ gameId, gameVersionId, editEntry, platformSupp return () => { cancelled = true } }, [editEntry, hardwareSlug]) + // Resolve the version label for display + const getVersionLabel = useCallback(() => { + if (selectedVersionId === "__new__") { + return newVersionString || "New version" + } + const v = gameVersions.find((v) => v.id === selectedVersionId) + if (!v) return "Unknown" + return v.versionString || (v.buildId ? `Build ${v.buildId}` : "Unknown version") + }, [selectedVersionId, newVersionString, gameVersions]) + const canProceed = () => { switch (currentStep) { - case 0: - return hardwareSlug !== "" - case 1: - return true // Anti-cheat is informational - case 2: + case 0: // Setup + if (hardwareSlug === "") return false + // If new version selected, require version string + if (selectedVersionId === "__new__" && !newVersionString.trim()) return false + return true + case 1: // Performance return performance.fpsAvg !== undefined && performance.fpsAvg > 0 - case 3: - return true // Settings are optional - case 4: - return true // Environment is optional - case 5: + case 2: // Settings + return true + case 3: // Environment + return true + case 4: // Review return true default: return false @@ -165,13 +211,46 @@ export function GameEntryWizard({ gameId, gameVersionId, editEntry, platformSupp } } + // Resolve the final version ID — create a new version if needed + const resolveVersionId = async (): Promise => { + if (selectedVersionId !== "__new__") { + return selectedVersionId + } + + // Create a new version via API + setIsCreatingVersion(true) + try { + const res = await fetch(`/api/games/${gameId}/versions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + versionString: newVersionString.trim(), + isLatest: false, + }), + }) + + if (!res.ok) { + const data = await res.json() + throw new Error(data.error || "Failed to create game version") + } + + const data = await res.json() as { data: { id: string } } + return data.data.id + } finally { + setIsCreatingVersion(false) + } + } + const handleSubmit = async () => { setIsSubmitting(true) setError(null) try { + // Resolve version ID (may create a new version) + const versionId = await resolveVersionId() + const payload = { - versionId: gameVersionId, + versionId, hardwareSlug, fpsAvg: Number(performance.fpsAvg), fpsOnePercentLow: performance.fpsOnePercentLow !== undefined ? Number(performance.fpsOnePercentLow) : null, @@ -189,6 +268,9 @@ export function GameEntryWizard({ gameId, gameVersionId, editEntry, platformSupp customSystem: environment.customSystem ?? false, settingsJson: settingsJson.length > 0 ? settingsJson : null, userNotes: userNotes || null, + antiCheatRelevant: antiCheat.antiCheatRelevant, + antiCheatName: antiCheat.antiCheatName || null, + antiCheatStatus: antiCheat.antiCheatStatus, } const url = editEntry @@ -260,25 +342,38 @@ export function GameEntryWizard({ gameId, gameVersionId, editEntry, platformSupp className="min-h-[300px]" > {currentStep === 0 && ( - + )} {currentStep === 1 && ( - - )} - {currentStep === 2 && ( )} - {currentStep === 3 && ( + {currentStep === 2 && ( )} - {currentStep === 4 && ( + {currentStep === 3 && ( )} - {currentStep === 5 && ( + {currentStep === 4 && ( {/* Navigation Buttons */} - {currentStep < 5 && ( + {currentStep < 4 && (
+
+ Game uses anti-cheat + + {relevant + ? "Yes — select the anti-cheat name and compatibility below" + : "No anti-cheat software detected in this game"} + +
+
+ + {relevant && ( +
+ {/* Anti-cheat name */} +
+ + + onChange({ ...value, antiCheatName: e.target.value }) + } + placeholder="e.g. Easy Anti-Cheat, BattlEye, Ricochet" + className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm text-text placeholder:text-text/30 focus:outline-none focus:ring-2 focus:ring-primary/30" + /> +
+ + {/* Status radios */} +
+ +
+ {( + [ + "supported", + "unsupported", + "unknown", + ] as const + ).map((status) => { + const cfg = statusConfig[status] + const Icon = cfg.icon + const active = value.antiCheatStatus === status + return ( + + ) + })} +
+
+
+ )} + + {/* Preview card */} +
- -

- Anti-Cheat: {antiCheatEntry.antiCheatName || config.label} + +

+ {relevant && value.antiCheatName + ? value.antiCheatName + : currentConfig.label}

-

{config.message}

+

+ {currentConfig.message} +

) diff --git a/components/wizard/steps/review-step.tsx b/components/wizard/steps/review-step.tsx index 3dbd4f3..eb9ed7b 100644 --- a/components/wizard/steps/review-step.tsx +++ b/components/wizard/steps/review-step.tsx @@ -1,7 +1,7 @@ "use client" import { motion } from "motion/react" -import { Send, Loader2, AlertCircle, Monitor, Gauge, SlidersHorizontal, Terminal, FileText } from "lucide-react" +import { Send, Loader2, AlertCircle, Monitor, Gauge, SlidersHorizontal, Terminal, FileText, GitBranch, Shield } from "lucide-react" import { TiptapEditor } from "@/components/tiptap-editor" import type { SettingCategory } from "@/components/wizard/settings-editor" import type { PerformanceData } from "./performance-step" @@ -11,6 +11,12 @@ import { UPSCALER_TYPE_OPTIONS, FRAME_GEN_OPTIONS } from "./environment-step" export interface ReviewData { hardwareSlug: string hardwareName: string + gameVersionLabel: string + antiCheat: { + antiCheatRelevant: boolean + antiCheatName: string + antiCheatStatus: "none" | "supported" | "unsupported" | "unknown" + } performance: PerformanceData settings: SettingCategory[] environment: EnvironmentData @@ -56,7 +62,7 @@ export function ReviewStep({ isSubmitting, error, }: ReviewStepProps) { - const { hardwareName, performance, environment, settings } = data + const { hardwareName, gameVersionLabel, antiCheat, performance, environment, settings } = data const upscalerLabel = (() => { if (!data.environment.upscalerType || data.environment.upscalerType === "none") return "None" @@ -85,10 +91,24 @@ export function ReviewStep({ {/* Summary Cards */}
- {/* Hardware */} + {/* Setup: Hardware + Version + Anti-Cheat */}
- + + + {antiCheat.antiCheatRelevant && ( + <> + + + + )} + {!antiCheat.antiCheatRelevant && ( + + )}
{/* Performance */} diff --git a/components/wizard/steps/setup-step.tsx b/components/wizard/steps/setup-step.tsx new file mode 100644 index 0000000..3a700bf --- /dev/null +++ b/components/wizard/steps/setup-step.tsx @@ -0,0 +1,131 @@ +"use client" + +import { GitBranch } from "lucide-react" +import { HardwareStep } from "./hardware-step" +import { AntiCheatStep, type AntiCheatData } from "./anti-cheat-step" + +export interface GameVersionInfo { + id: string + versionString: string | null + buildId: string | null + isLatest: boolean +} + +interface SetupStepProps { + gameId: string + gameVersions: GameVersionInfo[] + hardwareSlug: string + onHardwareChange: (slug: string) => void + hardwareName: string + selectedVersionId: string + onVersionChange: (versionId: string) => void + newVersionString: string + onNewVersionStringChange: (value: string) => void + isCreatingVersion: boolean + antiCheat: AntiCheatData + onAntiCheatChange: (data: AntiCheatData) => void + platformSupport: { + hardwareSlug: string + antiCheatRelevant: boolean + antiCheatName: string | null + antiCheatStatus: "none" | "supported" | "unsupported" | "unknown" + }[] +} + +export function SetupStep({ + gameId, + gameVersions, + hardwareSlug, + onHardwareChange, + hardwareName, + selectedVersionId, + onVersionChange, + newVersionString, + onNewVersionStringChange, + isCreatingVersion, + antiCheat, + onAntiCheatChange, + platformSupport, +}: SetupStepProps) { + const isNewVersion = selectedVersionId === "__new__" + + return ( +
+ {/* Hardware Section */} +
+ +
+ +
+ + {/* Game Version Section */} +
+
+ +
+

Game Version

+

+ Which version of the game did you test? This helps others know if benchmarks match their version. +

+
+
+ +
+
+ + +
+ + {isNewVersion && ( +
+ + onNewVersionStringChange(e.target.value)} + placeholder="e.g. 1.2.3, Patch 4.0, Hotfix Jan 2025" + className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors" + disabled={isCreatingVersion} + /> +

+ Enter the game version you tested. This will create a new version entry. +

+
+ )} +
+
+ +
+ + {/* Anti-Cheat Section */} +
+ +
+
+ ) +} \ No newline at end of file diff --git a/lib/api/admin-performance.ts b/lib/api/admin-performance.ts index d7631cb..6b48977 100644 --- a/lib/api/admin-performance.ts +++ b/lib/api/admin-performance.ts @@ -8,6 +8,7 @@ import { user, } from "@/lib/db/schema" import { eq, desc, sql, and, ilike, isNull, isNotNull, or } from "drizzle-orm" +import { fuzzySearchTerm } from "@/lib/db/search" import { requireContributorOrAdmin, requireAdmin, @@ -47,7 +48,7 @@ export const adminPerformanceRoutes = new Elysia({ prefix: "/admin" }) conditions.push( or( ilike(user.name, `%${searchTerm}%`), - ilike(games.title, `%${searchTerm}%`), + ilike(games.title, fuzzySearchTerm(searchTerm)), ), ) } diff --git a/lib/api/crud-builder.ts b/lib/api/crud-builder.ts index 0b9977d..ef92b46 100644 --- a/lib/api/crud-builder.ts +++ b/lib/api/crud-builder.ts @@ -17,6 +17,7 @@ import { or, } from "drizzle-orm" import { requireRole } from "@/lib/auth/guard" +import { fuzzySearchTerm } from "@/lib/db/search" /** Which columns are text-searchable via ilike */ export type CrudSearchConfig = { @@ -94,10 +95,15 @@ export function createCrudRoutes( // Search if (query.search && search) { + const searchStr = query.search const searchConditions = search.fields .map((field) => { const col = columns[field] - return col ? ilike(col, `%${query.search}%`) : null + if (!col) return null + const pattern = field === "title" + ? fuzzySearchTerm(searchStr) + : `%${searchStr}%` + return ilike(col, pattern) }) .filter(Boolean) as SQL[] if (searchConditions.length > 0) { diff --git a/lib/api/games-listing.ts b/lib/api/games-listing.ts index d4fcfb0..72da4b5 100644 --- a/lib/api/games-listing.ts +++ b/lib/api/games-listing.ts @@ -8,6 +8,7 @@ import { hardware, } from "@/lib/db/schema" import { ilike, or, sql, eq, and, desc, asc, inArray, gte, lte } from "drizzle-orm" +import { fuzzySearchTerm } from "@/lib/db/search" const MAX_OFFSET = 10000 const PAGE_SIZE = 24 @@ -39,10 +40,11 @@ export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get( // Search filter (title, developer, publisher) if (search) { + const titleTerm = fuzzySearchTerm(search) const term = `%${search}%` conditions.push( or( - ilike(games.title, term), + ilike(games.title, titleTerm), ilike(games.developer, term), ilike(games.publisher, term), )!, diff --git a/lib/api/performance-submit.ts b/lib/api/performance-submit.ts index 59497f7..a432786 100644 --- a/lib/api/performance-submit.ts +++ b/lib/api/performance-submit.ts @@ -4,8 +4,9 @@ import { performanceEntries, gameVersions, hardware, + gamePlatformSupport, } from "@/lib/db/schema" -import { eq, sql } from "drizzle-orm" +import { eq, and, sql } from "drizzle-orm" import { requireRole } from "@/lib/auth/guard" import { recalculatePlayability } from "./playability" @@ -72,7 +73,7 @@ export const performanceSubmitRoutes = new Elysia({ prefix: "/performance" }) // Verify the game version exists const [version] = await db - .select({ id: gameVersions.id }) + .select({ id: gameVersions.id, gameId: gameVersions.gameId }) .from(gameVersions) .where(eq(gameVersions.id, body.versionId)) .limit(1) @@ -120,18 +121,48 @@ export const performanceSubmitRoutes = new Elysia({ prefix: "/performance" }) }) .returning() - // Recalculate playability for this game (fire and forget) - const [gameVersion] = await db - .select({ gameId: gameVersions.gameId }) - .from(gameVersions) - .where(eq(gameVersions.id, body.versionId)) - .limit(1) - if (gameVersion) { - recalculatePlayability(gameVersion.gameId).catch((err) => - console.error("Failed to recalculate playability:", err), + // Update or create gamePlatformSupport with anti-cheat info + const [existingSupport] = await db + .select() + .from(gamePlatformSupport) + .where( + and( + eq(gamePlatformSupport.gameId, version.gameId), + eq(gamePlatformSupport.hardwareSlug, body.hardwareSlug), + ), ) + .limit(1) + + if (existingSupport) { + await db + .update(gamePlatformSupport) + .set({ + antiCheatRelevant: body.antiCheatRelevant ?? existingSupport.antiCheatRelevant, + antiCheatName: body.antiCheatRelevant + ? (body.antiCheatName ?? existingSupport.antiCheatName) + : null, + antiCheatStatus: body.antiCheatStatus ?? existingSupport.antiCheatStatus, + updatedAt: new Date(), + }) + .where(eq(gamePlatformSupport.id, existingSupport.id)) + } else { + await db.insert(gamePlatformSupport).values({ + gameId: version.gameId, + hardwareSlug: body.hardwareSlug, + isSupported: true, + protonStatus: "unknown", + antiCheatRelevant: body.antiCheatRelevant ?? false, + antiCheatName: body.antiCheatRelevant ? body.antiCheatName ?? null : null, + antiCheatStatus: body.antiCheatStatus ?? "unknown", + playabilityStatus: "unknown", + }) } + // Recalculate playability for this game (fire and forget) + recalculatePlayability(version.gameId).catch((err) => + console.error("Failed to recalculate playability:", err), + ) + set.status = 201 return { id: entry.id, @@ -190,6 +221,16 @@ export const performanceSubmitRoutes = new Elysia({ prefix: "/performance" }) ]), ), userNotes: t.Optional(t.Union([t.String(), t.Null()])), + antiCheatRelevant: t.Optional(t.Boolean()), + antiCheatName: t.Optional(t.Union([t.String(), t.Null()])), + antiCheatStatus: t.Optional( + t.Union([ + t.Literal("none"), + t.Literal("supported"), + t.Literal("unsupported"), + t.Literal("unknown"), + ]), + ), }), }, ) diff --git a/lib/api/performance.ts b/lib/api/performance.ts index 21965d1..9807e79 100644 --- a/lib/api/performance.ts +++ b/lib/api/performance.ts @@ -1,6 +1,6 @@ import { Elysia, t } from "elysia" import { createCrudRoutes } from "./crud-builder" -import { performanceEntries, games, gameVersions, hardware, user } from "@/lib/db/schema" +import { performanceEntries, games, gameVersions, hardware, user, gamePlatformSupport } from "@/lib/db/schema" import { db } from "@/lib/db/index" import { eq, and, desc, sql } from "drizzle-orm" import { requireRole } from "@/lib/auth/guard" @@ -268,10 +268,10 @@ export const performanceVerifyRoutes = new Elysia({ updatedAt: new Date(), } - if (body.fpsAvg !== undefined) updateData.fpsAvg = body.fpsAvg - if (body.fpsOnePercentLow !== undefined) updateData.fpsOnePercentLow = body.fpsOnePercentLow - if (body.fpsLow !== undefined) updateData.fpsLow = body.fpsLow - if (body.fpsHigh !== undefined) updateData.fpsHigh = body.fpsHigh + if (body.fpsAvg !== undefined) updateData.fpsAvg = body.fpsAvg ?? undefined + if (body.fpsOnePercentLow !== undefined) updateData.fpsOnePercentLow = body.fpsOnePercentLow ?? undefined + if (body.fpsLow !== undefined) updateData.fpsLow = body.fpsLow ?? undefined + if (body.fpsHigh !== undefined) updateData.fpsHigh = body.fpsHigh ?? undefined if (body.protonVersion !== undefined) updateData.protonVersion = body.protonVersion if (body.osVersion !== undefined) @@ -295,15 +295,82 @@ export const performanceVerifyRoutes = new Elysia({ .where(eq(performanceEntries.id, params.id)) .returning() + // Update gamePlatformSupport anti-cheat info if provided + if ( + body.antiCheatRelevant !== undefined || + body.antiCheatName !== undefined || + body.antiCheatStatus !== undefined + ) { + // Need versionId to resolve gameId + const [entryVersion] = await db + .select({ versionId: performanceEntries.versionId }) + .from(performanceEntries) + .where(eq(performanceEntries.id, params.id)) + .limit(1) + + if (entryVersion) { + const [gv] = await db + .select({ gameId: gameVersions.gameId }) + .from(gameVersions) + .where(eq(gameVersions.id, entryVersion.versionId)) + .limit(1) + + if (gv) { + const [existingSupport] = await db + .select() + .from(gamePlatformSupport) + .where( + and( + eq(gamePlatformSupport.gameId, gv.gameId), + eq(gamePlatformSupport.hardwareSlug, updated.hardwareSlug), + ), + ) + .limit(1) + + if (existingSupport) { + await db + .update(gamePlatformSupport) + .set({ + antiCheatRelevant: + body.antiCheatRelevant !== undefined + ? body.antiCheatRelevant + : existingSupport.antiCheatRelevant, + antiCheatName: + body.antiCheatName !== undefined + ? body.antiCheatName + : existingSupport.antiCheatName, + antiCheatStatus: + body.antiCheatStatus !== undefined + ? (body.antiCheatStatus ?? "unknown") + : existingSupport.antiCheatStatus, + updatedAt: new Date(), + }) + .where(eq(gamePlatformSupport.id, existingSupport.id)) + } else { + await db.insert(gamePlatformSupport).values({ + gameId: gv.gameId, + hardwareSlug: updated.hardwareSlug, + isSupported: true, + protonStatus: "unknown", + antiCheatRelevant: body.antiCheatRelevant ?? false, + antiCheatName: body.antiCheatName ?? null, + antiCheatStatus: (body.antiCheatStatus ?? "unknown"), + playabilityStatus: "unknown", + }) + } + } + } + } + return updated }, { params: t.Object({ id: t.String() }), body: t.Object({ - fpsAvg: t.Optional(t.Number()), - fpsOnePercentLow: t.Optional(t.Number()), - fpsLow: t.Optional(t.Number()), - fpsHigh: t.Optional(t.Number()), + fpsAvg: t.Optional(t.Union([t.Number(), t.Null()])), + fpsOnePercentLow: t.Optional(t.Union([t.Number(), t.Null()])), + fpsLow: t.Optional(t.Union([t.Number(), t.Null()])), + fpsHigh: t.Optional(t.Union([t.Number(), t.Null()])), protonVersion: t.Optional(t.Union([t.String(), t.Null()])), osVersion: t.Optional(t.Union([t.String(), t.Null()])), upscalerType: t.Optional( @@ -331,6 +398,17 @@ export const performanceVerifyRoutes = new Elysia({ launchOptions: t.Optional(t.Union([t.String(), t.Null()])), settingsJson: t.Optional(t.Union([t.Array(t.Any()), t.Null()])), userNotes: t.Optional(t.Union([t.String(), t.Null()])), + antiCheatRelevant: t.Optional(t.Boolean()), + antiCheatName: t.Optional(t.Union([t.String(), t.Null()])), + antiCheatStatus: t.Optional( + t.Union([ + t.Literal("none"), + t.Literal("supported"), + t.Literal("unsupported"), + t.Literal("unknown"), + t.Null(), + ]), + ), }), }, ) diff --git a/lib/api/search-unified.ts b/lib/api/search-unified.ts index c603f0f..d125403 100644 --- a/lib/api/search-unified.ts +++ b/lib/api/search-unified.ts @@ -7,6 +7,7 @@ import { gameComments, } from "@/lib/db/schema" import { ilike, or, sql, eq, inArray, and } from "drizzle-orm" +import { fuzzySearchTerm } from "@/lib/db/search" interface SteamSearchItem { id: number @@ -31,6 +32,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get( return { error: "Query must be at least 2 characters" } } + const titleTerm = fuzzySearchTerm(query.q) const term = `%${query.q}%` // ── 1. Search local database ──────────────────────────────────── @@ -39,7 +41,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get( .from(games) .where( or( - ilike(games.title, term), + ilike(games.title, titleTerm), ilike(games.developer, term), ilike(games.publisher, term), ), @@ -281,6 +283,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get( genres: g.genres, source: g.source, counts, + platforms: g.platforms, platformSupport: platform ? { isSupported: platform.isSupported, @@ -289,7 +292,19 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get( antiCheatName: platform.antiCheatName, antiCheatStatus: platform.antiCheatStatus, } - : null, + : g.platforms + ? { + isSupported: g.platforms.linux || g.platforms.windows || false, + protonStatus: g.platforms.linux + ? "native" + : g.platforms.windows + ? "proton" + : "unsupported", + antiCheatRelevant: false, + antiCheatName: null, + antiCheatStatus: "unknown", + } + : null, isRawPerformer: rawPerformerMap.get(g.id) ?? false, isPoorPerformance: poorPerformerMap.get(g.id) ?? false, bestFps: bestFpsMap.get(g.id) ?? null, diff --git a/lib/db/search.ts b/lib/db/search.ts new file mode 100644 index 0000000..ca39824 --- /dev/null +++ b/lib/db/search.ts @@ -0,0 +1,17 @@ +/** + * Normalizes a raw search query so that common word separators + * (spaces, hyphens, underscores, colons, dots) are treated equivalently. + * + * Replaces any sequence of separator chars with a single `%` ILIKE wildcard, + * then wraps the whole pattern in `%…%`. + * + * Example: + * fuzzySearchTerm("counter strike") → "%counter%strike%" + * fuzzySearchTerm("counter-strike") → "%counter%strike%" + * + * Both will match "Counter-Strike", "Counter Strike", "Counter_Strike", etc. + */ +export function fuzzySearchTerm(rawQuery: string): string { + const normalized = rawQuery.replace(/[-_\s:.]+/g, "%") + return `%${normalized}%` +} \ No newline at end of file diff --git a/package.json b/package.json index 5d6357c..c373424 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "deckyvault", - "version": "2026.0.9", + "version": "2026.0.95", "private": true, "scripts": { "dev": "next dev --experimental-https",