feat: updates page and lint fixes
This commit is contained in:
@@ -169,7 +169,7 @@ export function GamesClient() {
|
|||||||
setSyncCompleted(true)
|
setSyncCompleted(true)
|
||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Ignore parse errors
|
// Ignore parse errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,7 +255,7 @@ export function GamesClient() {
|
|||||||
}))
|
}))
|
||||||
setSyncCompleted(true)
|
setSyncCompleted(true)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Ignore parse errors
|
// Ignore parse errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -485,18 +485,18 @@ export function GamesPageClient({
|
|||||||
sortBy: sort,
|
sortBy: sort,
|
||||||
}}
|
}}
|
||||||
onLoad={(filters) => {
|
onLoad={(filters) => {
|
||||||
setMinFps(filters.minFps || "")
|
setMinFps((filters.minFps as string) || "")
|
||||||
setMaxFps(filters.maxFps || "")
|
setMaxFps((filters.maxFps as string) || "")
|
||||||
setFsrSupport(filters.fsrSupport || false)
|
setFsrSupport((filters.fsrSupport as boolean) || false)
|
||||||
setProtonNative(filters.protonNative || "any")
|
setProtonNative((filters.protonNative as string) || "any")
|
||||||
setAntiCheatStatus(filters.antiCheatStatus || "any")
|
setAntiCheatStatus((filters.antiCheatStatus as string) || "any")
|
||||||
setPlayabilityStatus(filters.playabilityStatus || "")
|
setPlayabilityStatus((filters.playabilityStatus as string) || "")
|
||||||
setSteamReviewMin(filters.steamReviewMin || "")
|
setSteamReviewMin((filters.steamReviewMin as string) || "")
|
||||||
setIsFree(filters.isFree || false)
|
setIsFree((filters.isFree as boolean) || false)
|
||||||
setHasMultiplayer(filters.hasMultiplayer || false)
|
setHasMultiplayer((filters.hasMultiplayer as boolean) || false)
|
||||||
if (filters.genre) setSelectedGenres([filters.genre])
|
if (filters.genre) setSelectedGenres([filters.genre as string])
|
||||||
else setSelectedGenres([])
|
else setSelectedGenres([])
|
||||||
if (filters.device) setSelectedDevice(filters.device)
|
if (filters.device) setSelectedDevice(filters.device as string)
|
||||||
else setSelectedDevice("")
|
else setSelectedDevice("")
|
||||||
if (filters.sortBy) setSort(filters.sortBy as SortOption)
|
if (filters.sortBy) setSort(filters.sortBy as SortOption)
|
||||||
}}
|
}}
|
||||||
|
|||||||
+6
-148
@@ -1,4 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
/* Colors */
|
/* Colors */
|
||||||
@@ -9,7 +10,11 @@
|
|||||||
--color-accent: #fb793c;
|
--color-accent: #fb793c;
|
||||||
--color-text: #ebe4f1;
|
--color-text: #ebe4f1;
|
||||||
--color-border: color-mix(in srgb, var(--color-text) 20%, transparent);
|
--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 */
|
/* Defaults */
|
||||||
--default-transition-duration: 0.3s;
|
--default-transition-duration: 0.3s;
|
||||||
@@ -17,150 +22,3 @@
|
|||||||
/* Fonts */
|
/* Fonts */
|
||||||
--font-sans: var(--font-lexend);
|
--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
@@ -33,16 +33,18 @@ export default function Landing() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id="hero"
|
id='hero'
|
||||||
className="w-full h-[calc(100vh-3.6rem)] flex flex-col items-center justify-center relative p-4"
|
className='w-full h-[calc(100vh-3.6rem)] flex flex-col items-center justify-center relative p-4'
|
||||||
>
|
>
|
||||||
<motion.h1
|
<motion.h1
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
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
|
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.h1>
|
||||||
<motion.h2
|
<motion.h2
|
||||||
layout
|
layout
|
||||||
@@ -50,18 +52,18 @@ export default function Landing() {
|
|||||||
opacity: 0,
|
opacity: 0,
|
||||||
}}
|
}}
|
||||||
animate={{ opacity: 0.8, transition: { delay: 0.5 } }}
|
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) => (
|
{"Find your game".split(" ").map((word, index) => (
|
||||||
<motion.span
|
<motion.span
|
||||||
key={index}
|
key={index}
|
||||||
className="text-center"
|
className='text-center'
|
||||||
>
|
>
|
||||||
{word}
|
{word}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
))}
|
))}
|
||||||
<AnimatePresence
|
<AnimatePresence
|
||||||
mode="wait"
|
mode='wait'
|
||||||
initial={false}
|
initial={false}
|
||||||
>
|
>
|
||||||
<motion.span
|
<motion.span
|
||||||
@@ -69,7 +71,7 @@ export default function Landing() {
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="text-primary font-bold"
|
className='text-primary font-bold'
|
||||||
>
|
>
|
||||||
{words[currentWord]}
|
{words[currentWord]}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
@@ -77,27 +79,27 @@ export default function Landing() {
|
|||||||
</motion.h2>
|
</motion.h2>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<motion.label
|
<motion.label
|
||||||
key="search-bar"
|
key='search-bar'
|
||||||
layoutId="search-bar"
|
layoutId='search-bar'
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
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
|
<input
|
||||||
type="text"
|
type='text'
|
||||||
placeholder="search by game or appid..."
|
placeholder='search by game or appid...'
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
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
|
<motion.button
|
||||||
onClick={handleSearchSubmit}
|
onClick={handleSearchSubmit}
|
||||||
whileTap={{ scale: 0.95 }}
|
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
|
search
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</motion.label>
|
</motion.label>
|
||||||
@@ -105,26 +107,41 @@ export default function Landing() {
|
|||||||
<motion.small
|
<motion.small
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1, transition: { delay: 1.5 } }}
|
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
|
<Link
|
||||||
title="Visit our Github Repository"
|
title='Visit our Github Repository'
|
||||||
href="https://github.com/AdrianBonpin/deckyvault"
|
href='/updates'
|
||||||
className="text-accent opacity-60 hover:opacity-100 transition-opacity cursor-pointer"
|
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.
|
Github.
|
||||||
</Link>
|
</Link>
|
||||||
|
<span className="opacity-60">v{process.env.NEXT_PUBLIC_APP_VERSION}</span>
|
||||||
</motion.small>
|
</motion.small>
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type='application/ld+json'
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: JSON.stringify({
|
__html: JSON.stringify({
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebSite",
|
"@type": "WebSite",
|
||||||
name: "DeckyVault",
|
name: "DeckyVault",
|
||||||
url: "https://deckyvault.xyz",
|
url: "https://deckyvault.xyz",
|
||||||
description: "Steam Deck benchmarks, settings, and performance guides",
|
description:
|
||||||
|
"Steam Deck benchmarks, settings, and performance guides",
|
||||||
potentialAction: {
|
potentialAction: {
|
||||||
"@type": "SearchAction",
|
"@type": "SearchAction",
|
||||||
target: "https://deckyvault.xyz/search?q={search_term_string}",
|
target: "https://deckyvault.xyz/search?q={search_term_string}",
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default async function UpdatePage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="w-full">
|
<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
|
<Link
|
||||||
href="/updates"
|
href="/updates"
|
||||||
className="inline-flex items-center gap-1 text-sm text-text/60 hover:text-primary transition-colors"
|
className="inline-flex items-center gap-1 text-sm text-text/60 hover:text-primary transition-colors"
|
||||||
|
|||||||
+18
-12
@@ -6,32 +6,38 @@ import { ArrowLeftIcon } from "lucide-react"
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Updates",
|
title: "Updates",
|
||||||
description: "See what's new in DeckyVault — version release notes, features, and improvements.",
|
description:
|
||||||
|
"See what's new in DeckyVault — version release notes, features, and improvements.",
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UpdatesPage() {
|
export default function UpdatesPage() {
|
||||||
const updates = getAllUpdates()
|
const updates = getAllUpdates()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="w-full max-w-2xl mx-auto px-4 py-8">
|
<main className='w-full max-w-7xl mx-auto px-4 py-8'>
|
||||||
<div className="flex flex-row items-center gap-2 mb-6">
|
<div className='flex flex-row items-center gap-2 mb-6'>
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href='/'
|
||||||
className="text-text/60 hover:text-primary transition-colors"
|
className='text-text/60 hover:text-primary transition-colors'
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon className="w-4 h-4" />
|
<ArrowLeftIcon className='w-4 h-4' />
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-2xl font-bold">Updates</h1>
|
<h1 className='text-2xl font-bold'>Updates</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-text/60 mb-8">
|
<p className='text-text/60 mb-8'>
|
||||||
Release notes and changelogs for every DeckyVault version.
|
Release notes, changelogs, and the like for DeckyVault.
|
||||||
</p>
|
</p>
|
||||||
{updates.length === 0 ? (
|
{updates.length === 0 ? (
|
||||||
<p className="text-text/40 text-center py-16">No updates yet.</p>
|
<p className='text-text/40 text-center py-16'>
|
||||||
|
No updates yet.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-3">
|
<div className='flex flex-col gap-3'>
|
||||||
{updates.map((update) => (
|
{updates.map((update) => (
|
||||||
<UpdateCard key={update.slug} update={update} />
|
<UpdateCard
|
||||||
|
key={update.slug}
|
||||||
|
update={update}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19",
|
"@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/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/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=="],
|
"@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=="],
|
"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=="],
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
|
"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": ["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-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
||||||
|
|
||||||
"postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function CommentSection({ gameId, initialCount }: CommentSectionProps) {
|
|||||||
const limit = 20
|
const limit = 20
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setMounted(true)
|
setMounted(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface SavedFilter {
|
interface SavedFilter {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
filters: Record<string, any>;
|
filters: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SavedFiltersProps {
|
interface SavedFiltersProps {
|
||||||
currentFilters: Record<string, any>;
|
currentFilters: Record<string, unknown>;
|
||||||
onLoad: (filters: Record<string, any>) => void;
|
onLoad: (filters: Record<string, unknown>) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { ThumbsUp, ThumbsDown, ExternalLink, MessageSquare } from "lucide-react";
|
import { ThumbsUp, ThumbsDown, ExternalLink, MessageSquare } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ export function SteamReviews({ gameId, steamAppId, className }: SteamReviewsProp
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(0);
|
||||||
|
|
||||||
const fetchReviews = async (newOffset: number) => {
|
const fetchReviews = useCallback(async (newOffset: number) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@@ -64,11 +64,12 @@ export function SteamReviews({ gameId, steamAppId, className }: SteamReviewsProp
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [gameId, setData, setLoading, setError, setOffset]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
fetchReviews(0);
|
fetchReviews(0);
|
||||||
}, [gameId]);
|
}, [gameId, fetchReviews]);
|
||||||
|
|
||||||
// Loading skeleton
|
// Loading skeleton
|
||||||
if (loading && !data) {
|
if (loading && !data) {
|
||||||
|
|||||||
@@ -10,17 +10,21 @@ export function UpdateCard({ update }: { update: UpdateMeta }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/updates/${update.slug}`}>
|
<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">
|
<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">
|
<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">
|
<span className='text-xs font-mono px-2 py-0.5 rounded bg-primary/10 text-primary border border-primary/20'>
|
||||||
v{update.version}
|
v{update.version}
|
||||||
</span>
|
</span>
|
||||||
<time className="text-xs text-text/60">{formattedDate}</time>
|
<time className='text-xs text-text/60'>
|
||||||
|
{formattedDate}
|
||||||
|
</time>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors">
|
<h3 className='text-lg font-semibold group-hover:text-primary transition-colors'>
|
||||||
{update.title}
|
{update.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-text/60 line-clamp-2">{update.summary}</p>
|
<p className='text-sm text-text/60 line-clamp-2'>
|
||||||
|
{update.summary}
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function UpdateViewer({
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* 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">
|
<header className="mb-8">
|
||||||
<div className="flex flex-row items-center gap-2 mb-2">
|
<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">
|
<span className="text-xs font-mono px-2 py-0.5 rounded bg-primary/10 text-primary border border-primary/20">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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 { SetupStep, type GameVersionInfo } from "@/components/wizard/steps/setup-step"
|
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 { 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"
|
||||||
@@ -69,6 +69,7 @@ export function GameEntryWizard({ gameId, gameVersions, defaultVersionId, editEn
|
|||||||
) ?? platformSupport.find((p) => p.antiCheatRelevant)
|
) ?? platformSupport.find((p) => p.antiCheatRelevant)
|
||||||
|
|
||||||
if (entry) {
|
if (entry) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setAntiCheat({
|
setAntiCheat({
|
||||||
antiCheatRelevant: entry.antiCheatRelevant,
|
antiCheatRelevant: entry.antiCheatRelevant,
|
||||||
antiCheatName: entry.antiCheatName ?? "",
|
antiCheatName: entry.antiCheatName ?? "",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
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"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export type AntiCheatData = {
|
export type AntiCheatData = {
|
||||||
@@ -61,7 +61,7 @@ export function AntiCheatStep({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
}: AntiCheatStepProps) {
|
}: AntiCheatStepProps) {
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing] = useState(false)
|
||||||
|
|
||||||
// Find the best existing entry to prefill:
|
// Find the best existing entry to prefill:
|
||||||
// Prefer the entry for the currently selected hardware,
|
// Prefer the entry for the currently selected hardware,
|
||||||
|
|||||||
@@ -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, GitBranch, Shield } from "lucide-react"
|
import { Send, Loader2, AlertCircle, Monitor, Gauge, SlidersHorizontal, Terminal, FileText } 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"
|
||||||
|
|||||||
@@ -33,11 +33,13 @@ interface SetupStepProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SetupStep({
|
export function SetupStep({
|
||||||
gameId,
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
gameId: _gameId,
|
||||||
gameVersions,
|
gameVersions,
|
||||||
hardwareSlug,
|
hardwareSlug,
|
||||||
onHardwareChange,
|
onHardwareChange,
|
||||||
hardwareName,
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
hardwareName: _hardwareName,
|
||||||
selectedVersionId,
|
selectedVersionId,
|
||||||
onVersionChange,
|
onVersionChange,
|
||||||
newVersionString,
|
newVersionString,
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ date: "2026-04-30"
|
|||||||
version: "2026.0.95"
|
version: "2026.0.95"
|
||||||
summary: "Major update adding anti-cheat tracking, Steam reviews integration, playability indicators, games list revamp, community suggestions, and more."
|
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
|
## Added
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Elysia, t } from "elysia"
|
import { Elysia, t } from "elysia"
|
||||||
import { db } from "@/lib/db/index"
|
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 { eq, and, desc } from "drizzle-orm"
|
||||||
import { requireAuth, requireContributorOrAdmin } from "@/lib/auth/guard"
|
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" }
|
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
|
const [suggestion] = await db
|
||||||
.insert(communitySuggestions)
|
.insert(communitySuggestions)
|
||||||
@@ -149,9 +149,9 @@ export const communitySuggestionRoutes = new Elysia({
|
|||||||
async ({ params, query }) => {
|
async ({ params, query }) => {
|
||||||
const status = query.status // optional filter
|
const status = query.status // optional filter
|
||||||
|
|
||||||
let conditions = [eq(communitySuggestions.gameId, params.gameId)]
|
const conditions = [eq(communitySuggestions.gameId, params.gameId)]
|
||||||
if (status) {
|
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
|
const suggestions = await db
|
||||||
@@ -263,7 +263,7 @@ export const communitySuggestionRoutes = new Elysia({
|
|||||||
|
|
||||||
// If approved, apply the change to the game
|
// If approved, apply the change to the game
|
||||||
if (status === "approved") {
|
if (status === "approved") {
|
||||||
const updateData: Record<string, any> = {}
|
const updateData: Record<string, string> = {}
|
||||||
updateData[suggestion.fieldName] = suggestion.proposedValue
|
updateData[suggestion.fieldName] = suggestion.proposedValue
|
||||||
|
|
||||||
await db
|
await db
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
gamePlatformSupport,
|
gamePlatformSupport,
|
||||||
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, type SQL } from "drizzle-orm"
|
||||||
import { fuzzySearchTerm } from "@/lib/db/search"
|
import { fuzzySearchTerm } from "@/lib/db/search"
|
||||||
|
|
||||||
const MAX_OFFSET = 10000
|
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) ────
|
// ── FPS range filter (games with benchmarks in this range) ────
|
||||||
if (minFps || maxFps) {
|
if (minFps || maxFps) {
|
||||||
const fpsConditions = [
|
const fpsConditions: (SQL | undefined)[] = [
|
||||||
eq(performanceEntries.isRemoved, false),
|
eq(performanceEntries.isRemoved, false),
|
||||||
minFps ? gte(performanceEntries.fpsAvg, Number(minFps)) : undefined,
|
minFps ? gte(performanceEntries.fpsAvg, Number(minFps)) : undefined,
|
||||||
maxFps ? lte(performanceEntries.fpsAvg, Number(maxFps)) : undefined,
|
maxFps ? lte(performanceEntries.fpsAvg, Number(maxFps)) : undefined,
|
||||||
].filter(Boolean) as any[]
|
].filter((c): c is SQL => c !== undefined)
|
||||||
|
|
||||||
const fpsSubquery = db
|
const fpsSubquery = db
|
||||||
.select({ gameId: gameVersions.gameId })
|
.select({ gameId: gameVersions.gameId })
|
||||||
@@ -127,7 +127,7 @@ export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
|
|||||||
|
|
||||||
// ── Proton / Native filter ────────────────────────────────────
|
// ── Proton / Native filter ────────────────────────────────────
|
||||||
if (protonNative && ["proton", "native", "both"].includes(protonNative)) {
|
if (protonNative && ["proton", "native", "both"].includes(protonNative)) {
|
||||||
const protonConditions: any[] = []
|
const protonConditions: SQL[] = []
|
||||||
|
|
||||||
if (protonNative === "proton" || protonNative === "both") {
|
if (protonNative === "proton" || protonNative === "both") {
|
||||||
protonConditions.push(eq(gamePlatformSupport.protonStatus, "proton"))
|
protonConditions.push(eq(gamePlatformSupport.protonStatus, "proton"))
|
||||||
@@ -148,7 +148,7 @@ export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
|
|||||||
// ── Anti-cheat status filter ──────────────────────────────────
|
// ── Anti-cheat status filter ──────────────────────────────────
|
||||||
const validAcStatuses = ["supported", "unsupported", "unknown", "none"]
|
const validAcStatuses = ["supported", "unsupported", "unknown", "none"]
|
||||||
if (antiCheatStatus && antiCheatStatus !== "any" && validAcStatuses.includes(antiCheatStatus)) {
|
if (antiCheatStatus && antiCheatStatus !== "any" && validAcStatuses.includes(antiCheatStatus)) {
|
||||||
const acConditions: any[] = [
|
const acConditions: SQL[] = [
|
||||||
eq(gamePlatformSupport.antiCheatRelevant, true),
|
eq(gamePlatformSupport.antiCheatRelevant, true),
|
||||||
eq(gamePlatformSupport.antiCheatStatus, antiCheatStatus as "none" | "supported" | "unsupported" | "unknown"),
|
eq(gamePlatformSupport.antiCheatStatus, antiCheatStatus as "none" | "supported" | "unsupported" | "unknown"),
|
||||||
]
|
]
|
||||||
|
|||||||
+3
-42
@@ -190,46 +190,7 @@ export const gameVersionsRoutes = new Elysia({ prefix: "/games/:gameId/versions"
|
|||||||
|
|
||||||
// ── Game Sync Routes ────────────────────────────────────────────────
|
// ── Game Sync Routes ────────────────────────────────────────────────
|
||||||
const MAX_BULK_SYNC = 1000
|
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" })
|
export const gameSyncRoutes = new Elysia({ prefix: "/games" })
|
||||||
// Bulk sync with streaming progress (defined before /:gameId/sync to avoid route conflict)
|
// 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 encoder = new TextEncoder()
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
async start(controller) {
|
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"))
|
controller.enqueue(encoder.encode(JSON.stringify(data) + "\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +256,6 @@ export const gameSyncRoutes = new Elysia({ prefix: "/games" })
|
|||||||
|
|
||||||
let synced = 0
|
let synced = 0
|
||||||
let failed = 0
|
let failed = 0
|
||||||
const batchSize = SYNC_CONCURRENCY
|
|
||||||
|
|
||||||
// Process syncs sequentially with delay to avoid rate limiting
|
// Process syncs sequentially with delay to avoid rate limiting
|
||||||
for (let i = 0; i < gamesToSync.length; i++) {
|
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 })
|
const result = await syncSteamGame(game.steamAppId, { forceRetry: true })
|
||||||
if (result.success) synced++
|
if (result.success) synced++
|
||||||
else failed++
|
else failed++
|
||||||
} catch (e) {
|
} catch {
|
||||||
failed++
|
failed++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Elysia } from "elysia";
|
import { Elysia } from "elysia";
|
||||||
import { db } from "@/lib/db/index";
|
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 { eq, and, avg, count, sql } from "drizzle-orm";
|
||||||
import { requireContributorOrAdmin } from "@/lib/auth/guard";
|
import { requireContributorOrAdmin } from "@/lib/auth/guard";
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ export async function recalculatePlayability(gameId: string): Promise<{
|
|||||||
await db
|
await db
|
||||||
.update(gamePlatformSupport)
|
.update(gamePlatformSupport)
|
||||||
.set({
|
.set({
|
||||||
playabilityStatus: status as any,
|
playabilityStatus: status as typeof playabilityStatusEnum.enumValues[number],
|
||||||
playabilityCalculatedAt: new Date(),
|
playabilityCalculatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(
|
.where(
|
||||||
@@ -111,13 +111,13 @@ export async function recalculatePlayability(gameId: string): Promise<{
|
|||||||
|
|
||||||
// Update aggregate game-level playability (worst of all devices)
|
// Update aggregate game-level playability (worst of all devices)
|
||||||
const priority = { unplayable: 0, needs_tweaks: 1, playable: 2, great: 3, unknown: 4 };
|
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) {
|
for (const r of results) {
|
||||||
if (
|
if (
|
||||||
priority[r.playabilityStatus as keyof typeof priority] <
|
priority[r.playabilityStatus as keyof typeof priority] <
|
||||||
priority[worstStatus 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
|
await db
|
||||||
.update(games)
|
.update(games)
|
||||||
.set({
|
.set({
|
||||||
playabilityStatus: worstStatus as any,
|
playabilityStatus: worstStatus as typeof playabilityStatusEnum.enumValues[number],
|
||||||
playabilityCalculatedAt: new Date(),
|
playabilityCalculatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(games.id, gameId));
|
.where(eq(games.id, gameId));
|
||||||
@@ -174,7 +174,7 @@ export const playabilityRoutes = new Elysia({ prefix: "/playability" })
|
|||||||
if (hardwareSlug) {
|
if (hardwareSlug) {
|
||||||
await db
|
await db
|
||||||
.update(gamePlatformSupport)
|
.update(gamePlatformSupport)
|
||||||
.set({ playabilityStatus: status as any, playabilityOverride: true })
|
.set({ playabilityStatus: status as typeof playabilityStatusEnum.enumValues[number], playabilityOverride: true })
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(gamePlatformSupport.gameId, params.gameId),
|
eq(gamePlatformSupport.gameId, params.gameId),
|
||||||
@@ -184,7 +184,7 @@ export const playabilityRoutes = new Elysia({ prefix: "/playability" })
|
|||||||
} else {
|
} else {
|
||||||
await db
|
await db
|
||||||
.update(games)
|
.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));
|
.where(eq(games.id, params.gameId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { eq } from "drizzle-orm"
|
|||||||
// In-memory cache for reviews (key -> { data, expires })
|
// In-memory cache for reviews (key -> { data, expires })
|
||||||
const reviewCache = new Map<
|
const reviewCache = new Map<
|
||||||
string,
|
string,
|
||||||
{ data: any; expires: number }
|
{ data: SteamReviewResponse; expires: number }
|
||||||
>()
|
>()
|
||||||
|
|
||||||
const CACHE_TTL = 60 * 60 * 1000 // 1 hour
|
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 })
|
reviewCache.set(cacheKey, { data: slicedData, expires: Date.now() + CACHE_TTL })
|
||||||
|
|
||||||
return slicedData
|
return slicedData
|
||||||
} catch (error) {
|
} catch {
|
||||||
set.status = 502
|
set.status = 502
|
||||||
return { error: "Steam review API unavailable" }
|
return { error: "Steam review API unavailable" }
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
import { db } from "@/lib/db/index"
|
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 { eq } from "drizzle-orm"
|
||||||
import { recalculatePlayability } from "@/lib/api/playability"
|
import { recalculatePlayability } from "@/lib/api/playability"
|
||||||
|
|
||||||
@@ -247,7 +247,7 @@ export async function syncSteamGame(
|
|||||||
metacriticUrl: d.metacritic?.url ?? null,
|
metacriticUrl: d.metacritic?.url ?? null,
|
||||||
recommendationsTotal: d.recommendations?.total ?? null,
|
recommendationsTotal: d.recommendations?.total ?? null,
|
||||||
steamReviewScore: reviewData.reviewScore,
|
steamReviewScore: reviewData.reviewScore,
|
||||||
steamReviewSentiment: reviewData.reviewSentiment as any,
|
steamReviewSentiment: reviewData.reviewSentiment as typeof steamReviewSentimentEnum.enumValues[number] | null,
|
||||||
steamReviewCount: reviewData.reviewCount,
|
steamReviewCount: reviewData.reviewCount,
|
||||||
priceCurrent: d.price_overview?.final ?? null,
|
priceCurrent: d.price_overview?.final ?? null,
|
||||||
priceInitial: d.price_overview?.initial ?? null,
|
priceInitial: d.price_overview?.initial ?? null,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import { version } from "./package.json";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
env: {
|
||||||
|
NEXT_PUBLIC_APP_VERSION: version,
|
||||||
|
},
|
||||||
// Elysia is Bun-native and must not be bundled by Next.js
|
// Elysia is Bun-native and must not be bundled by Next.js
|
||||||
serverExternalPackages: ["elysia", "@elysiajs/eden"],
|
serverExternalPackages: ["elysia", "@elysiajs/eden"],
|
||||||
images: {
|
images: {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
|
|||||||
Reference in New Issue
Block a user