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 { 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user