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:
@@ -51,3 +51,6 @@ public/sw.js
|
|||||||
.pi/
|
.pi/
|
||||||
|
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
|
# temporary files
|
||||||
|
post.md
|
||||||
+1137
-845
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|||||||
+847
-709
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -43,7 +43,7 @@ const nextConfig: NextConfig = {
|
|||||||
// Cloudflare R2
|
// Cloudflare R2
|
||||||
{
|
{
|
||||||
protocol: "https",
|
protocol: "https",
|
||||||
hostname: "**.r2.dev",
|
hostname: "cdn.deckyvault.xyz",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user