chore: format components, switch to CDN hostname, and improve error handling in game stats

- Format long lines and union types in game page, preset modal, games page, and filter drawer
- Replace **.r2.dev with cdn.deckyvault.xyz in next.config image hostnames
- Replace sql ANY with drizzle inArray for hardware query in game stats
- Add try/catch error handler in game stats route
- Add post.md to .gitignore
This commit is contained in:
2026-05-10 04:15:38 +08:00
parent f02408c0c7
commit bcf2d540b8
7 changed files with 2251 additions and 1780 deletions
+4 -1
View File
@@ -50,4 +50,7 @@ public/sw.js
.worktrees/ .worktrees/
.pi/ .pi/
.superpowers/ .superpowers/
# temporary files
post.md
File diff suppressed because it is too large Load Diff
+164 -134
View File
@@ -105,6 +105,7 @@ export function PresetDetailModal({
const [userVote, setUserVote] = useState<"up" | "down" | null>(null) const [userVote, setUserVote] = useState<"up" | "down" | null>(null)
const [localUpvotes, setLocalUpvotes] = useState(preset.upvotes) const [localUpvotes, setLocalUpvotes] = useState(preset.upvotes)
const [localDownvotes, setLocalDownvotes] = useState(preset.downvotes) const [localDownvotes, setLocalDownvotes] = useState(preset.downvotes)
const [mobileTab, setMobileTab] = useState<"details" | "settings">("settings")
const isOwner = session?.user?.id === preset.userId const isOwner = session?.user?.id === preset.userId
const isAdmin = session?.user?.role === "admin" const isAdmin = session?.user?.role === "admin"
@@ -203,7 +204,7 @@ export function PresetDetailModal({
> >
{/* Modal card */} {/* Modal card */}
<motion.div <motion.div
className="relative w-full max-w-4xl max-h-[90vh] overflow-hidden rounded-xl border border-border bg-background flex flex-col" className="relative w-full max-w-4xl h-[90vh] md:max-h-[90vh] overflow-hidden rounded-xl border border-border bg-background flex flex-col"
initial={{ y: "100%", opacity: 0 }} initial={{ y: "100%", opacity: 0 }}
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
exit={{ y: "100%", opacity: 0 }} exit={{ y: "100%", opacity: 0 }}
@@ -230,10 +231,36 @@ export function PresetDetailModal({
</button> </button>
</div> </div>
{/* Mobile tabs */}
<div className="flex md:hidden shrink-0 border-b border-border">
<button
onClick={() => setMobileTab("details")}
className={`flex-1 py-2.5 text-sm font-medium transition-colors cursor-pointer ${
mobileTab === "details"
? "text-primary border-b-2 border-primary"
: "text-text/50 hover:text-text/70"
}`}
>
Details
</button>
<button
onClick={() => setMobileTab("settings")}
className={`flex-1 py-2.5 text-sm font-medium transition-colors cursor-pointer ${
mobileTab === "settings"
? "text-primary border-b-2 border-primary"
: "text-text/50 hover:text-text/70"
}`}
>
Settings
</button>
</div>
{/* Two-column body */} {/* Two-column body */}
<div className="flex flex-col md:flex-row overflow-hidden flex-1"> <div className="flex flex-col md:flex-row overflow-hidden flex-1">
{/* Left panel */} {/* Left panel */}
<div className="w-full md:w-1/3 md:min-w-[240px] flex flex-col gap-3 md:gap-4 p-4 md:p-5 border-b md:border-b-0 md:border-r border-border overflow-y-auto"> <div className={`w-full md:w-1/3 md:min-w-60 flex-col gap-3 md:gap-4 p-4 md:p-5 border-b md:border-b-0 md:border-r border-border overflow-y-auto ${
mobileTab === "details" ? "flex" : "hidden md:flex"
}`}>
{/* User info */} {/* User info */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-text/10 overflow-hidden flex items-center justify-center shrink-0"> <div className="h-10 w-10 rounded-full bg-text/10 overflow-hidden flex items-center justify-center shrink-0">
@@ -271,137 +298,8 @@ export function PresetDetailModal({
<div className="h-px bg-border" /> <div className="h-px bg-border" />
{/* Vote buttons */}
<div className="flex items-center gap-3">
<button
onClick={handleUpvote}
disabled={!isAuthenticated || userVote === "up"}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium border transition-colors cursor-pointer ${
userVote === "up"
? "bg-green-500/10 border-green-500/30 text-green-400"
: "border-border text-text/70 hover:bg-text/5"
} ${!isAuthenticated ? "opacity-50 cursor-not-allowed" : ""}`}
title={!isAuthenticated ? "Sign in to vote" : undefined}
>
<ThumbsUpIcon className="h-4 w-4" />
{localUpvotes}
</button>
<button
onClick={handleDownvote}
disabled={!isAuthenticated || userVote === "down"}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium border transition-colors cursor-pointer ${
userVote === "down"
? "bg-red-500/10 border-red-500/30 text-red-400"
: "border-border text-text/70 hover:bg-text/5"
} ${!isAuthenticated ? "opacity-50 cursor-not-allowed" : ""}`}
title={!isAuthenticated ? "Sign in to vote" : undefined}
>
<ThumbsDownIcon className="h-4 w-4" />
{localDownvotes}
</button>
</div>
{/* FPS */}
{preset.fpsAvg !== null && (
<div className="flex flex-col gap-1">
<span className="text-xs text-text/50 uppercase tracking-wider">
Avg FPS
</span>
<div className="text-sm text-text">
<span className="font-semibold tabular-nums">
{preset.fpsAvg}
</span>
{preset.fpsLow !== null && preset.fpsHigh !== null && (
<span className="text-text/50 ml-1">
({Math.round(preset.fpsLow)}{Math.round(preset.fpsHigh)})
</span>
)}
</div>
</div>
)}
{preset.fpsOnePercentLow !== null && (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-text/50 uppercase tracking-wider">1% Low FPS</span>
<span className="text-sm font-semibold tabular-nums text-text">
{preset.fpsOnePercentLow} fps
</span>
</div>
)}
<div className="h-px bg-border" />
{/* Metadata */}
<div className="flex flex-col gap-3">
<MetaItem label="Proton" value={preset.protonVersion} />
<MetaItem label="OS" value={preset.osVersion} />
{preset.tdpWatts !== null && (
<MetaItem label="TDP" value={`${Math.round(preset.tdpWatts)}W`} />
)}
{preset.hardwareWattHours !== null && preset.hardwareDeviceType === "handheld" && (
<MetaItem label="Battery" value={`${Math.round(preset.hardwareWattHours)}Wh`} />
)}
{preset.tdpWatts !== null && preset.hardwareWattHours !== null && preset.hardwareDeviceType === "handheld" && (
<MetaItem label="Est. Battery" value={`~${(preset.hardwareWattHours / preset.tdpWatts).toFixed(1)}h`} />
)}
<MetaItem
label="Upscaler"
value={
preset.upscalerType && preset.upscalerType !== "none"
? `${preset.upscalerType.toUpperCase()}${preset.upscalerVersion ? ` ${preset.upscalerVersion}` : ""}`
: null
}
/>
<MetaItem
label="Frame Gen"
value={
preset.frameGenMethod && preset.frameGenMethod !== "none"
? preset.frameGenMethod === "fsr_fg"
? "FSR FG"
: preset.frameGenMethod === "dlss_fg"
? "DLSS FG"
: preset.frameGenMethod
: null
}
/>
<MetaItem label="Launch Options" value={preset.launchOptions} />
{preset.loadTimeSsd !== null && (
<MetaItem label="Load Time (SSD)" value={`${preset.loadTimeSsd}s`} />
)}
{preset.loadTimeSd !== null && (
<MetaItem label="Load Time (SD)" value={`${preset.loadTimeSd}s`} />
)}
{preset.estimatedBatteryMin !== null && (
<MetaItem label="Battery Life" value={`${preset.estimatedBatteryMin} min`} />
)}
{preset.customSystem && (
<MetaItem label="Custom System" value="Yes" />
)}
</div>
<div className="h-px bg-border" />
{/* Screenshots */}
<div className="h-px bg-border" />
{preset.screenshots && preset.screenshots.length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-text/50 uppercase tracking-wider">Screenshots</span>
<div className="flex gap-2">
{preset.screenshots.map((ss) => (
<a key={ss.id} href={ss.url} target="_blank" rel="noopener noreferrer" className="block">
<img
src={ss.url}
alt="Screenshot"
className="w-20 h-12 object-cover rounded border border-border hover:border-primary/50 transition-colors"
loading="lazy"
/>
</a>
))}
</div>
</div>
)}
{/* Actions */} {/* Actions */}
<div className="flex flex-wrap items-center gap-2 mt-auto"> <div className="flex flex-wrap items-center gap-2">
{isAdmin && ( {isAdmin && (
<button <button
onClick={handleTogglePin} onClick={handleTogglePin}
@@ -535,10 +433,142 @@ export function PresetDetailModal({
</span> </span>
)} )}
</div> </div>
<div className="h-px bg-border" />
{/* Vote buttons */}
<div className="flex items-center gap-3">
<button
onClick={handleUpvote}
disabled={!isAuthenticated || userVote === "up"}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium border transition-colors cursor-pointer ${
userVote === "up"
? "bg-green-500/10 border-green-500/30 text-green-400"
: "border-border text-text/70 hover:bg-text/5"
} ${!isAuthenticated ? "opacity-50 cursor-not-allowed" : ""}`}
title={!isAuthenticated ? "Sign in to vote" : undefined}
>
<ThumbsUpIcon className="h-4 w-4" />
{localUpvotes}
</button>
<button
onClick={handleDownvote}
disabled={!isAuthenticated || userVote === "down"}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium border transition-colors cursor-pointer ${
userVote === "down"
? "bg-red-500/10 border-red-500/30 text-red-400"
: "border-border text-text/70 hover:bg-text/5"
} ${!isAuthenticated ? "opacity-50 cursor-not-allowed" : ""}`}
title={!isAuthenticated ? "Sign in to vote" : undefined}
>
<ThumbsDownIcon className="h-4 w-4" />
{localDownvotes}
</button>
</div>
{/* FPS */}
{preset.fpsAvg !== null && (
<div className="flex flex-col gap-1">
<span className="text-xs text-text/50 uppercase tracking-wider">
Avg FPS
</span>
<div className="text-sm text-text">
<span className="font-semibold tabular-nums">
{preset.fpsAvg}
</span>
{preset.fpsLow !== null && preset.fpsHigh !== null && (
<span className="text-text/50 ml-1">
({Math.round(preset.fpsLow)}{Math.round(preset.fpsHigh)})
</span>
)}
</div>
</div>
)}
{preset.fpsOnePercentLow !== null && (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-text/50 uppercase tracking-wider">1% Low FPS</span>
<span className="text-sm font-semibold tabular-nums text-text">
{preset.fpsOnePercentLow} fps
</span>
</div>
)}
<div className="h-px bg-border" />
{/* Metadata */}
<div className="flex flex-col gap-3">
<MetaItem label="Proton" value={preset.protonVersion} />
<MetaItem label="OS" value={preset.osVersion} />
{preset.tdpWatts !== null && (
<MetaItem label="TDP" value={`${Math.round(preset.tdpWatts)}W`} />
)}
{preset.hardwareWattHours !== null && preset.hardwareDeviceType === "handheld" && (
<MetaItem label="Battery" value={`${Math.round(preset.hardwareWattHours)}Wh`} />
)}
{preset.tdpWatts !== null && preset.hardwareWattHours !== null && preset.hardwareDeviceType === "handheld" && (
<MetaItem label="Est. Battery" value={`~${(preset.hardwareWattHours / preset.tdpWatts).toFixed(1)}h`} />
)}
<MetaItem
label="Upscaler"
value={
preset.upscalerType && preset.upscalerType !== "none"
? `${preset.upscalerType.toUpperCase()}${preset.upscalerVersion ? ` ${preset.upscalerVersion}` : ""}`
: null
}
/>
<MetaItem
label="Frame Gen"
value={
preset.frameGenMethod && preset.frameGenMethod !== "none"
? preset.frameGenMethod === "fsr_fg"
? "FSR FG"
: preset.frameGenMethod === "dlss_fg"
? "DLSS FG"
: preset.frameGenMethod
: null
}
/>
<MetaItem label="Launch Options" value={preset.launchOptions} />
{preset.loadTimeSsd !== null && (
<MetaItem label="Load Time (SSD)" value={`${preset.loadTimeSsd}s`} />
)}
{preset.loadTimeSd !== null && (
<MetaItem label="Load Time (SD)" value={`${preset.loadTimeSd}s`} />
)}
{preset.estimatedBatteryMin !== null && (
<MetaItem label="Battery Life" value={`${preset.estimatedBatteryMin} min`} />
)}
{preset.customSystem && (
<MetaItem label="Custom System" value="Yes" />
)}
</div>
<div className="h-px bg-border" />
{/* Screenshots */}
{preset.screenshots && preset.screenshots.length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-text/50 uppercase tracking-wider">Screenshots</span>
<div className="flex gap-2">
{preset.screenshots.map((ss) => (
<a key={ss.id} href={ss.url} target="_blank" rel="noopener noreferrer" className="block">
<img
src={ss.url}
alt="Screenshot"
className="w-20 h-12 object-cover rounded border border-border hover:border-primary/50 transition-colors"
loading="lazy"
/>
</a>
))}
</div>
</div>
)}
</div> </div>
{/* Right panel */} {/* Right panel */}
<div className="flex-1 overflow-y-auto p-5"> <div className={`flex-1 overflow-y-auto p-5 ${
mobileTab === "settings" ? "block" : "hidden md:block"
}`}>
{/* YouTube Video */} {/* YouTube Video */}
{preset.youtubeVideoId && ( {preset.youtubeVideoId && (
<div className="mb-4"> <div className="mb-4">
@@ -662,7 +692,7 @@ function MetaItem({
<span className="text-[10px] text-text/50 uppercase tracking-wider"> <span className="text-[10px] text-text/50 uppercase tracking-wider">
{label} {label}
</span> </span>
<span className="text-sm text-text/80 break-words"> <span className="text-sm text-text/80 wrap-break-words">
{value ?? "—"} {value ?? "—"}
</span> </span>
</div> </div>
File diff suppressed because it is too large Load Diff
+74 -72
View File
@@ -5,84 +5,86 @@ import { XIcon } from "lucide-react"
import { motion, AnimatePresence } from "motion/react" import { motion, AnimatePresence } from "motion/react"
interface FilterDrawerProps { interface FilterDrawerProps {
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
children: React.ReactNode children: React.ReactNode
} }
export function FilterDrawer({ isOpen, onClose, children }: FilterDrawerProps) { export function FilterDrawer({ isOpen, onClose, children }: FilterDrawerProps) {
const drawerRef = useRef<HTMLDivElement>(null) const drawerRef = useRef<HTMLDivElement>(null)
// Close on Escape // Close on Escape
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose() if (e.key === "Escape") onClose()
} }
document.addEventListener("keydown", handleKeyDown) document.addEventListener("keydown", handleKeyDown)
return () => document.removeEventListener("keydown", handleKeyDown) return () => document.removeEventListener("keydown", handleKeyDown)
}, [isOpen, onClose]) }, [isOpen, onClose])
// Prevent body scroll when open // Prevent body scroll when open
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
document.body.style.overflow = "hidden" document.body.style.overflow = "hidden"
} else { } else {
document.body.style.overflow = "" document.body.style.overflow = ""
} }
return () => { return () => {
document.body.style.overflow = "" document.body.style.overflow = ""
} }
}, [isOpen]) }, [isOpen])
// Focus trap // Focus trap
useEffect(() => { useEffect(() => {
if (isOpen && drawerRef.current) { if (isOpen && drawerRef.current) {
const firstFocusable = drawerRef.current.querySelector<HTMLElement>( const firstFocusable = drawerRef.current.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
) )
firstFocusable?.focus() firstFocusable?.focus()
} }
}, [isOpen]) }, [isOpen])
return ( return (
<AnimatePresence> <AnimatePresence>
{isOpen && ( {isOpen && (
<> <>
{/* Backdrop */} {/* Backdrop */}
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden" className='fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden'
onClick={onClose} onClick={onClose}
/> />
{/* Drawer */} {/* Drawer */}
<motion.div <motion.div
ref={drawerRef} ref={drawerRef}
initial={{ x: "100%" }} initial={{ x: "100%" }}
animate={{ x: 0 }} animate={{ x: 0 }}
exit={{ x: "100%" }} exit={{ x: "100%" }}
transition={{ type: "spring", damping: 30, stiffness: 300 }} transition={{
className="fixed top-0 right-0 bottom-0 z-50 w-[85vw] max-w-sm overflow-y-auto bg-background border-l border-border lg:hidden" type: "spring",
> damping: 30,
<div className="sticky top-0 z-10 flex items-center justify-between p-4 border-b border-border bg-background"> stiffness: 300,
<h2 className="text-sm font-semibold">Filters</h2> }}
<button className='fixed top-0 right-0 bottom-0 z-50 w-[85vw] max-w-sm overflow-y-auto bg-background border-l border-border lg:hidden'
onClick={onClose} >
className="p-1.5 rounded-md hover:bg-text/5 transition-colors cursor-pointer" <div className='sticky top-0 z-10 flex items-center justify-between p-4 border-b border-border bg-background'>
aria-label="Close filters" <h2 className='text-sm font-semibold'>Filters</h2>
> <button
<XIcon className="h-4 w-4 text-text/50" /> onClick={onClose}
</button> className='p-1.5 rounded-md hover:bg-text/5 transition-colors cursor-pointer'
</div> aria-label='Close filters'
<div className="p-4"> >
{children} <XIcon className='h-4 w-4 text-text/50' />
</div> </button>
</motion.div> </div>
</> <div className='p-4'>{children}</div>
)} </motion.div>
</AnimatePresence> </>
) )}
</AnimatePresence>
)
} }
+24 -18
View File
@@ -6,13 +6,14 @@ import {
performanceEntries, performanceEntries,
hardware, hardware,
} from "@/lib/db/schema" } from "@/lib/db/schema"
import { eq, and, sql } from "drizzle-orm" import { eq, and, inArray, sql } from "drizzle-orm"
export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get( export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
"/:gameId/stats", "/:gameId/stats",
async ({ params, set }) => { async ({ params, set }) => {
const { gameId } = params const { gameId } = params
try {
// Verify game exists // Verify game exists
const [game] = await db const [game] = await db
.select({ id: games.id, steamAppId: games.steamAppId }) .select({ id: games.id, steamAppId: games.steamAppId })
@@ -163,7 +164,7 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
Map<string, { sum: number; count: number }> Map<string, { sum: number; count: number }>
>() >()
for (const e of entries) { for (const e of entries) {
const date = e.createdAt const date = new Date(e.createdAt)
const month = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}` const month = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`
if (!histMap.has(month)) histMap.set(month, new Map()) if (!histMap.has(month)) histMap.set(month, new Map())
const deviceMap = histMap.get(month)! const deviceMap = histMap.get(month)!
@@ -245,6 +246,22 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
const bestOnePercentLow = entries.reduce((best, e) => const bestOnePercentLow = entries.reduce((best, e) =>
e.fpsOnePercentLow != null && e.fpsOnePercentLow > (best ?? 0) ? e.fpsOnePercentLow : best, null as number | null) e.fpsOnePercentLow != null && e.fpsOnePercentLow > (best ?? 0) ? e.fpsOnePercentLow : best, null as number | null)
// Fetch hardware wattHours for the relevant devices (used by deviceBreakdown + batteryLife)
const deviceSlugs = [...new Set(entries.map((e) => e.hardwareSlug))]
const deviceData = await db
.select({
slug: hardware.slug,
wattHours: hardware.wattHours,
tdpMax: hardware.tdpMax,
deviceType: hardware.deviceType,
})
.from(hardware)
.where(inArray(hardware.slug, deviceSlugs))
const deviceWattHoursMap = new Map(
deviceData.map((d) => [d.slug, d]),
)
// ── 8. Device breakdown ─────────────────────────────────────── // ── 8. Device breakdown ───────────────────────────────────────
const deviceBreakdown = Array.from(boxplotMap.entries()).map( const deviceBreakdown = Array.from(boxplotMap.entries()).map(
([slug, { hardwareName, values }]) => { ([slug, { hardwareName, values }]) => {
@@ -288,22 +305,6 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
})) }))
// ── 12. Battery life estimates ───────────────────────────── // ── 12. Battery life estimates ─────────────────────────────
// Fetch hardware wattHours for the relevant devices
const deviceSlugs = [...new Set(entries.map((e) => e.hardwareSlug))]
const deviceData = await db
.select({
slug: hardware.slug,
wattHours: hardware.wattHours,
tdpMax: hardware.tdpMax,
deviceType: hardware.deviceType,
})
.from(hardware)
.where(sql`${hardware.slug} = ANY(${deviceSlugs})`)
const deviceWattHoursMap = new Map(
deviceData.map((d) => [d.slug, d]),
)
const batteryLife = entries const batteryLife = entries
.filter((e) => e.tdpWatts != null && e.tdpWatts > 0) .filter((e) => e.tdpWatts != null && e.tdpWatts > 0)
.map((e) => { .map((e) => {
@@ -359,6 +360,11 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
batteryLife, batteryLife,
filterOptions: { protonVersions, osVersions }, filterOptions: { protonVersions, osVersions },
} }
} catch (err) {
console.error("Error computing game stats:", err)
set.status = 500
return { error: "Failed to compute game stats" }
}
}, },
{ {
params: t.Object({ gameId: t.String() }), params: t.Object({ gameId: t.String() }),
+1 -1
View File
@@ -43,7 +43,7 @@ const nextConfig: NextConfig = {
// Cloudflare R2 // Cloudflare R2
{ {
protocol: "https", protocol: "https",
hostname: "**.r2.dev", hostname: "cdn.deckyvault.xyz",
} }
], ],
}, },