feat: rearrange game benchmark wizard and improve overall
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
All notable changes to DeckyVault will be documented in this file.
|
All notable changes to DeckyVault will be documented in this file.
|
||||||
|
|
||||||
## [Unreleased] - 2026-04-30
|
## [2026.0.95] - 2026-04-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ export function GamesClient() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
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) {
|
if (res.ok && !cancelled) {
|
||||||
const json = (await res.json()) as GamesApiResponse
|
const json = (await res.json()) as GamesApiResponse
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ interface Game {
|
|||||||
playabilityStatus?: "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown" | null
|
playabilityStatus?: "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown" | null
|
||||||
steamReviewScore?: number | null
|
steamReviewScore?: number | null
|
||||||
steamReviewSentiment?: string | null
|
steamReviewSentiment?: string | null
|
||||||
|
steamReviewCount?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Counts {
|
interface Counts {
|
||||||
|
|||||||
+15
-3
@@ -254,16 +254,25 @@ export default async function GamePage({
|
|||||||
.orderBy(desc(performanceEntries.isPinned), desc(performanceEntries.upvotes)),
|
.orderBy(desc(performanceEntries.isPinned), desc(performanceEntries.upvotes)),
|
||||||
])
|
])
|
||||||
|
|
||||||
// ── Sync logic: force or stale-while-revalidate ─────────────────
|
// ── Sync logic: force or stale-while-revalidate ────────────────
|
||||||
const shouldSync =
|
const shouldSync =
|
||||||
game.source === "steam" &&
|
game.source === "steam" &&
|
||||||
game.steamAppId &&
|
game.steamAppId &&
|
||||||
(forceSync || isSyncStale(game.lastSync))
|
(forceSync || isSyncStale(game.lastSync))
|
||||||
|
|
||||||
if (shouldSync) {
|
if (shouldSync) {
|
||||||
after(async () => {
|
if (forceSync) {
|
||||||
|
// Block render on forced sync so user sees fresh data immediately
|
||||||
await syncSteamGame(game.steamAppId!)
|
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)
|
// Serialize for client component (Dates → strings)
|
||||||
@@ -293,6 +302,9 @@ export default async function GamePage({
|
|||||||
releaseDate: game.releaseDate,
|
releaseDate: game.releaseDate,
|
||||||
categories: game.categories,
|
categories: game.categories,
|
||||||
platforms: game.platforms,
|
platforms: game.platforms,
|
||||||
|
steamReviewScore: game.steamReviewScore,
|
||||||
|
steamReviewSentiment: game.steamReviewSentiment,
|
||||||
|
steamReviewCount: game.steamReviewCount,
|
||||||
}
|
}
|
||||||
|
|
||||||
const serializedPresets = presetRows.map((p) => ({
|
const serializedPresets = presetRows.map((p) => ({
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { notFound } from "next/navigation"
|
|||||||
import { db } from "@/lib/db/index"
|
import { db } from "@/lib/db/index"
|
||||||
import { games, gameVersions, performanceEntries, gamePlatformSupport } from "@/lib/db/schema"
|
import { games, gameVersions, performanceEntries, gamePlatformSupport } from "@/lib/db/schema"
|
||||||
import { eq, sql } from "drizzle-orm"
|
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
|
// This page needs live data — skip static generation at build time
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
@@ -47,37 +47,34 @@ export default async function SubmitBenchmarkPage({
|
|||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get or create the latest game version
|
// Fetch all game versions for version selection
|
||||||
let [version] = await db
|
const allVersions = await db
|
||||||
.select()
|
.select({
|
||||||
|
id: gameVersions.id,
|
||||||
|
versionString: gameVersions.versionString,
|
||||||
|
buildId: gameVersions.buildId,
|
||||||
|
isLatest: gameVersions.isLatest,
|
||||||
|
})
|
||||||
.from(gameVersions)
|
.from(gameVersions)
|
||||||
.where(
|
.where(eq(gameVersions.gameId, game.id))
|
||||||
eq(gameVersions.gameId, game.id),
|
|
||||||
)
|
|
||||||
.orderBy(sql`${gameVersions.createdAt} DESC`)
|
.orderBy(sql`${gameVersions.createdAt} DESC`)
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
// Create a default version if none exists
|
// Create a default version if none exists
|
||||||
if (!version) {
|
if (allVersions.length === 0) {
|
||||||
[version] = await db
|
const [newVersion] = await db
|
||||||
.insert(gameVersions)
|
.insert(gameVersions)
|
||||||
.values({
|
.values({
|
||||||
gameId: game.id,
|
gameId: game.id,
|
||||||
isLatest: true,
|
isLatest: true,
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
}
|
allVersions.push({
|
||||||
|
id: newVersion.id,
|
||||||
// Fetch platform support for anti-cheat awareness
|
versionString: newVersion.versionString,
|
||||||
const platformSupport = await db
|
buildId: newVersion.buildId,
|
||||||
.select({
|
isLatest: newVersion.isLatest,
|
||||||
hardwareSlug: gamePlatformSupport.hardwareSlug,
|
|
||||||
antiCheatRelevant: gamePlatformSupport.antiCheatRelevant,
|
|
||||||
antiCheatName: gamePlatformSupport.antiCheatName,
|
|
||||||
antiCheatStatus: gamePlatformSupport.antiCheatStatus,
|
|
||||||
})
|
})
|
||||||
.from(gamePlatformSupport)
|
}
|
||||||
.where(eq(gamePlatformSupport.gameId, game.id))
|
|
||||||
|
|
||||||
// If editing, fetch the existing performance entry
|
// If editing, fetch the existing performance entry
|
||||||
let editEntry = null
|
let editEntry = null
|
||||||
@@ -90,6 +87,26 @@ export default async function SubmitBenchmarkPage({
|
|||||||
editEntry = entry ?? null
|
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 (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 py-8 w-full">
|
<div className="max-w-7xl mx-auto px-4 py-8 w-full">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
@@ -104,7 +121,8 @@ export default async function SubmitBenchmarkPage({
|
|||||||
|
|
||||||
<GameEntryWizard
|
<GameEntryWizard
|
||||||
gameId={game.id}
|
gameId={game.id}
|
||||||
gameVersionId={version.id}
|
gameVersions={gameVersionInfos}
|
||||||
|
defaultVersionId={defaultVersionId}
|
||||||
editEntry={editEntry}
|
editEntry={editEntry}
|
||||||
platformSupport={platformSupport}
|
platformSupport={platformSupport}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -677,7 +677,7 @@ function GameCard({ game }: { game: GamesListItem }) {
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
{game.playabilityStatus && (
|
{game.playabilityStatus && (
|
||||||
<PlayabilityBadge status={game.playabilityStatus} compact showLabel={false} />
|
<PlayabilityBadge status={game.playabilityStatus} compact />
|
||||||
)}
|
)}
|
||||||
{game.antiCheatRelevant && game.antiCheatStatus === "unsupported" && (
|
{game.antiCheatRelevant && game.antiCheatStatus === "unsupported" && (
|
||||||
<AntiCheatBadge
|
<AntiCheatBadge
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
|
import { after } from "next/server"
|
||||||
import { db } from "@/lib/db/index"
|
import { db } from "@/lib/db/index"
|
||||||
import {
|
import {
|
||||||
games,
|
games,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
hardware,
|
hardware,
|
||||||
} from "@/lib/db/schema"
|
} from "@/lib/db/schema"
|
||||||
import { sql, eq, and, desc, inArray } from "drizzle-orm"
|
import { sql, eq, and, desc, inArray } from "drizzle-orm"
|
||||||
|
import { isSyncStale, syncSteamGame } from "@/lib/steam/sync"
|
||||||
import { GamesPageClient } from "./games-page-client"
|
import { GamesPageClient } from "./games-page-client"
|
||||||
|
|
||||||
// This page needs live data — skip static generation at build time
|
// This page needs live data — skip static generation at build time
|
||||||
@@ -51,6 +53,7 @@ export default async function GamesPage() {
|
|||||||
steamReviewScore: games.steamReviewScore,
|
steamReviewScore: games.steamReviewScore,
|
||||||
playabilityStatus: games.playabilityStatus,
|
playabilityStatus: games.playabilityStatus,
|
||||||
onlineMultiplayerStatus: games.onlineMultiplayerStatus,
|
onlineMultiplayerStatus: games.onlineMultiplayerStatus,
|
||||||
|
lastSync: games.lastSync,
|
||||||
})
|
})
|
||||||
.from(games)
|
.from(games)
|
||||||
.orderBy(desc(games.createdAt))
|
.orderBy(desc(games.createdAt))
|
||||||
@@ -163,6 +166,25 @@ export default async function GamesPage() {
|
|||||||
const allGenres = Array.from(genreSet).sort()
|
const allGenres = Array.from(genreSet).sort()
|
||||||
const allDevices = deviceRows
|
const allDevices = deviceRows
|
||||||
|
|
||||||
|
// ── Background sync for stale games ────────────────────────────────
|
||||||
|
const staleSteamAppIds = gamesData
|
||||||
|
.filter((g) => 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
|
// JSON-LD ItemList
|
||||||
const jsonLd = {
|
const jsonLd = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
|
|||||||
+1
-1
@@ -245,7 +245,7 @@ function SearchResultCard({
|
|||||||
transition={{ duration: 0.3, delay: index * 0.02 }}
|
transition={{ duration: 0.3, delay: index * 0.02 }}
|
||||||
whileHover={{ scale: 1.005 }}
|
whileHover={{ scale: 1.005 }}
|
||||||
whileTap={{ scale: 0.995 }}
|
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
|
title={isLocal
|
||||||
? "Click to view game details, benchmarks, and settings"
|
? "Click to view game details, benchmarks, and settings"
|
||||||
: "Click to add this game to DeckyVault and view its page"
|
: "Click to add this game to DeckyVault and view its page"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface CommentsApiResponse {
|
|||||||
|
|
||||||
export function CommentSection({ gameId, initialCount }: CommentSectionProps) {
|
export function CommentSection({ gameId, initialCount }: CommentSectionProps) {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
const [comments, setComments] = useState<CommentData[]>([])
|
const [comments, setComments] = useState<CommentData[]>([])
|
||||||
const [total, setTotal] = useState(initialCount)
|
const [total, setTotal] = useState(initialCount)
|
||||||
const [offset, setOffset] = useState(0)
|
const [offset, setOffset] = useState(0)
|
||||||
@@ -29,6 +30,10 @@ export function CommentSection({ gameId, initialCount }: CommentSectionProps) {
|
|||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const limit = 20
|
const limit = 20
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -125,8 +130,8 @@ export function CommentSection({ gameId, initialCount }: CommentSectionProps) {
|
|||||||
<span className="text-sm text-text/50">({total})</span>
|
<span className="text-sm text-text/50">({total})</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Compose */}
|
{/* Compose — suppress until mounted to avoid hydration mismatch */}
|
||||||
{session ? (
|
{mounted && session ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<TiptapEditor
|
<TiptapEditor
|
||||||
placeholder="Leave a comment..."
|
placeholder="Leave a comment..."
|
||||||
@@ -149,7 +154,7 @@ export function CommentSection({ gameId, initialCount }: CommentSectionProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : mounted ? (
|
||||||
<div className="p-4 rounded-lg border border-border bg-text/3 text-center">
|
<div className="p-4 rounded-lg border border-border bg-text/3 text-center">
|
||||||
<p className="text-sm text-text/70">
|
<p className="text-sm text-text/70">
|
||||||
<Link
|
<Link
|
||||||
@@ -161,6 +166,8 @@ export function CommentSection({ gameId, initialCount }: CommentSectionProps) {
|
|||||||
to leave a comment
|
to leave a comment
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-[72px] rounded-lg border border-border bg-text/[0.02] animate-pulse" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Comment list */}
|
{/* Comment list */}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function PlayabilityBadge({
|
|||||||
)}
|
)}
|
||||||
title={config.label}
|
title={config.label}
|
||||||
>
|
>
|
||||||
<span className={cn("h-2 w-2 rounded-full", config.dotColor)} />
|
<Icon className="h-3 w-3" />
|
||||||
{showLabel && config.label}
|
{showLabel && config.label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { useState, useCallback, useEffect } from "react"
|
|||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { motion, AnimatePresence } from "motion/react"
|
import { motion, AnimatePresence } from "motion/react"
|
||||||
import { StepIndicator } from "@/components/wizard/step-indicator"
|
import { StepIndicator } from "@/components/wizard/step-indicator"
|
||||||
import { HardwareStep } from "@/components/wizard/steps/hardware-step"
|
import { SetupStep, type GameVersionInfo } from "@/components/wizard/steps/setup-step"
|
||||||
import { AntiCheatStep } from "@/components/wizard/steps/anti-cheat-step"
|
import { AntiCheatStep, type AntiCheatData } from "@/components/wizard/steps/anti-cheat-step"
|
||||||
import { PerformanceStep, type PerformanceData } from "@/components/wizard/steps/performance-step"
|
import { PerformanceStep, type PerformanceData } from "@/components/wizard/steps/performance-step"
|
||||||
import { SettingsStep } from "@/components/wizard/steps/settings-step"
|
import { SettingsStep } from "@/components/wizard/steps/settings-step"
|
||||||
import { EnvironmentStep, type EnvironmentData } from "@/components/wizard/steps/environment-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 type { SettingCategory } from "@/components/wizard/settings-editor"
|
||||||
import { performanceEntries } from "@/lib/db/schema"
|
import { performanceEntries } from "@/lib/db/schema"
|
||||||
|
|
||||||
|
// Export GameVersionInfo so the server page can use it
|
||||||
|
export type { GameVersionInfo }
|
||||||
|
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ label: "Hardware", tooltip: "Choose the hardware you tested this game on" },
|
{ label: "Setup", tooltip: "Choose the hardware, game version, and anti-cheat status" },
|
||||||
{ label: "Anti-Cheat", tooltip: "Review anti-cheat compatibility for your selected hardware" },
|
|
||||||
{ label: "Performance", tooltip: "Enter the performance metrics you observed. FPS Average is required." },
|
{ 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: "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" },
|
{ label: "Environment", tooltip: "Specify the software environment and any launch options used" },
|
||||||
@@ -25,30 +27,63 @@ const STEPS = [
|
|||||||
interface PlatformSupportEntry {
|
interface PlatformSupportEntry {
|
||||||
hardwareSlug: string
|
hardwareSlug: string
|
||||||
antiCheatRelevant: boolean
|
antiCheatRelevant: boolean
|
||||||
antiCheatStatus: "none" | "supported" | "unsupported" | "unknown"
|
|
||||||
antiCheatName: string | null
|
antiCheatName: string | null
|
||||||
|
antiCheatStatus: "none" | "supported" | "unsupported" | "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GameEntryWizardProps {
|
interface GameEntryWizardProps {
|
||||||
gameId: string
|
gameId: string
|
||||||
gameVersionId: string
|
gameVersions: GameVersionInfo[]
|
||||||
|
defaultVersionId: string
|
||||||
editEntry?: typeof performanceEntries.$inferSelect | null
|
editEntry?: typeof performanceEntries.$inferSelect | null
|
||||||
platformSupport: PlatformSupportEntry[]
|
platformSupport: PlatformSupportEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GameEntryWizard({ gameId, gameVersionId, editEntry, platformSupport }: GameEntryWizardProps) {
|
export function GameEntryWizard({ gameId, gameVersions, defaultVersionId, editEntry, platformSupport }: GameEntryWizardProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [currentStep, setCurrentStep] = useState(0)
|
const [currentStep, setCurrentStep] = useState(0)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
|
|
||||||
// Step 1: Hardware
|
// Step 0: Setup — Hardware
|
||||||
const [hardwareSlug, setHardwareSlug] = useState(editEntry?.hardwareSlug ?? "")
|
const [hardwareSlug, setHardwareSlug] = useState(editEntry?.hardwareSlug ?? "")
|
||||||
const [hardwareName, setHardwareName] = useState("")
|
const [hardwareName, setHardwareName] = useState("")
|
||||||
|
|
||||||
// Step 2: Anti-Cheat (informational)
|
// Step 0: Setup — Game Version
|
||||||
// Step 3: Performance
|
const [selectedVersionId, setSelectedVersionId] = useState(defaultVersionId)
|
||||||
|
const [newVersionString, setNewVersionString] = useState("")
|
||||||
|
const [isCreatingVersion, setIsCreatingVersion] = useState(false)
|
||||||
|
|
||||||
|
// Step 0: Setup — Anti-Cheat
|
||||||
|
const [antiCheat, setAntiCheat] = useState<AntiCheatData>({
|
||||||
|
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<PerformanceData>(
|
const [performance, setPerformance] = useState<PerformanceData>(
|
||||||
editEntry
|
editEntry
|
||||||
? {
|
? {
|
||||||
@@ -62,12 +97,12 @@ export function GameEntryWizard({ gameId, gameVersionId, editEntry, platformSupp
|
|||||||
: {},
|
: {},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Step 4: Settings
|
// Step 2: Settings
|
||||||
const [settingsJson, setSettingsJson] = useState<SettingCategory[]>(
|
const [settingsJson, setSettingsJson] = useState<SettingCategory[]>(
|
||||||
editEntry?.settingsJson ?? [],
|
editEntry?.settingsJson ?? [],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Step 5: Environment
|
// Step 3: Environment
|
||||||
const [environment, setEnvironment] = useState<EnvironmentData>(
|
const [environment, setEnvironment] = useState<EnvironmentData>(
|
||||||
editEntry
|
editEntry
|
||||||
? {
|
? {
|
||||||
@@ -86,7 +121,7 @@ export function GameEntryWizard({ gameId, gameVersionId, editEntry, platformSupp
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Step 6: Notes
|
// Step 4: Notes
|
||||||
const [userNotes, setUserNotes] = useState(editEntry?.userNotes ?? "")
|
const [userNotes, setUserNotes] = useState(editEntry?.userNotes ?? "")
|
||||||
|
|
||||||
// Fetch hardware name when slug changes
|
// Fetch hardware name when slug changes
|
||||||
@@ -128,19 +163,30 @@ export function GameEntryWizard({ gameId, gameVersionId, editEntry, platformSupp
|
|||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [editEntry, hardwareSlug])
|
}, [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 = () => {
|
const canProceed = () => {
|
||||||
switch (currentStep) {
|
switch (currentStep) {
|
||||||
case 0:
|
case 0: // Setup
|
||||||
return hardwareSlug !== ""
|
if (hardwareSlug === "") return false
|
||||||
case 1:
|
// If new version selected, require version string
|
||||||
return true // Anti-cheat is informational
|
if (selectedVersionId === "__new__" && !newVersionString.trim()) return false
|
||||||
case 2:
|
return true
|
||||||
|
case 1: // Performance
|
||||||
return performance.fpsAvg !== undefined && performance.fpsAvg > 0
|
return performance.fpsAvg !== undefined && performance.fpsAvg > 0
|
||||||
case 3:
|
case 2: // Settings
|
||||||
return true // Settings are optional
|
return true
|
||||||
case 4:
|
case 3: // Environment
|
||||||
return true // Environment is optional
|
return true
|
||||||
case 5:
|
case 4: // Review
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
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<string> => {
|
||||||
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Resolve version ID (may create a new version)
|
||||||
|
const versionId = await resolveVersionId()
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
versionId: gameVersionId,
|
versionId,
|
||||||
hardwareSlug,
|
hardwareSlug,
|
||||||
fpsAvg: Number(performance.fpsAvg),
|
fpsAvg: Number(performance.fpsAvg),
|
||||||
fpsOnePercentLow: performance.fpsOnePercentLow !== undefined ? Number(performance.fpsOnePercentLow) : null,
|
fpsOnePercentLow: performance.fpsOnePercentLow !== undefined ? Number(performance.fpsOnePercentLow) : null,
|
||||||
@@ -189,6 +268,9 @@ export function GameEntryWizard({ gameId, gameVersionId, editEntry, platformSupp
|
|||||||
customSystem: environment.customSystem ?? false,
|
customSystem: environment.customSystem ?? false,
|
||||||
settingsJson: settingsJson.length > 0 ? settingsJson : null,
|
settingsJson: settingsJson.length > 0 ? settingsJson : null,
|
||||||
userNotes: userNotes || null,
|
userNotes: userNotes || null,
|
||||||
|
antiCheatRelevant: antiCheat.antiCheatRelevant,
|
||||||
|
antiCheatName: antiCheat.antiCheatName || null,
|
||||||
|
antiCheatStatus: antiCheat.antiCheatStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = editEntry
|
const url = editEntry
|
||||||
@@ -260,25 +342,38 @@ export function GameEntryWizard({ gameId, gameVersionId, editEntry, platformSupp
|
|||||||
className="min-h-[300px]"
|
className="min-h-[300px]"
|
||||||
>
|
>
|
||||||
{currentStep === 0 && (
|
{currentStep === 0 && (
|
||||||
<HardwareStep value={hardwareSlug} onChange={handleHardwareChange} />
|
<SetupStep
|
||||||
|
gameId={gameId}
|
||||||
|
gameVersions={gameVersions}
|
||||||
|
hardwareSlug={hardwareSlug}
|
||||||
|
onHardwareChange={handleHardwareChange}
|
||||||
|
hardwareName={hardwareName}
|
||||||
|
selectedVersionId={selectedVersionId}
|
||||||
|
onVersionChange={setSelectedVersionId}
|
||||||
|
newVersionString={newVersionString}
|
||||||
|
onNewVersionStringChange={setNewVersionString}
|
||||||
|
isCreatingVersion={isCreatingVersion}
|
||||||
|
antiCheat={antiCheat}
|
||||||
|
onAntiCheatChange={setAntiCheat}
|
||||||
|
platformSupport={platformSupport}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<AntiCheatStep hardwareSlug={hardwareSlug} platformSupport={platformSupport} />
|
|
||||||
)}
|
|
||||||
{currentStep === 2 && (
|
|
||||||
<PerformanceStep value={performance} onChange={setPerformance} />
|
<PerformanceStep value={performance} onChange={setPerformance} />
|
||||||
)}
|
)}
|
||||||
{currentStep === 3 && (
|
{currentStep === 2 && (
|
||||||
<SettingsStep value={settingsJson} onChange={setSettingsJson} />
|
<SettingsStep value={settingsJson} onChange={setSettingsJson} />
|
||||||
)}
|
)}
|
||||||
{currentStep === 4 && (
|
{currentStep === 3 && (
|
||||||
<EnvironmentStep value={environment} onChange={setEnvironment} />
|
<EnvironmentStep value={environment} onChange={setEnvironment} />
|
||||||
)}
|
)}
|
||||||
{currentStep === 5 && (
|
{currentStep === 4 && (
|
||||||
<ReviewStep
|
<ReviewStep
|
||||||
data={{
|
data={{
|
||||||
hardwareSlug,
|
hardwareSlug,
|
||||||
hardwareName,
|
hardwareName,
|
||||||
|
gameVersionLabel: getVersionLabel(),
|
||||||
|
antiCheat,
|
||||||
performance,
|
performance,
|
||||||
settings: settingsJson,
|
settings: settingsJson,
|
||||||
environment,
|
environment,
|
||||||
@@ -294,7 +389,7 @@ export function GameEntryWizard({ gameId, gameVersionId, editEntry, platformSupp
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Navigation Buttons */}
|
{/* Navigation Buttons */}
|
||||||
{currentStep < 5 && (
|
{currentStep < 4 && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Shield, ShieldCheck, ShieldX, ShieldQuestion } from "lucide-react"
|
import { useState, useEffect } from "react"
|
||||||
|
import { Shield, ShieldCheck, ShieldX, ShieldQuestion, Pencil } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export type AntiCheatData = {
|
||||||
|
antiCheatRelevant: boolean
|
||||||
|
antiCheatName: string
|
||||||
|
antiCheatStatus: "none" | "supported" | "unsupported" | "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
interface PlatformSupportEntry {
|
interface PlatformSupportEntry {
|
||||||
hardwareSlug: string
|
hardwareSlug: string
|
||||||
antiCheatRelevant: boolean
|
antiCheatRelevant: boolean
|
||||||
@@ -13,6 +20,8 @@ interface PlatformSupportEntry {
|
|||||||
interface AntiCheatStepProps {
|
interface AntiCheatStepProps {
|
||||||
hardwareSlug: string
|
hardwareSlug: string
|
||||||
platformSupport: PlatformSupportEntry[]
|
platformSupport: PlatformSupportEntry[]
|
||||||
|
value: AntiCheatData
|
||||||
|
onChange: (data: AntiCheatData) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
@@ -21,94 +30,198 @@ const statusConfig = {
|
|||||||
label: "Supported",
|
label: "Supported",
|
||||||
color: "border-green-500/30 bg-green-500/10",
|
color: "border-green-500/30 bg-green-500/10",
|
||||||
textColor: "text-green-400",
|
textColor: "text-green-400",
|
||||||
message:
|
message: "Anti-cheat works on Linux/SteamOS. Multiplayer should work.",
|
||||||
"This game's anti-cheat supports Linux/SteamOS. Multiplayer should work.",
|
|
||||||
},
|
},
|
||||||
unsupported: {
|
unsupported: {
|
||||||
icon: ShieldX,
|
icon: ShieldX,
|
||||||
label: "Unsupported",
|
label: "Unsupported",
|
||||||
color: "border-red-500/30 bg-red-500/10",
|
color: "border-red-500/30 bg-red-500/10",
|
||||||
textColor: "text-red-400",
|
textColor: "text-red-400",
|
||||||
message:
|
message: "Anti-cheat does not support Linux/SteamOS. Multiplayer may not work.",
|
||||||
"This game's anti-cheat does not support Linux/SteamOS. Multiplayer may not work.",
|
|
||||||
},
|
},
|
||||||
unknown: {
|
unknown: {
|
||||||
icon: ShieldQuestion,
|
icon: ShieldQuestion,
|
||||||
label: "Unknown",
|
label: "Unknown",
|
||||||
color: "border-yellow-500/30 bg-yellow-500/10",
|
color: "border-yellow-500/30 bg-yellow-500/10",
|
||||||
textColor: "text-yellow-400",
|
textColor: "text-yellow-400",
|
||||||
message:
|
message: "Compatibility is unknown. Multiplayer may or may not work.",
|
||||||
"Anti-cheat compatibility is unknown. Multiplayer may or may not work.",
|
|
||||||
},
|
},
|
||||||
none: {
|
none: {
|
||||||
icon: Shield,
|
icon: Shield,
|
||||||
label: "None",
|
label: "None",
|
||||||
color: "border-zinc-500/30 bg-zinc-500/10",
|
color: "border-zinc-500/30 bg-zinc-500/10",
|
||||||
textColor: "text-zinc-400",
|
textColor: "text-zinc-400",
|
||||||
message: "No anti-cheat detected for this game.",
|
message: "No anti-cheat detected.",
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export function AntiCheatStep({
|
export function AntiCheatStep({
|
||||||
hardwareSlug,
|
hardwareSlug,
|
||||||
platformSupport,
|
platformSupport,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
}: AntiCheatStepProps) {
|
}: AntiCheatStepProps) {
|
||||||
// Anti-cheat is a game-level property, not device-specific.
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
// Find the first entry with anti-cheat info (any device).
|
|
||||||
const antiCheatEntry = platformSupport.find((p) => p.antiCheatRelevant)
|
|
||||||
|
|
||||||
if (!antiCheatEntry) {
|
// Find the best existing entry to prefill:
|
||||||
return (
|
// Prefer the entry for the currently selected hardware,
|
||||||
<div className="space-y-6">
|
// otherwise fall back to any entry with anti-cheat data.
|
||||||
<div className="flex items-start gap-3">
|
const hardwareEntry = platformSupport.find(
|
||||||
<Shield className="h-4 w-4 text-text mt-0.5 flex-shrink-0" />
|
(p) => p.hardwareSlug === hardwareSlug && p.antiCheatRelevant
|
||||||
<div>
|
)
|
||||||
<h3 className="text-sm font-semibold text-text">
|
const anyEntry = platformSupport.find((p) => p.antiCheatRelevant)
|
||||||
Anti-Cheat Status
|
const existingEntry = hardwareEntry ?? anyEntry
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-text/60 mt-1">
|
|
||||||
Check the anti-cheat compatibility before submitting.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border border-zinc-500/30 bg-zinc-500/10 p-4">
|
// Prefill once when the component mounts if the current value is the default
|
||||||
<div className="flex items-center gap-2">
|
useEffect(() => {
|
||||||
<Shield className="h-5 w-5 text-zinc-400" />
|
if (existingEntry && !isEditing) {
|
||||||
<h4 className="font-medium">No Anti-Cheat</h4>
|
onChange({
|
||||||
</div>
|
antiCheatRelevant: existingEntry.antiCheatRelevant,
|
||||||
<p className="mt-2 text-sm text-zinc-400">
|
antiCheatName: existingEntry.antiCheatName ?? "",
|
||||||
This game does not use anti-cheat software. Multiplayer (if
|
antiCheatStatus: existingEntry.antiCheatStatus,
|
||||||
available) should work without issues.
|
})
|
||||||
</p>
|
}
|
||||||
</div>
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
</div>
|
}, [hardwareSlug]) // re-prefill when hardware changes
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = statusConfig[antiCheatEntry.antiCheatStatus]
|
const relevant = value.antiCheatRelevant
|
||||||
const Icon = config.icon
|
const currentConfig = statusConfig[value.antiCheatStatus]
|
||||||
|
const CurrentIcon = currentConfig.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Shield className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
|
<Shield className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-text">Anti-Cheat Status</h3>
|
<h3 className="text-sm font-semibold text-text">Anti-Cheat</h3>
|
||||||
<p className="text-xs text-text/60 mt-1">
|
<p className="text-xs text-text/60 mt-1">
|
||||||
Check the anti-cheat compatibility before submitting.
|
Set the anti-cheat status for this game. This helps others know if multiplayer will work.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn("rounded-lg border p-4", config.color)}>
|
{/* Toggle: Has anti-cheat? */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
antiCheatRelevant: !relevant,
|
||||||
|
antiCheatStatus: !relevant ? "unknown" : "none",
|
||||||
|
antiCheatName: !relevant ? value.antiCheatName : "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
|
||||||
|
relevant ? "bg-primary" : "bg-text/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
|
||||||
|
relevant ? "translate-x-6" : "translate-x-1"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm text-text">Game uses anti-cheat</span>
|
||||||
|
<span className="text-xs text-text/50">
|
||||||
|
{relevant
|
||||||
|
? "Yes — select the anti-cheat name and compatibility below"
|
||||||
|
: "No anti-cheat software detected in this game"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{relevant && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Anti-cheat name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text/70 mb-1.5">
|
||||||
|
Anti-Cheat Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value.antiCheatName}
|
||||||
|
onChange={(e) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status radios */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text/70 mb-2">
|
||||||
|
Compatibility on Linux / SteamOS
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
"supported",
|
||||||
|
"unsupported",
|
||||||
|
"unknown",
|
||||||
|
] as const
|
||||||
|
).map((status) => {
|
||||||
|
const cfg = statusConfig[status]
|
||||||
|
const Icon = cfg.icon
|
||||||
|
const active = value.antiCheatStatus === status
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onChange({ ...value, antiCheatStatus: status })
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-2.5 rounded-lg border text-left transition-colors",
|
||||||
|
active
|
||||||
|
? cfg.color
|
||||||
|
: "border-border bg-text/[0.02] hover:bg-text/5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 shrink-0",
|
||||||
|
active ? cfg.textColor : "text-text/30"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-medium",
|
||||||
|
active ? cfg.textColor : "text-text/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preview card */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border p-4",
|
||||||
|
currentConfig.color
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon className={cn("h-5 w-5", config.textColor)} />
|
<CurrentIcon className={cn("h-5 w-5", currentConfig.textColor)} />
|
||||||
<h4 className="font-medium">
|
<h4 className="font-medium text-sm">
|
||||||
Anti-Cheat: {antiCheatEntry.antiCheatName || config.label}
|
{relevant && value.antiCheatName
|
||||||
|
? value.antiCheatName
|
||||||
|
: currentConfig.label}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<p className={cn("mt-2 text-sm", config.textColor)}>{config.message}</p>
|
<p className={cn("mt-2 text-sm", currentConfig.textColor)}>
|
||||||
|
{currentConfig.message}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { motion } from "motion/react"
|
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 { TiptapEditor } from "@/components/tiptap-editor"
|
||||||
import type { SettingCategory } from "@/components/wizard/settings-editor"
|
import type { SettingCategory } from "@/components/wizard/settings-editor"
|
||||||
import type { PerformanceData } from "./performance-step"
|
import type { PerformanceData } from "./performance-step"
|
||||||
@@ -11,6 +11,12 @@ import { UPSCALER_TYPE_OPTIONS, FRAME_GEN_OPTIONS } from "./environment-step"
|
|||||||
export interface ReviewData {
|
export interface ReviewData {
|
||||||
hardwareSlug: string
|
hardwareSlug: string
|
||||||
hardwareName: string
|
hardwareName: string
|
||||||
|
gameVersionLabel: string
|
||||||
|
antiCheat: {
|
||||||
|
antiCheatRelevant: boolean
|
||||||
|
antiCheatName: string
|
||||||
|
antiCheatStatus: "none" | "supported" | "unsupported" | "unknown"
|
||||||
|
}
|
||||||
performance: PerformanceData
|
performance: PerformanceData
|
||||||
settings: SettingCategory[]
|
settings: SettingCategory[]
|
||||||
environment: EnvironmentData
|
environment: EnvironmentData
|
||||||
@@ -56,7 +62,7 @@ export function ReviewStep({
|
|||||||
isSubmitting,
|
isSubmitting,
|
||||||
error,
|
error,
|
||||||
}: ReviewStepProps) {
|
}: ReviewStepProps) {
|
||||||
const { hardwareName, performance, environment, settings } = data
|
const { hardwareName, gameVersionLabel, antiCheat, performance, environment, settings } = data
|
||||||
|
|
||||||
const upscalerLabel = (() => {
|
const upscalerLabel = (() => {
|
||||||
if (!data.environment.upscalerType || data.environment.upscalerType === "none") return "None"
|
if (!data.environment.upscalerType || data.environment.upscalerType === "none") return "None"
|
||||||
@@ -85,10 +91,24 @@ export function ReviewStep({
|
|||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{/* Hardware */}
|
{/* Setup: Hardware + Version + Anti-Cheat */}
|
||||||
<div className="rounded-lg border border-border bg-text/5 p-4">
|
<div className="rounded-lg border border-border bg-text/5 p-4">
|
||||||
<SectionHeader icon={Monitor} label="Hardware" />
|
<SectionHeader icon={Monitor} label="Setup" />
|
||||||
<SummaryRow label="Device" value={hardwareName || data.hardwareSlug || "Not selected"} />
|
<SummaryRow label="Device" value={hardwareName || data.hardwareSlug || "Not selected"} />
|
||||||
|
<SummaryRow label="Game Version" value={gameVersionLabel} />
|
||||||
|
{antiCheat.antiCheatRelevant && (
|
||||||
|
<>
|
||||||
|
<SummaryRow label="Anti-Cheat" value={antiCheat.antiCheatName || "Unknown"} />
|
||||||
|
<SummaryRow label="Anti-Cheat Status" value={
|
||||||
|
antiCheat.antiCheatStatus === "supported" ? "Supported" :
|
||||||
|
antiCheat.antiCheatStatus === "unsupported" ? "Unsupported" :
|
||||||
|
antiCheat.antiCheatStatus === "unknown" ? "Unknown" : "None"
|
||||||
|
} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!antiCheat.antiCheatRelevant && (
|
||||||
|
<SummaryRow label="Anti-Cheat" value="None" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Performance */}
|
{/* Performance */}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Hardware Section */}
|
||||||
|
<section>
|
||||||
|
<HardwareStep value={hardwareSlug} onChange={onHardwareChange} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
|
||||||
|
{/* Game Version Section */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<GitBranch className="h-4 w-4 text-primary" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-text">Game Version</h3>
|
||||||
|
<p className="text-xs text-text/60">
|
||||||
|
Which version of the game did you test? This helps others know if benchmarks match their version.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-text/60">Version</label>
|
||||||
|
<select
|
||||||
|
value={selectedVersionId}
|
||||||
|
onChange={(e) => onVersionChange(e.target.value)}
|
||||||
|
className="w-full appearance-none px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{gameVersions.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.versionString
|
||||||
|
? v.versionString
|
||||||
|
: v.buildId
|
||||||
|
? `Build ${v.buildId}`
|
||||||
|
: "Unknown version"}
|
||||||
|
{v.isLatest ? " (latest)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="__new__">
|
||||||
|
+ New version...
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isNewVersion && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-text/60">
|
||||||
|
Version String <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newVersionString}
|
||||||
|
onChange={(e) => 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}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-text/40">
|
||||||
|
Enter the game version you tested. This will create a new version entry.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
|
||||||
|
{/* Anti-Cheat Section */}
|
||||||
|
<section>
|
||||||
|
<AntiCheatStep
|
||||||
|
hardwareSlug={hardwareSlug}
|
||||||
|
platformSupport={platformSupport}
|
||||||
|
value={antiCheat}
|
||||||
|
onChange={onAntiCheatChange}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
user,
|
user,
|
||||||
} from "@/lib/db/schema"
|
} from "@/lib/db/schema"
|
||||||
import { eq, desc, sql, and, ilike, isNull, isNotNull, or } from "drizzle-orm"
|
import { eq, desc, sql, and, ilike, isNull, isNotNull, or } from "drizzle-orm"
|
||||||
|
import { fuzzySearchTerm } from "@/lib/db/search"
|
||||||
import {
|
import {
|
||||||
requireContributorOrAdmin,
|
requireContributorOrAdmin,
|
||||||
requireAdmin,
|
requireAdmin,
|
||||||
@@ -47,7 +48,7 @@ export const adminPerformanceRoutes = new Elysia({ prefix: "/admin" })
|
|||||||
conditions.push(
|
conditions.push(
|
||||||
or(
|
or(
|
||||||
ilike(user.name, `%${searchTerm}%`),
|
ilike(user.name, `%${searchTerm}%`),
|
||||||
ilike(games.title, `%${searchTerm}%`),
|
ilike(games.title, fuzzySearchTerm(searchTerm)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
or,
|
or,
|
||||||
} from "drizzle-orm"
|
} from "drizzle-orm"
|
||||||
import { requireRole } from "@/lib/auth/guard"
|
import { requireRole } from "@/lib/auth/guard"
|
||||||
|
import { fuzzySearchTerm } from "@/lib/db/search"
|
||||||
|
|
||||||
/** Which columns are text-searchable via ilike */
|
/** Which columns are text-searchable via ilike */
|
||||||
export type CrudSearchConfig = {
|
export type CrudSearchConfig = {
|
||||||
@@ -94,10 +95,15 @@ export function createCrudRoutes<T extends AnyPgTable>(
|
|||||||
|
|
||||||
// Search
|
// Search
|
||||||
if (query.search && search) {
|
if (query.search && search) {
|
||||||
|
const searchStr = query.search
|
||||||
const searchConditions = search.fields
|
const searchConditions = search.fields
|
||||||
.map((field) => {
|
.map((field) => {
|
||||||
const col = columns[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[]
|
.filter(Boolean) as SQL[]
|
||||||
if (searchConditions.length > 0) {
|
if (searchConditions.length > 0) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
hardware,
|
hardware,
|
||||||
} from "@/lib/db/schema"
|
} from "@/lib/db/schema"
|
||||||
import { ilike, or, sql, eq, and, desc, asc, inArray, gte, lte } from "drizzle-orm"
|
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 MAX_OFFSET = 10000
|
||||||
const PAGE_SIZE = 24
|
const PAGE_SIZE = 24
|
||||||
@@ -39,10 +40,11 @@ export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
|
|||||||
|
|
||||||
// Search filter (title, developer, publisher)
|
// Search filter (title, developer, publisher)
|
||||||
if (search) {
|
if (search) {
|
||||||
|
const titleTerm = fuzzySearchTerm(search)
|
||||||
const term = `%${search}%`
|
const term = `%${search}%`
|
||||||
conditions.push(
|
conditions.push(
|
||||||
or(
|
or(
|
||||||
ilike(games.title, term),
|
ilike(games.title, titleTerm),
|
||||||
ilike(games.developer, term),
|
ilike(games.developer, term),
|
||||||
ilike(games.publisher, term),
|
ilike(games.publisher, term),
|
||||||
)!,
|
)!,
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import {
|
|||||||
performanceEntries,
|
performanceEntries,
|
||||||
gameVersions,
|
gameVersions,
|
||||||
hardware,
|
hardware,
|
||||||
|
gamePlatformSupport,
|
||||||
} from "@/lib/db/schema"
|
} from "@/lib/db/schema"
|
||||||
import { eq, sql } from "drizzle-orm"
|
import { eq, and, sql } from "drizzle-orm"
|
||||||
import { requireRole } from "@/lib/auth/guard"
|
import { requireRole } from "@/lib/auth/guard"
|
||||||
import { recalculatePlayability } from "./playability"
|
import { recalculatePlayability } from "./playability"
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ export const performanceSubmitRoutes = new Elysia({ prefix: "/performance" })
|
|||||||
|
|
||||||
// Verify the game version exists
|
// Verify the game version exists
|
||||||
const [version] = await db
|
const [version] = await db
|
||||||
.select({ id: gameVersions.id })
|
.select({ id: gameVersions.id, gameId: gameVersions.gameId })
|
||||||
.from(gameVersions)
|
.from(gameVersions)
|
||||||
.where(eq(gameVersions.id, body.versionId))
|
.where(eq(gameVersions.id, body.versionId))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
@@ -120,18 +121,48 @@ export const performanceSubmitRoutes = new Elysia({ prefix: "/performance" })
|
|||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
// Recalculate playability for this game (fire and forget)
|
// Update or create gamePlatformSupport with anti-cheat info
|
||||||
const [gameVersion] = await db
|
const [existingSupport] = await db
|
||||||
.select({ gameId: gameVersions.gameId })
|
.select()
|
||||||
.from(gameVersions)
|
.from(gamePlatformSupport)
|
||||||
.where(eq(gameVersions.id, body.versionId))
|
.where(
|
||||||
.limit(1)
|
and(
|
||||||
if (gameVersion) {
|
eq(gamePlatformSupport.gameId, version.gameId),
|
||||||
recalculatePlayability(gameVersion.gameId).catch((err) =>
|
eq(gamePlatformSupport.hardwareSlug, body.hardwareSlug),
|
||||||
console.error("Failed to recalculate playability:", err),
|
),
|
||||||
)
|
)
|
||||||
|
.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
|
set.status = 201
|
||||||
return {
|
return {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
@@ -190,6 +221,16 @@ export const performanceSubmitRoutes = new Elysia({ prefix: "/performance" })
|
|||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
userNotes: t.Optional(t.Union([t.String(), 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"),
|
||||||
|
]),
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+87
-9
@@ -1,6 +1,6 @@
|
|||||||
import { Elysia, t } from "elysia"
|
import { Elysia, t } from "elysia"
|
||||||
import { createCrudRoutes } from "./crud-builder"
|
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 { db } from "@/lib/db/index"
|
||||||
import { eq, and, desc, sql } from "drizzle-orm"
|
import { eq, and, desc, sql } from "drizzle-orm"
|
||||||
import { requireRole } from "@/lib/auth/guard"
|
import { requireRole } from "@/lib/auth/guard"
|
||||||
@@ -268,10 +268,10 @@ export const performanceVerifyRoutes = new Elysia({
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.fpsAvg !== undefined) updateData.fpsAvg = body.fpsAvg
|
if (body.fpsAvg !== undefined) updateData.fpsAvg = body.fpsAvg ?? undefined
|
||||||
if (body.fpsOnePercentLow !== undefined) updateData.fpsOnePercentLow = body.fpsOnePercentLow
|
if (body.fpsOnePercentLow !== undefined) updateData.fpsOnePercentLow = body.fpsOnePercentLow ?? undefined
|
||||||
if (body.fpsLow !== undefined) updateData.fpsLow = body.fpsLow
|
if (body.fpsLow !== undefined) updateData.fpsLow = body.fpsLow ?? undefined
|
||||||
if (body.fpsHigh !== undefined) updateData.fpsHigh = body.fpsHigh
|
if (body.fpsHigh !== undefined) updateData.fpsHigh = body.fpsHigh ?? undefined
|
||||||
if (body.protonVersion !== undefined)
|
if (body.protonVersion !== undefined)
|
||||||
updateData.protonVersion = body.protonVersion
|
updateData.protonVersion = body.protonVersion
|
||||||
if (body.osVersion !== undefined)
|
if (body.osVersion !== undefined)
|
||||||
@@ -295,15 +295,82 @@ export const performanceVerifyRoutes = new Elysia({
|
|||||||
.where(eq(performanceEntries.id, params.id))
|
.where(eq(performanceEntries.id, params.id))
|
||||||
.returning()
|
.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
|
return updated
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
params: t.Object({ id: t.String() }),
|
params: t.Object({ id: t.String() }),
|
||||||
body: t.Object({
|
body: t.Object({
|
||||||
fpsAvg: t.Optional(t.Number()),
|
fpsAvg: t.Optional(t.Union([t.Number(), t.Null()])),
|
||||||
fpsOnePercentLow: t.Optional(t.Number()),
|
fpsOnePercentLow: t.Optional(t.Union([t.Number(), t.Null()])),
|
||||||
fpsLow: t.Optional(t.Number()),
|
fpsLow: t.Optional(t.Union([t.Number(), t.Null()])),
|
||||||
fpsHigh: t.Optional(t.Number()),
|
fpsHigh: t.Optional(t.Union([t.Number(), t.Null()])),
|
||||||
protonVersion: t.Optional(t.Union([t.String(), t.Null()])),
|
protonVersion: t.Optional(t.Union([t.String(), t.Null()])),
|
||||||
osVersion: t.Optional(t.Union([t.String(), t.Null()])),
|
osVersion: t.Optional(t.Union([t.String(), t.Null()])),
|
||||||
upscalerType: t.Optional(
|
upscalerType: t.Optional(
|
||||||
@@ -331,6 +398,17 @@ export const performanceVerifyRoutes = new Elysia({
|
|||||||
launchOptions: t.Optional(t.Union([t.String(), t.Null()])),
|
launchOptions: t.Optional(t.Union([t.String(), t.Null()])),
|
||||||
settingsJson: t.Optional(t.Union([t.Array(t.Any()), t.Null()])),
|
settingsJson: t.Optional(t.Union([t.Array(t.Any()), t.Null()])),
|
||||||
userNotes: t.Optional(t.Union([t.String(), 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(),
|
||||||
|
]),
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
gameComments,
|
gameComments,
|
||||||
} from "@/lib/db/schema"
|
} from "@/lib/db/schema"
|
||||||
import { ilike, or, sql, eq, inArray, and } from "drizzle-orm"
|
import { ilike, or, sql, eq, inArray, and } from "drizzle-orm"
|
||||||
|
import { fuzzySearchTerm } from "@/lib/db/search"
|
||||||
|
|
||||||
interface SteamSearchItem {
|
interface SteamSearchItem {
|
||||||
id: number
|
id: number
|
||||||
@@ -31,6 +32,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
|
|||||||
return { error: "Query must be at least 2 characters" }
|
return { error: "Query must be at least 2 characters" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const titleTerm = fuzzySearchTerm(query.q)
|
||||||
const term = `%${query.q}%`
|
const term = `%${query.q}%`
|
||||||
|
|
||||||
// ── 1. Search local database ────────────────────────────────────
|
// ── 1. Search local database ────────────────────────────────────
|
||||||
@@ -39,7 +41,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
|
|||||||
.from(games)
|
.from(games)
|
||||||
.where(
|
.where(
|
||||||
or(
|
or(
|
||||||
ilike(games.title, term),
|
ilike(games.title, titleTerm),
|
||||||
ilike(games.developer, term),
|
ilike(games.developer, term),
|
||||||
ilike(games.publisher, term),
|
ilike(games.publisher, term),
|
||||||
),
|
),
|
||||||
@@ -281,6 +283,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
|
|||||||
genres: g.genres,
|
genres: g.genres,
|
||||||
source: g.source,
|
source: g.source,
|
||||||
counts,
|
counts,
|
||||||
|
platforms: g.platforms,
|
||||||
platformSupport: platform
|
platformSupport: platform
|
||||||
? {
|
? {
|
||||||
isSupported: platform.isSupported,
|
isSupported: platform.isSupported,
|
||||||
@@ -289,7 +292,19 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
|
|||||||
antiCheatName: platform.antiCheatName,
|
antiCheatName: platform.antiCheatName,
|
||||||
antiCheatStatus: platform.antiCheatStatus,
|
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,
|
isRawPerformer: rawPerformerMap.get(g.id) ?? false,
|
||||||
isPoorPerformance: poorPerformerMap.get(g.id) ?? false,
|
isPoorPerformance: poorPerformerMap.get(g.id) ?? false,
|
||||||
bestFps: bestFpsMap.get(g.id) ?? null,
|
bestFps: bestFpsMap.get(g.id) ?? null,
|
||||||
|
|||||||
@@ -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}%`
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "deckyvault",
|
"name": "deckyvault",
|
||||||
"version": "2026.0.9",
|
"version": "2026.0.95",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --experimental-https",
|
"dev": "next dev --experimental-https",
|
||||||
|
|||||||
Reference in New Issue
Block a user