feat: power quick-look bar, YouTube indicator, battery chart, screenshot gallery on game details

This commit is contained in:
2026-05-09 23:20:34 +08:00
parent 4998337913
commit b5d9f0e19b
3 changed files with 181 additions and 0 deletions
+85
View File
@@ -43,6 +43,7 @@ import { FpsRangeChart } from "@/components/charts/FpsRangeChart"
import { DeviceDonut } from "@/components/charts/DeviceDonut" import { DeviceDonut } from "@/components/charts/DeviceDonut"
import { PerformanceTierChart } from "@/components/charts/PerformanceTierChart" import { PerformanceTierChart } from "@/components/charts/PerformanceTierChart"
import { StabilityScatterChart } from "@/components/charts/StabilityScatterChart" import { StabilityScatterChart } from "@/components/charts/StabilityScatterChart"
import { BatteryLifeChart } from "@/components/charts/BatteryLifeChart"
// Comments // Comments
import { CommentSection } from "@/components/comments/comment-section" import { CommentSection } from "@/components/comments/comment-section"
@@ -122,6 +123,9 @@ interface Preset {
loadTimeSsd: number | null loadTimeSsd: number | null
loadTimeSd: number | null loadTimeSd: number | null
estimatedBatteryMin: number | null estimatedBatteryMin: number | null
tdpWatts: number | null
youtubeVideoId: string | null
screenshots: Array<{ id: string; url: string; width: number; height: number; orderIndex: number }> | null
customSystem: boolean customSystem: boolean
userNotes: string | null userNotes: string | null
userId: string userId: string
@@ -131,6 +135,8 @@ interface Preset {
isPinned: boolean isPinned: boolean
pinnedAt: string | null pinnedAt: string | null
createdAt: string createdAt: string
hardwareWattHours: number | null
hardwareDeviceType: string | null
} }
interface StatsResponse { interface StatsResponse {
@@ -184,6 +190,9 @@ interface StatsResponse {
hardwareSlug: string hardwareSlug: string
hardwareName: string hardwareName: string
count: number count: number
wattHours: number | null
tdpMax: number | null
deviceType: string | null
}> }>
performanceTiers: Array<{ performanceTiers: Array<{
hardwareSlug: string hardwareSlug: string
@@ -199,6 +208,16 @@ interface StatsResponse {
fpsOnePercentLow: number fpsOnePercentLow: number
stabilityRatio: number stabilityRatio: number
}> }>
batteryLife: Array<{
id: string
hardwareSlug: string
tdpWatts: number
estimatedBatteryMin: number
estimatedBatteryHours: number
wattHours: number | null
tdpMax: number | null
estimatedAtMaxTdpMin: number | null
}>
filterOptions: { filterOptions: {
protonVersions: string[] protonVersions: string[]
osVersions: string[] osVersions: string[]
@@ -677,6 +696,7 @@ export function GamePageClient({
deviceBreakdown: filterByDevice(stats.deviceBreakdown), deviceBreakdown: filterByDevice(stats.deviceBreakdown),
performanceTiers: filterByDevice(stats.performanceTiers), performanceTiers: filterByDevice(stats.performanceTiers),
stabilityScatter: filterByDevice(stats.stabilityScatter), stabilityScatter: filterByDevice(stats.stabilityScatter),
batteryLife: filterByDevice(stats.batteryLife),
} }
}, [stats, selectedDevices, filters]) }, [stats, selectedDevices, filters])
@@ -1089,6 +1109,11 @@ export function GamePageClient({
</p> </p>
</div> </div>
<div className='flex items-center gap-2 text-xs text-text/60 shrink-0'> <div className='flex items-center gap-2 text-xs text-text/60 shrink-0'>
{preset.youtubeVideoId && (
<svg className="h-3 w-3 text-red-400 shrink-0" viewBox="0 0 24 24" fill="currentColor">
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
</svg>
)}
<span className='flex items-center gap-0.5'> <span className='flex items-center gap-0.5'>
<ThumbsUpIcon className='h-3 w-3' /> <ThumbsUpIcon className='h-3 w-3' />
{preset.upvotes} {preset.upvotes}
@@ -1132,6 +1157,27 @@ export function GamePageClient({
</div> </div>
)} )}
{/* Power / Battery quick-look */}
{(() => {
const dev = stats?.deviceBreakdown.find((d) => d.hardwareSlug === preset.hardwareSlug)
const isHandheld = dev?.deviceType === "handheld"
const wh = dev?.wattHours ?? null
const tdp = preset.tdpWatts ?? null
const estHours = wh && tdp && tdp > 0 ? wh / tdp : null
if (!isHandheld || (!tdp && !wh)) return null
return (
<div className="text-[11px] text-text/50 flex items-center gap-1.5 flex-wrap">
{tdp && <span> {Math.round(tdp)}W</span>}
{wh && <span>🔋 {Math.round(wh)}Wh</span>}
{estHours !== null && (
<span> ~{estHours.toFixed(1)}h</span>
)}
</div>
)
})()}
{/* Technology tags */} {/* Technology tags */}
<div className='flex flex-wrap items-center gap-1.5'> <div className='flex flex-wrap items-center gap-1.5'>
{preset.upscalerType && {preset.upscalerType &&
@@ -1254,6 +1300,11 @@ export function GamePageClient({
</p> </p>
</div> </div>
<div className='flex items-center gap-2 text-xs text-text/60 shrink-0'> <div className='flex items-center gap-2 text-xs text-text/60 shrink-0'>
{preset.youtubeVideoId && (
<svg className="h-3 w-3 text-red-400 shrink-0" viewBox="0 0 24 24" fill="currentColor">
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
</svg>
)}
<span className='flex items-center gap-0.5'> <span className='flex items-center gap-0.5'>
<ThumbsUpIcon className='h-3 w-3' /> <ThumbsUpIcon className='h-3 w-3' />
{preset.upvotes} {preset.upvotes}
@@ -1297,6 +1348,27 @@ export function GamePageClient({
</div> </div>
)} )}
{/* Power / Battery quick-look */}
{(() => {
const dev = stats?.deviceBreakdown.find((d) => d.hardwareSlug === preset.hardwareSlug)
const isHandheld = dev?.deviceType === "handheld"
const wh = dev?.wattHours ?? null
const tdp = preset.tdpWatts ?? null
const estHours = wh && tdp && tdp > 0 ? wh / tdp : null
if (!isHandheld || (!tdp && !wh)) return null
return (
<div className="text-[11px] text-text/50 flex items-center gap-1.5 flex-wrap">
{tdp && <span> {Math.round(tdp)}W</span>}
{wh && <span>🔋 {Math.round(wh)}Wh</span>}
{estHours !== null && (
<span> ~{estHours.toFixed(1)}h</span>
)}
</div>
)
})()}
{/* Technology tags */} {/* Technology tags */}
<div className='flex flex-wrap items-center gap-1.5'> <div className='flex flex-wrap items-center gap-1.5'>
{preset.upscalerType && {preset.upscalerType &&
@@ -1491,6 +1563,19 @@ export function GamePageClient({
</div> </div>
)} )}
{/* Battery Life Estimates */}
{filteredStats && filteredStats.batteryLife && filteredStats.batteryLife.length > 0 && (
<div className="rounded-xl border border-border bg-text/3 p-4">
<h3 className="text-sm font-medium text-text/80 mb-2">
Battery Life Estimates
</h3>
<BatteryLifeChart
data={filteredStats.batteryLife}
deviceNames={Object.fromEntries(filteredStats.deviceBreakdown.map((d) => [d.hardwareSlug, d.hardwareName]))}
/>
</div>
)}
</div> </div>
</motion.div> </motion.div>
+45
View File
@@ -11,9 +11,11 @@ import {
gamePlatformSupport, gamePlatformSupport,
hardware, hardware,
user, user,
entryScreenshots,
} from "@/lib/db/schema" } from "@/lib/db/schema"
import { and, desc, eq, sql } from "drizzle-orm" import { and, desc, eq, sql } from "drizzle-orm"
import { isSyncStale, syncSteamGame, ensureSteamGame } from "@/lib/steam/sync" import { isSyncStale, syncSteamGame, ensureSteamGame } from "@/lib/steam/sync"
import { getR2PublicUrl } from "@/lib/storage"
import { GamePageClient } from "./game-page-client" import { GamePageClient } from "./game-page-client"
// This page needs live data — skip static generation at build time // This page needs live data — skip static generation at build time
@@ -177,6 +179,8 @@ export default async function GamePage({
loadTimeSsd: performanceEntries.loadTimeSsd, loadTimeSsd: performanceEntries.loadTimeSsd,
loadTimeSd: performanceEntries.loadTimeSd, loadTimeSd: performanceEntries.loadTimeSd,
estimatedBatteryMin: performanceEntries.estimatedBatteryMin, estimatedBatteryMin: performanceEntries.estimatedBatteryMin,
tdpWatts: performanceEntries.tdpWatts,
youtubeVideoId: performanceEntries.youtubeVideoId,
customSystem: performanceEntries.customSystem, customSystem: performanceEntries.customSystem,
userNotes: performanceEntries.userNotes, userNotes: performanceEntries.userNotes,
verifiedAt: performanceEntries.verifiedAt, verifiedAt: performanceEntries.verifiedAt,
@@ -278,6 +282,11 @@ export default async function GamePage({
loadTimeSsd: p.loadTimeSsd ?? null, loadTimeSsd: p.loadTimeSsd ?? null,
loadTimeSd: p.loadTimeSd ?? null, loadTimeSd: p.loadTimeSd ?? null,
estimatedBatteryMin: p.estimatedBatteryMin ?? null, estimatedBatteryMin: p.estimatedBatteryMin ?? null,
tdpWatts: p.tdpWatts ?? null,
youtubeVideoId: p.youtubeVideoId ?? null,
screenshots: null,
hardwareWattHours: null,
hardwareDeviceType: null,
customSystem: p.customSystem ?? false, customSystem: p.customSystem ?? false,
userNotes: p.userNotes, userNotes: p.userNotes,
userId: p.userId, userId: p.userId,
@@ -289,6 +298,42 @@ export default async function GamePage({
pinnedAt: p.pinnedAt ? p.pinnedAt.toISOString() : null, pinnedAt: p.pinnedAt ? p.pinnedAt.toISOString() : null,
})) }))
// Fetch screenshots and hardware details for each preset
const publicUrl = getR2PublicUrl()
for (const preset of serializedPresets) {
const screenshots = await db
.select({
id: entryScreenshots.id,
storageKey: entryScreenshots.storageKey,
orderIndex: entryScreenshots.orderIndex,
width: entryScreenshots.width,
height: entryScreenshots.height,
})
.from(entryScreenshots)
.where(eq(entryScreenshots.entryId, preset.id))
.orderBy(entryScreenshots.orderIndex)
preset.screenshots = screenshots.map((ss) => ({
id: ss.id,
url: `${publicUrl}/${ss.storageKey}`,
width: ss.width,
height: ss.height,
orderIndex: ss.orderIndex,
})) as any
const [hw] = await db
.select({
wattHours: hardware.wattHours,
deviceType: hardware.deviceType,
})
.from(hardware)
.where(eq(hardware.slug, preset.hardwareSlug))
.limit(1)
preset.hardwareWattHours = hw?.wattHours ? Number(hw.wattHours) : null as any
preset.hardwareDeviceType = (hw?.deviceType ?? null) as any
}
return ( return (
<> <>
<script <script
+51
View File
@@ -43,6 +43,11 @@ interface Preset {
loadTimeSsd: number | null loadTimeSsd: number | null
loadTimeSd: number | null loadTimeSd: number | null
estimatedBatteryMin: number | null estimatedBatteryMin: number | null
tdpWatts: number | null
youtubeVideoId: string | null
screenshots: Array<{ id: string; url: string; width: number; height: number }> | null
hardwareWattHours: number | null
hardwareDeviceType: string | null
customSystem: boolean customSystem: boolean
userNotes: string | null userNotes: string | null
userId: string userId: string
@@ -329,6 +334,15 @@ export function PresetDetailModal({
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<MetaItem label="Proton" value={preset.protonVersion} /> <MetaItem label="Proton" value={preset.protonVersion} />
<MetaItem label="OS" value={preset.osVersion} /> <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 <MetaItem
label="Upscaler" label="Upscaler"
value={ value={
@@ -366,6 +380,26 @@ export function PresetDetailModal({
<div className="h-px bg-border" /> <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 mt-auto">
{isAdmin && ( {isAdmin && (
@@ -505,6 +539,23 @@ export function PresetDetailModal({
{/* Right panel */} {/* Right panel */}
<div className="flex-1 overflow-y-auto p-5"> <div className="flex-1 overflow-y-auto p-5">
{/* YouTube Video */}
{preset.youtubeVideoId && (
<div className="mb-4">
<div className="relative w-full" style={{ paddingBottom: "56.25%" }}>
<iframe
src={`https://www.youtube-nocookie.com/embed/${preset.youtubeVideoId}`}
className="absolute inset-0 w-full h-full rounded-lg"
allow="accelerometer; autoplay; encrypted-media; picture-in-picture"
sandbox="allow-scripts allow-same-origin allow-presentation"
allowFullScreen
loading="lazy"
title="Gameplay Video"
/>
</div>
</div>
)}
{hasCategories ? ( {hasCategories ? (
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.div <motion.div