feat: updates page and lint fixes

This commit is contained in:
2026-05-01 10:15:13 +08:00
parent 7bcd580397
commit ca08024cb5
25 changed files with 188 additions and 314 deletions
+2 -2
View File
@@ -169,7 +169,7 @@ export function GamesClient() {
setSyncCompleted(true)
setSelectedIds(new Set())
}
} catch (e) {
} catch {
// Ignore parse errors
}
}
@@ -255,7 +255,7 @@ export function GamesClient() {
}))
setSyncCompleted(true)
}
} catch (e) {
} catch {
// Ignore parse errors
}
}
+11 -11
View File
@@ -485,18 +485,18 @@ export function GamesPageClient({
sortBy: sort,
}}
onLoad={(filters) => {
setMinFps(filters.minFps || "")
setMaxFps(filters.maxFps || "")
setFsrSupport(filters.fsrSupport || false)
setProtonNative(filters.protonNative || "any")
setAntiCheatStatus(filters.antiCheatStatus || "any")
setPlayabilityStatus(filters.playabilityStatus || "")
setSteamReviewMin(filters.steamReviewMin || "")
setIsFree(filters.isFree || false)
setHasMultiplayer(filters.hasMultiplayer || false)
if (filters.genre) setSelectedGenres([filters.genre])
setMinFps((filters.minFps as string) || "")
setMaxFps((filters.maxFps as string) || "")
setFsrSupport((filters.fsrSupport as boolean) || false)
setProtonNative((filters.protonNative as string) || "any")
setAntiCheatStatus((filters.antiCheatStatus as string) || "any")
setPlayabilityStatus((filters.playabilityStatus as string) || "")
setSteamReviewMin((filters.steamReviewMin as string) || "")
setIsFree((filters.isFree as boolean) || false)
setHasMultiplayer((filters.hasMultiplayer as boolean) || false)
if (filters.genre) setSelectedGenres([filters.genre as string])
else setSelectedGenres([])
if (filters.device) setSelectedDevice(filters.device)
if (filters.device) setSelectedDevice(filters.device as string)
else setSelectedDevice("")
if (filters.sortBy) setSort(filters.sortBy as SortOption)
}}
+7 -149
View File
@@ -1,4 +1,5 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@theme inline {
/* Colors */
@@ -9,158 +10,15 @@
--color-accent: #fb793c;
--color-text: #ebe4f1;
--color-border: color-mix(in srgb, var(--color-text) 20%, transparent);
--color-border-active: color-mix(in srgb, var(--color-text) 60%, transparent);
--color-border-active: color-mix(
in srgb,
var(--color-text) 60%,
transparent
);
/* Defaults */
--default-transition-duration: 0.3s;
/* Fonts */
--font-sans: var(--font-lexend);
}
.scrollbar-hide {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Markdown content styles */
.update-content {
color: var(--color-text);
}
.update-content h2 {
font-size: 1.5rem;
font-weight: 700;
margin-top: 2.5rem;
margin-bottom: 1rem;
color: var(--color-text);
}
.update-content h3 {
font-size: 1.125rem;
font-weight: 600;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
color: var(--color-text);
}
.update-content p {
margin-bottom: 0.75rem;
line-height: 1.7;
color: rgba(235, 228, 241, 0.8);
}
.update-content ul {
list-style-type: disc;
padding-left: 1.5rem;
margin-bottom: 1rem;
}
.update-content ol {
list-style-type: decimal;
padding-left: 1.5rem;
margin-bottom: 1rem;
}
.update-content li {
margin-bottom: 0.375rem;
line-height: 1.6;
}
.update-content li > ul,
.update-content li > ol {
margin-top: 0.25rem;
margin-bottom: 0.5rem;
}
.update-content a {
color: var(--color-primary);
text-decoration: none;
transition: opacity 0.2s;
}
.update-content a:hover {
text-decoration: underline;
opacity: 0.8;
}
/* Hide anchor link icons from rehype-autolink-headings by default */
.update-content a[aria-hidden="true"] {
text-decoration: none;
margin-right: 0.375rem;
opacity: 0;
transition: opacity 0.2s;
}
.update-content h2:hover a[aria-hidden="true"],
.update-content h3:hover a[aria-hidden="true"] {
opacity: 0.4;
}
.update-content a[aria-hidden="true"]::before {
content: "#";
font-size: 0.75rem;
font-weight: 400;
}
.update-content code {
color: var(--color-accent);
background: rgba(235, 228, 241, 0.06);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.update-content pre {
background: rgba(235, 228, 241, 0.06);
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin-bottom: 1rem;
}
.update-content pre code {
background: transparent;
padding: 0;
}
.update-content strong {
font-weight: 700;
color: var(--color-text);
}
.update-content hr {
border: none;
border-top: 1px solid var(--color-border);
margin: 2rem 0;
}
.update-content blockquote {
border-left: 3px solid var(--color-primary);
padding-left: 1rem;
margin: 1rem 0;
color: rgba(235, 228, 241, 0.7);
font-style: italic;
}
.update-content table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
.update-content th,
.update-content td {
border: 1px solid var(--color-border);
padding: 0.5rem 0.75rem;
text-align: left;
}
.update-content th {
font-weight: 600;
background: rgba(235, 228, 241, 0.05);
}
}
+41 -24
View File
@@ -33,16 +33,18 @@ export default function Landing() {
return (
<section
id="hero"
className="w-full h-[calc(100vh-3.6rem)] flex flex-col items-center justify-center relative p-4"
id='hero'
className='w-full h-[calc(100vh-3.6rem)] flex flex-col items-center justify-center relative p-4'
>
<motion.h1
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="font-bold text-3xl md:text-5xl text-center flex flex-row gap-2 items-center justify-center"
className='font-bold text-3xl md:text-5xl text-center flex flex-row gap-2 items-center justify-center'
>
DeckyVault
<span className="border border-border text-xs md:text-base px-2 py-1 rounded-md bg-primary/10 font-medium">beta</span>
<span className='border border-border text-xs md:text-base px-2 py-1 rounded-md bg-primary/10 font-medium'>
beta
</span>
</motion.h1>
<motion.h2
layout
@@ -50,18 +52,18 @@ export default function Landing() {
opacity: 0,
}}
animate={{ opacity: 0.8, transition: { delay: 0.5 } }}
className="mt-4 flex flex-row flex-wrap items-center justify-center gap-x-1 md:gap-x-2 text-base md:text-xl"
className='mt-4 flex flex-row flex-wrap items-center justify-center gap-x-1 md:gap-x-2 text-base md:text-xl'
>
{"Find your game".split(" ").map((word, index) => (
<motion.span
key={index}
className="text-center"
className='text-center'
>
{word}
</motion.span>
))}
<AnimatePresence
mode="wait"
mode='wait'
initial={false}
>
<motion.span
@@ -69,7 +71,7 @@ export default function Landing() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="text-primary font-bold"
className='text-primary font-bold'
>
{words[currentWord]}
</motion.span>
@@ -77,27 +79,27 @@ export default function Landing() {
</motion.h2>
<AnimatePresence>
<motion.label
key="search-bar"
layoutId="search-bar"
key='search-bar'
layoutId='search-bar'
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-8 flex flex-row items-center gap-4 bg-text/5 px-2 py-2 rounded-md border border-border placeholder:text-text/60 group hover:border-border-active transition-colors focus-within:border-primary/80! focus-within:ring-2 focus-within:ring-primary/50! focus-within:ring-offset-2 focus-within:ring-offset-background cursor-text w-full max-w-md"
className='mt-8 flex flex-row items-center gap-4 bg-text/5 px-2 py-2 rounded-md border border-border placeholder:text-text/60 group hover:border-border-active transition-colors focus-within:border-primary/80! focus-within:ring-2 focus-within:ring-primary/50! focus-within:ring-offset-2 focus-within:ring-offset-background cursor-text w-full max-w-md'
>
<Gamepad2Icon className="h-6 w-6 group-focus-within:stroke-accent transition-colors shrink-0" />
<Gamepad2Icon className='h-6 w-6 group-focus-within:stroke-accent transition-colors shrink-0' />
<input
type="text"
placeholder="search by game or appid..."
type='text'
placeholder='search by game or appid...'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 outline-none bg-transparent text-xl min-w-0"
className='flex-1 outline-none bg-transparent text-xl min-w-0'
/>
<motion.button
onClick={handleSearchSubmit}
whileTap={{ scale: 0.95 }}
className="text-sm flex flex-row gap-1 items-center bg-text text-background px-2 py-1 rounded-sm cursor-pointer hover:opacity-60 transition-opacity shrink-0"
className='text-sm flex flex-row gap-1 items-center bg-text text-background px-2 py-1 rounded-sm cursor-pointer hover:opacity-60 transition-opacity shrink-0'
>
<SearchIcon className="h-3 w-3" />
<SearchIcon className='h-3 w-3' />
search
</motion.button>
</motion.label>
@@ -105,26 +107,41 @@ export default function Landing() {
<motion.small
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 1.5 } }}
className="mt-8 text-center text-xs flex flex-row gap-1"
className='mt-8 text-center text-xs flex flex-row gap-1'
>
<span className="opacity-60">2026 DeckyVault.</span>
<Link
title="Visit our Github Repository"
href="https://github.com/AdrianBonpin/deckyvault"
className="text-accent opacity-60 hover:opacity-100 transition-opacity cursor-pointer"
title='Visit our Github Repository'
href='/updates'
className='text-accent opacity-60 hover:opacity-100 transition-opacity cursor-pointer'
>
See what{"'"}s new.
</Link>
</motion.small>
<motion.small
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 1.5 } }}
className='mt-2 text-center text-xs flex flex-row gap-1'
>
<span className='opacity-60'>2026 DeckyVault.</span>
<Link
title='Visit our Github Repository'
href='https://github.com/AdrianBonpin/deckyvault'
className='text-accent opacity-60 hover:opacity-100 transition-opacity cursor-pointer'
>
Github.
</Link>
<span className="opacity-60">v{process.env.NEXT_PUBLIC_APP_VERSION}</span>
</motion.small>
<script
type="application/ld+json"
type='application/ld+json'
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "WebSite",
name: "DeckyVault",
url: "https://deckyvault.xyz",
description: "Steam Deck benchmarks, settings, and performance guides",
description:
"Steam Deck benchmarks, settings, and performance guides",
potentialAction: {
"@type": "SearchAction",
target: "https://deckyvault.xyz/search?q={search_term_string}",
+1 -1
View File
@@ -43,7 +43,7 @@ export default async function UpdatePage({
return (
<main className="w-full">
<div className="w-full max-w-4xl mx-auto px-4 pt-4">
<div className="w-full max-w-7xl mx-auto px-4 pt-4">
<Link
href="/updates"
className="inline-flex items-center gap-1 text-sm text-text/60 hover:text-primary transition-colors"
+34 -28
View File
@@ -5,36 +5,42 @@ import { UpdateCard } from "@/components/updates/update-card"
import { ArrowLeftIcon } from "lucide-react"
export const metadata: Metadata = {
title: "Updates",
description: "See what's new in DeckyVault — version release notes, features, and improvements.",
title: "Updates",
description:
"See what's new in DeckyVault — version release notes, features, and improvements.",
}
export default function UpdatesPage() {
const updates = getAllUpdates()
const updates = getAllUpdates()
return (
<main className="w-full max-w-2xl mx-auto px-4 py-8">
<div className="flex flex-row items-center gap-2 mb-6">
<Link
href="/"
className="text-text/60 hover:text-primary transition-colors"
>
<ArrowLeftIcon className="w-4 h-4" />
</Link>
<h1 className="text-2xl font-bold">Updates</h1>
</div>
<p className="text-text/60 mb-8">
Release notes and changelogs for every DeckyVault version.
</p>
{updates.length === 0 ? (
<p className="text-text/40 text-center py-16">No updates yet.</p>
) : (
<div className="flex flex-col gap-3">
{updates.map((update) => (
<UpdateCard key={update.slug} update={update} />
))}
</div>
)}
</main>
)
return (
<main className='w-full max-w-7xl mx-auto px-4 py-8'>
<div className='flex flex-row items-center gap-2 mb-6'>
<Link
href='/'
className='text-text/60 hover:text-primary transition-colors'
>
<ArrowLeftIcon className='w-4 h-4' />
</Link>
<h1 className='text-2xl font-bold'>Updates</h1>
</div>
<p className='text-text/60 mb-8'>
Release notes, changelogs, and the like for DeckyVault.
</p>
{updates.length === 0 ? (
<p className='text-text/40 text-center py-16'>
No updates yet.
</p>
) : (
<div className='flex flex-col gap-3'>
{updates.map((update) => (
<UpdateCard
key={update.slug}
update={update}
/>
))}
</div>
)}
</main>
)
}
+9
View File
@@ -39,6 +39,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
@@ -599,6 +600,8 @@
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.4", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "postcss": "^8.5.6", "tailwindcss": "4.2.4" } }, "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg=="],
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
"@tiptap/core": ["@tiptap/core@3.22.4", "", { "peerDependencies": { "@tiptap/pm": "3.22.4" } }, "sha512-vGIGm/HpqLg8EAAQXQ+koV+/S828OEpzocfWcPOwo1u2QUVf9dQG47Yy6JJ8zFFaJwfv4dBcOXli+7BrJwsxDQ=="],
"@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-7/61kNPbGFhMgM//zMknD0pSb69rGdRIkpulXOWS1JBrFHkH6hjZDfrOETNzgKkO+NlmzVl9rXSTv0xauS3lzA=="],
@@ -867,6 +870,8 @@
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
@@ -1385,6 +1390,8 @@
"postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
@@ -1639,6 +1646,8 @@
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
+1
View File
@@ -31,6 +31,7 @@ export function CommentSection({ gameId, initialCount }: CommentSectionProps) {
const limit = 20
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true)
}, [])
+4 -4
View File
@@ -1,18 +1,18 @@
"use client";
import { useState, useEffect } from "react";
import { Bookmark, Plus, Trash2, Check } from "lucide-react";
import { Bookmark, Plus, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
interface SavedFilter {
id: string;
name: string;
filters: Record<string, any>;
filters: Record<string, unknown>;
}
interface SavedFiltersProps {
currentFilters: Record<string, any>;
onLoad: (filters: Record<string, any>) => void;
currentFilters: Record<string, unknown>;
onLoad: (filters: Record<string, unknown>) => void;
className?: string;
}
+5 -4
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { ThumbsUp, ThumbsDown, ExternalLink, MessageSquare } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -42,7 +42,7 @@ export function SteamReviews({ gameId, steamAppId, className }: SteamReviewsProp
const [error, setError] = useState<string | null>(null);
const [offset, setOffset] = useState(0);
const fetchReviews = async (newOffset: number) => {
const fetchReviews = useCallback(async (newOffset: number) => {
setLoading(true);
setError(null);
@@ -64,11 +64,12 @@ export function SteamReviews({ gameId, steamAppId, className }: SteamReviewsProp
} finally {
setLoading(false);
}
};
}, [gameId, setData, setLoading, setError, setOffset]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchReviews(0);
}, [gameId]);
}, [gameId, fetchReviews]);
// Loading skeleton
if (loading && !data) {
+25 -21
View File
@@ -2,26 +2,30 @@ import Link from "next/link"
import type { UpdateMeta } from "@/lib/updates"
export function UpdateCard({ update }: { update: UpdateMeta }) {
const formattedDate = new Date(update.date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
const formattedDate = new Date(update.date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
return (
<Link href={`/updates/${update.slug}`}>
<article className="group flex flex-col gap-2 p-4 rounded-lg border border-border hover:border-border-active hover:bg-text/3 transition-colors">
<div className="flex flex-row items-center gap-2">
<span className="text-xs font-mono px-2 py-0.5 rounded bg-primary/10 text-primary border border-primary/20">
v{update.version}
</span>
<time className="text-xs text-text/60">{formattedDate}</time>
</div>
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors">
{update.title}
</h3>
<p className="text-sm text-text/60 line-clamp-2">{update.summary}</p>
</article>
</Link>
)
return (
<Link href={`/updates/${update.slug}`}>
<article className='group flex flex-col gap-2 p-4 border border-border rounded-md hover:bg-text/3 transition-colors'>
<div className='flex flex-row items-center gap-2'>
<span className='text-xs font-mono px-2 py-0.5 rounded bg-primary/10 text-primary border border-primary/20'>
v{update.version}
</span>
<time className='text-xs text-text/60'>
{formattedDate}
</time>
</div>
<h3 className='text-lg font-semibold group-hover:text-primary transition-colors'>
{update.title}
</h3>
<p className='text-sm text-text/60 line-clamp-2'>
{update.summary}
</p>
</article>
</Link>
)
}
+1 -1
View File
@@ -27,7 +27,7 @@ export function UpdateViewer({
</aside>
{/* Main content */}
<article className="flex-1 min-w-0">
<article className="flex-1 min-w-0 prose prose-invert lg:proxe-xl">
<header className="mb-8">
<div className="flex flex-row items-center gap-2 mb-2">
<span className="text-xs font-mono px-2 py-0.5 rounded bg-primary/10 text-primary border border-primary/20">
+2 -1
View File
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"
import { motion, AnimatePresence } from "motion/react"
import { StepIndicator } from "@/components/wizard/step-indicator"
import { SetupStep, type GameVersionInfo } from "@/components/wizard/steps/setup-step"
import { AntiCheatStep, type AntiCheatData } from "@/components/wizard/steps/anti-cheat-step"
import { 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"
@@ -69,6 +69,7 @@ export function GameEntryWizard({ gameId, gameVersions, defaultVersionId, editEn
) ?? platformSupport.find((p) => p.antiCheatRelevant)
if (entry) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setAntiCheat({
antiCheatRelevant: entry.antiCheatRelevant,
antiCheatName: entry.antiCheatName ?? "",
+2 -2
View File
@@ -1,7 +1,7 @@
"use client"
import { useState, useEffect } from "react"
import { Shield, ShieldCheck, ShieldX, ShieldQuestion, Pencil } from "lucide-react"
import { Shield, ShieldCheck, ShieldX, ShieldQuestion } from "lucide-react"
import { cn } from "@/lib/utils"
export type AntiCheatData = {
@@ -61,7 +61,7 @@ export function AntiCheatStep({
value,
onChange,
}: AntiCheatStepProps) {
const [isEditing, setIsEditing] = useState(false)
const [isEditing] = useState(false)
// Find the best existing entry to prefill:
// Prefer the entry for the currently selected hardware,
+1 -1
View File
@@ -1,7 +1,7 @@
"use client"
import { motion } from "motion/react"
import { Send, Loader2, AlertCircle, Monitor, Gauge, SlidersHorizontal, Terminal, FileText, GitBranch, Shield } from "lucide-react"
import { Send, Loader2, AlertCircle, Monitor, Gauge, SlidersHorizontal, Terminal, FileText } from "lucide-react"
import { TiptapEditor } from "@/components/tiptap-editor"
import type { SettingCategory } from "@/components/wizard/settings-editor"
import type { PerformanceData } from "./performance-step"
+4 -2
View File
@@ -33,11 +33,13 @@ interface SetupStepProps {
}
export function SetupStep({
gameId,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
gameId: _gameId,
gameVersions,
hardwareSlug,
onHardwareChange,
hardwareName,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
hardwareName: _hardwareName,
selectedVersionId,
onVersionChange,
newVersionString,
+9
View File
@@ -4,6 +4,15 @@ date: "2026-04-30"
version: "2026.0.95"
summary: "Major update adding anti-cheat tracking, Steam reviews integration, playability indicators, games list revamp, community suggestions, and more."
---
# Hello everyone!
Starting with somewhat every major release, we'll be making this quick changelog like updates page to track whats new and whats changed. Feel free to check for whats new as I'm always trying to improve the site and experience a lot more!
For now here are some of the recent updates if you have some time to read over them.
And if there are any issues/problems, feel free to visit the [contact page](https://deckyvault.xyz/contact) to submit a report or leave a review!
> Adrian Bonpin
## Added
+5 -5
View File
@@ -1,6 +1,6 @@
import { Elysia, t } from "elysia"
import { db } from "@/lib/db/index"
import { communitySuggestions, games, user } from "@/lib/db/schema"
import { communitySuggestions, games, user, suggestionStatusEnum } from "@/lib/db/schema"
import { eq, and, desc } from "drizzle-orm"
import { requireAuth, requireContributorOrAdmin } from "@/lib/auth/guard"
@@ -109,7 +109,7 @@ export const communitySuggestionRoutes = new Elysia({
return { error: "You already have a pending suggestion for this field" }
}
const currentValue = String((game as any)[fieldName] ?? "")
const currentValue = String((game as Record<string, unknown>)[fieldName] ?? "")
const [suggestion] = await db
.insert(communitySuggestions)
@@ -149,9 +149,9 @@ export const communitySuggestionRoutes = new Elysia({
async ({ params, query }) => {
const status = query.status // optional filter
let conditions = [eq(communitySuggestions.gameId, params.gameId)]
const conditions = [eq(communitySuggestions.gameId, params.gameId)]
if (status) {
conditions.push(eq(communitySuggestions.status, status as any))
conditions.push(eq(communitySuggestions.status, status as typeof suggestionStatusEnum.enumValues[number]))
}
const suggestions = await db
@@ -263,7 +263,7 @@ export const communitySuggestionRoutes = new Elysia({
// If approved, apply the change to the game
if (status === "approved") {
const updateData: Record<string, any> = {}
const updateData: Record<string, string> = {}
updateData[suggestion.fieldName] = suggestion.proposedValue
await db
+5 -5
View File
@@ -7,7 +7,7 @@ import {
gamePlatformSupport,
hardware,
} 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, type SQL } from "drizzle-orm"
import { fuzzySearchTerm } from "@/lib/db/search"
const MAX_OFFSET = 10000
@@ -92,11 +92,11 @@ export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
// ── FPS range filter (games with benchmarks in this range) ────
if (minFps || maxFps) {
const fpsConditions = [
const fpsConditions: (SQL | undefined)[] = [
eq(performanceEntries.isRemoved, false),
minFps ? gte(performanceEntries.fpsAvg, Number(minFps)) : undefined,
maxFps ? lte(performanceEntries.fpsAvg, Number(maxFps)) : undefined,
].filter(Boolean) as any[]
].filter((c): c is SQL => c !== undefined)
const fpsSubquery = db
.select({ gameId: gameVersions.gameId })
@@ -127,7 +127,7 @@ export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
// ── Proton / Native filter ────────────────────────────────────
if (protonNative && ["proton", "native", "both"].includes(protonNative)) {
const protonConditions: any[] = []
const protonConditions: SQL[] = []
if (protonNative === "proton" || protonNative === "both") {
protonConditions.push(eq(gamePlatformSupport.protonStatus, "proton"))
@@ -148,7 +148,7 @@ export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
// ── Anti-cheat status filter ──────────────────────────────────
const validAcStatuses = ["supported", "unsupported", "unknown", "none"]
if (antiCheatStatus && antiCheatStatus !== "any" && validAcStatuses.includes(antiCheatStatus)) {
const acConditions: any[] = [
const acConditions: SQL[] = [
eq(gamePlatformSupport.antiCheatRelevant, true),
eq(gamePlatformSupport.antiCheatStatus, antiCheatStatus as "none" | "supported" | "unsupported" | "unknown"),
]
+3 -42
View File
@@ -190,46 +190,7 @@ export const gameVersionsRoutes = new Elysia({ prefix: "/games/:gameId/versions"
// ── Game Sync Routes ────────────────────────────────────────────────
const MAX_BULK_SYNC = 1000
const SYNC_CONCURRENCY = 5 // Number of parallel syncs
const SYNC_BATCH_DELAY_MS = 2000 // Delay between batches to respect rate limits (2 seconds)
/**
* Process syncs sequentially with delay to avoid Steam rate limiting.
* Steam API has aggressive rate limiting - parallel requests get blocked quickly.
*/
async function syncSequentially(
gamesToSync: { id: string; steamAppId: number | null }[]
): Promise<{ synced: number; failed: number; results: Map<string, { success: boolean; error?: string }> }> {
let synced = 0
let failed = 0
const results = new Map<string, { success: boolean; error?: string }>()
for (let i = 0; i < gamesToSync.length; i++) {
const game = gamesToSync[i]
if (!game.steamAppId) continue
try {
const result = await syncSteamGame(game.steamAppId, { forceRetry: true })
if (result.success) {
synced++
results.set(game.id, { success: true })
} else {
failed++
results.set(game.id, { success: false, error: result.error })
}
} catch (e) {
failed++
results.set(game.id, { success: false, error: String(e) })
}
// Delay between syncs to avoid rate limiting
if (i < gamesToSync.length - 1) {
await new Promise((resolve) => setTimeout(resolve, SYNC_BATCH_DELAY_MS))
}
}
return { synced, failed, results }
}
export const gameSyncRoutes = new Elysia({ prefix: "/games" })
// Bulk sync with streaming progress (defined before /:gameId/sync to avoid route conflict)
@@ -285,7 +246,8 @@ export const gameSyncRoutes = new Elysia({ prefix: "/games" })
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const send = (data: any) => {
type ProgressEvent = { type: string; current?: number; total: number; synced: number; failed: number; currentGame?: string | null }
const send = (data: ProgressEvent) => {
controller.enqueue(encoder.encode(JSON.stringify(data) + "\n"))
}
@@ -294,7 +256,6 @@ export const gameSyncRoutes = new Elysia({ prefix: "/games" })
let synced = 0
let failed = 0
const batchSize = SYNC_CONCURRENCY
// Process syncs sequentially with delay to avoid rate limiting
for (let i = 0; i < gamesToSync.length; i++) {
@@ -305,7 +266,7 @@ export const gameSyncRoutes = new Elysia({ prefix: "/games" })
const result = await syncSteamGame(game.steamAppId, { forceRetry: true })
if (result.success) synced++
else failed++
} catch (e) {
} catch {
failed++
}
+7 -7
View File
@@ -1,6 +1,6 @@
import { Elysia } from "elysia";
import { db } from "@/lib/db/index";
import { games, gamePlatformSupport, performanceEntries, gameVersions } from "@/lib/db/schema";
import { games, gamePlatformSupport, performanceEntries, gameVersions, playabilityStatusEnum } from "@/lib/db/schema";
import { eq, and, avg, count, sql } from "drizzle-orm";
import { requireContributorOrAdmin } from "@/lib/auth/guard";
@@ -95,7 +95,7 @@ export async function recalculatePlayability(gameId: string): Promise<{
await db
.update(gamePlatformSupport)
.set({
playabilityStatus: status as any,
playabilityStatus: status as typeof playabilityStatusEnum.enumValues[number],
playabilityCalculatedAt: new Date(),
})
.where(
@@ -111,13 +111,13 @@ export async function recalculatePlayability(gameId: string): Promise<{
// Update aggregate game-level playability (worst of all devices)
const priority = { unplayable: 0, needs_tweaks: 1, playable: 2, great: 3, unknown: 4 };
let worstStatus: string = "unknown";
let worstStatus: typeof playabilityStatusEnum.enumValues[number] = "unknown";
for (const r of results) {
if (
priority[r.playabilityStatus as keyof typeof priority] <
priority[worstStatus as keyof typeof priority]
) {
worstStatus = r.playabilityStatus;
worstStatus = r.playabilityStatus as typeof playabilityStatusEnum.enumValues[number];
}
}
@@ -132,7 +132,7 @@ export async function recalculatePlayability(gameId: string): Promise<{
await db
.update(games)
.set({
playabilityStatus: worstStatus as any,
playabilityStatus: worstStatus as typeof playabilityStatusEnum.enumValues[number],
playabilityCalculatedAt: new Date(),
})
.where(eq(games.id, gameId));
@@ -174,7 +174,7 @@ export const playabilityRoutes = new Elysia({ prefix: "/playability" })
if (hardwareSlug) {
await db
.update(gamePlatformSupport)
.set({ playabilityStatus: status as any, playabilityOverride: true })
.set({ playabilityStatus: status as typeof playabilityStatusEnum.enumValues[number], playabilityOverride: true })
.where(
and(
eq(gamePlatformSupport.gameId, params.gameId),
@@ -184,7 +184,7 @@ export const playabilityRoutes = new Elysia({ prefix: "/playability" })
} else {
await db
.update(games)
.set({ playabilityStatus: status as any, playabilityOverride: true })
.set({ playabilityStatus: status as typeof playabilityStatusEnum.enumValues[number], playabilityOverride: true })
.where(eq(games.id, params.gameId));
}
+2 -2
View File
@@ -6,7 +6,7 @@ import { eq } from "drizzle-orm"
// In-memory cache for reviews (key -> { data, expires })
const reviewCache = new Map<
string,
{ data: any; expires: number }
{ data: SteamReviewResponse; expires: number }
>()
const CACHE_TTL = 60 * 60 * 1000 // 1 hour
@@ -100,7 +100,7 @@ export const steamReviewRoutes = new Elysia({ prefix: "/steam-reviews" })
reviewCache.set(cacheKey, { data: slicedData, expires: Date.now() + CACHE_TTL })
return slicedData
} catch (error) {
} catch {
set.status = 502
return { error: "Steam review API unavailable" }
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { db } from "@/lib/db/index"
import { games } from "@/lib/db/schema"
import { games, steamReviewSentimentEnum } from "@/lib/db/schema"
import { eq } from "drizzle-orm"
import { recalculatePlayability } from "@/lib/api/playability"
@@ -247,7 +247,7 @@ export async function syncSteamGame(
metacriticUrl: d.metacritic?.url ?? null,
recommendationsTotal: d.recommendations?.total ?? null,
steamReviewScore: reviewData.reviewScore,
steamReviewSentiment: reviewData.reviewSentiment as any,
steamReviewSentiment: reviewData.reviewSentiment as typeof steamReviewSentimentEnum.enumValues[number] | null,
steamReviewCount: reviewData.reviewCount,
priceCurrent: d.price_overview?.final ?? null,
priceInitial: d.price_overview?.initial ?? null,
+4
View File
@@ -1,6 +1,10 @@
import type { NextConfig } from "next";
import { version } from "./package.json";
const nextConfig: NextConfig = {
env: {
NEXT_PUBLIC_APP_VERSION: version,
},
// Elysia is Bun-native and must not be bundled by Next.js
serverExternalPackages: ["elysia", "@elysiajs/eden"],
images: {
+1
View File
@@ -50,6 +50,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",