feat: power quick-look bar, YouTube indicator, battery chart, screenshot gallery on game details
This commit is contained in:
@@ -43,6 +43,7 @@ import { FpsRangeChart } from "@/components/charts/FpsRangeChart"
|
||||
import { DeviceDonut } from "@/components/charts/DeviceDonut"
|
||||
import { PerformanceTierChart } from "@/components/charts/PerformanceTierChart"
|
||||
import { StabilityScatterChart } from "@/components/charts/StabilityScatterChart"
|
||||
import { BatteryLifeChart } from "@/components/charts/BatteryLifeChart"
|
||||
|
||||
// Comments
|
||||
import { CommentSection } from "@/components/comments/comment-section"
|
||||
@@ -122,6 +123,9 @@ interface Preset {
|
||||
loadTimeSsd: number | null
|
||||
loadTimeSd: 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
|
||||
userNotes: string | null
|
||||
userId: string
|
||||
@@ -131,6 +135,8 @@ interface Preset {
|
||||
isPinned: boolean
|
||||
pinnedAt: string | null
|
||||
createdAt: string
|
||||
hardwareWattHours: number | null
|
||||
hardwareDeviceType: string | null
|
||||
}
|
||||
|
||||
interface StatsResponse {
|
||||
@@ -184,6 +190,9 @@ interface StatsResponse {
|
||||
hardwareSlug: string
|
||||
hardwareName: string
|
||||
count: number
|
||||
wattHours: number | null
|
||||
tdpMax: number | null
|
||||
deviceType: string | null
|
||||
}>
|
||||
performanceTiers: Array<{
|
||||
hardwareSlug: string
|
||||
@@ -199,6 +208,16 @@ interface StatsResponse {
|
||||
fpsOnePercentLow: 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: {
|
||||
protonVersions: string[]
|
||||
osVersions: string[]
|
||||
@@ -677,6 +696,7 @@ export function GamePageClient({
|
||||
deviceBreakdown: filterByDevice(stats.deviceBreakdown),
|
||||
performanceTiers: filterByDevice(stats.performanceTiers),
|
||||
stabilityScatter: filterByDevice(stats.stabilityScatter),
|
||||
batteryLife: filterByDevice(stats.batteryLife),
|
||||
}
|
||||
}, [stats, selectedDevices, filters])
|
||||
|
||||
@@ -1089,6 +1109,11 @@ export function GamePageClient({
|
||||
</p>
|
||||
</div>
|
||||
<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'>
|
||||
<ThumbsUpIcon className='h-3 w-3' />
|
||||
{preset.upvotes}
|
||||
@@ -1132,6 +1157,27 @@ export function GamePageClient({
|
||||
</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 */}
|
||||
<div className='flex flex-wrap items-center gap-1.5'>
|
||||
{preset.upscalerType &&
|
||||
@@ -1254,6 +1300,11 @@ export function GamePageClient({
|
||||
</p>
|
||||
</div>
|
||||
<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'>
|
||||
<ThumbsUpIcon className='h-3 w-3' />
|
||||
{preset.upvotes}
|
||||
@@ -1297,6 +1348,27 @@ export function GamePageClient({
|
||||
</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 */}
|
||||
<div className='flex flex-wrap items-center gap-1.5'>
|
||||
{preset.upscalerType &&
|
||||
@@ -1491,6 +1563,19 @@ export function GamePageClient({
|
||||
</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>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
gamePlatformSupport,
|
||||
hardware,
|
||||
user,
|
||||
entryScreenshots,
|
||||
} from "@/lib/db/schema"
|
||||
import { and, desc, eq, sql } from "drizzle-orm"
|
||||
import { isSyncStale, syncSteamGame, ensureSteamGame } from "@/lib/steam/sync"
|
||||
import { getR2PublicUrl } from "@/lib/storage"
|
||||
import { GamePageClient } from "./game-page-client"
|
||||
|
||||
// This page needs live data — skip static generation at build time
|
||||
@@ -177,6 +179,8 @@ export default async function GamePage({
|
||||
loadTimeSsd: performanceEntries.loadTimeSsd,
|
||||
loadTimeSd: performanceEntries.loadTimeSd,
|
||||
estimatedBatteryMin: performanceEntries.estimatedBatteryMin,
|
||||
tdpWatts: performanceEntries.tdpWatts,
|
||||
youtubeVideoId: performanceEntries.youtubeVideoId,
|
||||
customSystem: performanceEntries.customSystem,
|
||||
userNotes: performanceEntries.userNotes,
|
||||
verifiedAt: performanceEntries.verifiedAt,
|
||||
@@ -278,6 +282,11 @@ export default async function GamePage({
|
||||
loadTimeSsd: p.loadTimeSsd ?? null,
|
||||
loadTimeSd: p.loadTimeSd ?? null,
|
||||
estimatedBatteryMin: p.estimatedBatteryMin ?? null,
|
||||
tdpWatts: p.tdpWatts ?? null,
|
||||
youtubeVideoId: p.youtubeVideoId ?? null,
|
||||
screenshots: null,
|
||||
hardwareWattHours: null,
|
||||
hardwareDeviceType: null,
|
||||
customSystem: p.customSystem ?? false,
|
||||
userNotes: p.userNotes,
|
||||
userId: p.userId,
|
||||
@@ -289,6 +298,42 @@ export default async function GamePage({
|
||||
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 (
|
||||
<>
|
||||
<script
|
||||
|
||||
@@ -43,6 +43,11 @@ interface Preset {
|
||||
loadTimeSsd: number | null
|
||||
loadTimeSd: 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
|
||||
userNotes: string | null
|
||||
userId: string
|
||||
@@ -329,6 +334,15 @@ export function PresetDetailModal({
|
||||
<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={
|
||||
@@ -366,6 +380,26 @@ export function PresetDetailModal({
|
||||
|
||||
<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 */}
|
||||
<div className="flex flex-wrap items-center gap-2 mt-auto">
|
||||
{isAdmin && (
|
||||
@@ -505,6 +539,23 @@ export function PresetDetailModal({
|
||||
|
||||
{/* Right panel */}
|
||||
<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 ? (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
|
||||
Reference in New Issue
Block a user