feat: horizontal preset layout, detail modal integration, reorder above stats

This commit is contained in:
2026-04-27 15:58:35 +08:00
parent 830c14fd3e
commit d2a4e58b74
2 changed files with 244 additions and 151 deletions
+236 -151
View File
@@ -12,11 +12,16 @@ import {
SparklesIcon, SparklesIcon,
DatabaseIcon, DatabaseIcon,
Plus, Plus,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react" } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { useSession } from "@/lib/auth-client" import { useSession } from "@/lib/auth-client"
import { FaSteam } from "react-icons/fa" import { FaSteam } from "react-icons/fa"
import { motion } from "motion/react" import { motion, AnimatePresence } from "motion/react"
import type { GameSettingCategory } from "@/lib/db/schema/performanceEntries"
import { PresetDetailModal } from "./preset-detail-modal"
import { BookmarkButton } from "@/components/saved-games/bookmark-button" import { BookmarkButton } from "@/components/saved-games/bookmark-button"
@@ -67,6 +72,8 @@ interface Preset {
hardwareSlug: string hardwareSlug: string
hardwareName: string hardwareName: string
upvotes: number upvotes: number
downvotes: number
settingsJson: GameSettingCategory[] | null
settingsCount: number settingsCount: number
fpsAvg: number | null fpsAvg: number | null
fpsLow: number | null fpsLow: number | null
@@ -76,6 +83,12 @@ interface Preset {
frameGenMethod: string | null frameGenMethod: string | null
protonVersion: string | null protonVersion: string | null
osVersion: string | null osVersion: string | null
launchOptions: string | null
userNotes: string | null
userId: string
userName: string | null
userImage: string | null
verifiedAt: string | null
createdAt: string createdAt: string
} }
@@ -207,6 +220,38 @@ export function GamePageClient({
frameGen: "all", frameGen: "all",
}) })
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null)
const [reportedPresets, setReportedPresets] = useState<Set<string>>(new Set())
const presetsRef = useRef<HTMLDivElement>(null)
const handleDeletePreset = async (presetId: string) => {
try {
const res = await fetch(`/api/performance/${presetId}/user-delete`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
})
if (res.ok) {
setSelectedPresetId(null)
}
} catch (err) {
console.error("Failed to delete preset:", err)
}
}
const handleReportPreset = async (presetId: string, reason: "inaccurate" | "spam" | "inappropriate" | "other", details?: string) => {
try {
const res = await fetch(`/api/performance/${presetId}/report`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason, details }),
})
if (res.ok) {
setReportedPresets((prev) => new Set(prev).add(presetId))
}
} catch (err) {
console.error("Failed to report preset:", err)
}
}
const handleImgError = useCallback(() => setImgError(true), []) const handleImgError = useCallback(() => setImgError(true), [])
@@ -322,6 +367,7 @@ export function GamePageClient({
}, []) }, [])
return ( return (
<>
<section className='w-full flex flex-col gap-8 pb-16'> <section className='w-full flex flex-col gap-8 pb-16'>
{/* Section 1: Hero Header */} {/* Section 1: Hero Header */}
<motion.div <motion.div
@@ -673,12 +719,184 @@ export function GamePageClient({
</div> </div>
</motion.div> </motion.div>
{/* Section 4: Statistics Dashboard */} {/* Section 4: Community Presets */}
<motion.div <motion.div
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }} transition={{ duration: 0.4, delay: 0.2 }}
className='px-4 md:px-[10svw]' className='px-4 md:px-[10svw]'
>
<div className='max-w-7xl mx-auto flex flex-col gap-4'>
<div className='flex items-center justify-between'>
<h2 className='text-lg font-semibold'>
Community Presets
</h2>
<span className='text-xs text-text/50'>
{filteredPresets.length} preset
{filteredPresets.length !== 1 ? "s" : ""}
</span>
</div>
{filteredPresets.length === 0 ? (
<div className='flex flex-col items-center justify-center py-16 gap-3 rounded-xl border border-border bg-text/2'>
<SettingsIcon className='h-10 w-10 text-text/20' />
<p className='text-sm text-text/40'>
No presets match the selected filters
</p>
</div>
) : (
<div className="relative group/presets">
{/* Left scroll button */}
<button
onClick={() => presetsRef.current?.scrollBy({ left: -300, behavior: "smooth" })}
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 hidden md:flex items-center justify-center h-10 w-10 rounded-full bg-background/80 border border-border hover:bg-text/5 opacity-0 group-hover/presets:opacity-100 transition-opacity"
>
<ChevronLeftIcon className="h-5 w-5" />
</button>
{/* Right scroll button */}
<button
onClick={() => presetsRef.current?.scrollBy({ left: 300, behavior: "smooth" })}
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 hidden md:flex items-center justify-center h-10 w-10 rounded-full bg-background/80 border border-border hover:bg-text/5 opacity-0 group-hover/presets:opacity-100 transition-opacity"
>
<ChevronRightIcon className="h-5 w-5" />
</button>
<div
ref={presetsRef}
className="flex gap-4 overflow-x-auto scrollbar-hide pb-4"
>
{[...filteredPresets]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.map((preset) => {
const raw = isRawPerformerPreset(preset)
const fpsColor = getFpsColor(preset)
return (
<motion.div
key={preset.id}
layoutId={preset.id}
onClick={() => setSelectedPresetId(preset.id)}
className={`flex-shrink-0 w-72 flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer hover:border-primary/30 ${
raw
? "border-green-500/30 bg-green-500/5"
: "border-border bg-text/3"
}`}
>
{/* Header */}
<div className='flex items-start justify-between gap-2'>
<div className='min-w-0'>
<div className='flex items-center gap-2'>
<h3 className='font-semibold text-sm truncate'>
{generatePresetName(preset)}
</h3>
{raw && (
<span className='inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-green-500/10 border border-green-500/20 text-green-400 text-[9px] font-semibold'>
<SparklesIcon className='h-2.5 w-2.5' />
Raw
</span>
)}
{isPoorPerformancePreset(preset) && preset.fpsAvg !== null && (
<span className='inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 text-[9px] font-semibold'>
Slow
</span>
)}
</div>
<p className='text-xs text-text/50 mt-0.5'>
{preset.hardwareName}
</p>
</div>
<div className='flex items-center gap-1 text-xs text-text/60 shrink-0'>
<TrendingUpIcon className='h-3 w-3' />
{preset.upvotes}
</div>
</div>
{/* Settings count */}
<div className='text-xs text-text/50'>
{preset.settingsCount} settings
</div>
{/* FPS */}
{preset.fpsAvg !== null && (
<div
className={`text-sm tabular-nums ${fpsColor}`}
>
<span className='font-semibold'>
{preset.fpsAvg}
</span>
<span className='text-text/40'>
{" "}
avg
</span>
{preset.fpsLow !== null && (
<span className='text-text/40'>
{" "}
({preset.fpsLow}
{preset.fpsHigh})
</span>
)}
</div>
)}
{/* Technology tags */}
<div className='flex flex-wrap items-center gap-1.5'>
{preset.upscalerType &&
preset.upscalerType !==
"none" && (
<span className='px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-500/10 text-purple-400 border border-purple-500/20'>
{preset.upscalerType.toUpperCase()}
{preset.upscalerVersion ? ` ${preset.upscalerVersion}` : ""}
</span>
)}
{preset.frameGenMethod &&
preset.frameGenMethod !==
"none" && (
<span
className={`px-1.5 py-0.5 rounded text-[10px] font-medium border ${
preset.frameGenMethod ===
"dlss_fg"
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
: "bg-orange-500/10 text-orange-400 border-orange-500/20"
}`}
>
{preset.frameGenMethod ===
"fsr_fg"
? "FSR FG"
: preset.frameGenMethod ===
"dlss_fg"
? "DLSS FG"
: preset.frameGenMethod}
</span>
)}
</div>
{/* Proton + OS */}
<div className='flex flex-wrap items-center gap-2 text-[10px] text-text/40'>
{preset.protonVersion && (
<span>
Proton{" "}
{preset.protonVersion}
</span>
)}
{preset.osVersion && (
<span>
SteamOS {preset.osVersion}
</span>
)}
</div>
</motion.div>
)
})}
</div>
</div>
)}
</div>
</motion.div>
{/* Section 5: Statistics Dashboard */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.25 }}
className='px-4 md:px-[10svw]'
> >
<div className='max-w-7xl mx-auto flex flex-col gap-6'> <div className='max-w-7xl mx-auto flex flex-col gap-6'>
{/* Row 1 — Featured */} {/* Row 1 — Featured */}
@@ -792,156 +1010,23 @@ export function GamePageClient({
)} )}
</div> </div>
</motion.div> </motion.div>
{/* Section 5: Community Presets */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.25 }}
className='px-4 md:px-[10svw]'
>
<div className='max-w-7xl mx-auto flex flex-col gap-4'>
<div className='flex items-center justify-between'>
<h2 className='text-lg font-semibold'>
Community Presets
</h2>
<span className='text-xs text-text/50'>
{filteredPresets.length} preset
{filteredPresets.length !== 1 ? "s" : ""}
</span>
</div>
{filteredPresets.length === 0 ? (
<div className='flex flex-col items-center justify-center py-16 gap-3 rounded-xl border border-border bg-text/2'>
<SettingsIcon className='h-10 w-10 text-text/20' />
<p className='text-sm text-text/40'>
No presets match the selected filters
</p>
</div>
) : (
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'>
{filteredPresets.map((preset) => {
const raw = isRawPerformerPreset(preset)
const fpsColor = getFpsColor(preset)
return (
<div
key={preset.id}
className={`flex flex-col gap-3 p-4 rounded-xl border transition-colors ${
raw
? "border-green-500/30 bg-green-500/5"
: "border-border bg-text/3"
}`}
>
{/* Header */}
<div className='flex items-start justify-between gap-2'>
<div className='min-w-0'>
<div className='flex items-center gap-2'>
<h3 className='font-semibold text-sm truncate'>
{generatePresetName(preset)}
</h3>
{raw && (
<span className='inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-green-500/10 border border-green-500/20 text-green-400 text-[9px] font-semibold'>
<SparklesIcon className='h-2.5 w-2.5' />
Raw
</span>
)}
{isPoorPerformancePreset(preset) && preset.fpsAvg !== null && (
<span className='inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 text-[9px] font-semibold'>
Slow
</span>
)}
</div>
<p className='text-xs text-text/50 mt-0.5'>
{preset.hardwareName}
</p>
</div>
<div className='flex items-center gap-1 text-xs text-text/60 shrink-0'>
<TrendingUpIcon className='h-3 w-3' />
{preset.upvotes}
</div>
</div>
{/* Settings count */}
<div className='text-xs text-text/50'>
{preset.settingsCount} settings
</div>
{/* FPS */}
{preset.fpsAvg !== null && (
<div
className={`text-sm tabular-nums ${fpsColor}`}
>
<span className='font-semibold'>
{preset.fpsAvg}
</span>
<span className='text-text/40'>
{" "}
avg
</span>
{preset.fpsLow !== null && (
<span className='text-text/40'>
{" "}
({preset.fpsLow}
{preset.fpsHigh})
</span>
)}
</div>
)}
{/* Technology tags */}
<div className='flex flex-wrap items-center gap-1.5'>
{preset.upscalerType &&
preset.upscalerType !==
"none" && (
<span className='px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-500/10 text-purple-400 border border-purple-500/20'>
{preset.upscalerType.toUpperCase()}
{preset.upscalerVersion ? ` ${preset.upscalerVersion}` : ""}
</span>
)}
{preset.frameGenMethod &&
preset.frameGenMethod !==
"none" && (
<span
className={`px-1.5 py-0.5 rounded text-[10px] font-medium border ${
preset.frameGenMethod ===
"dlss_fg"
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
: "bg-orange-500/10 text-orange-400 border-orange-500/20"
}`}
>
{preset.frameGenMethod ===
"fsr_fg"
? "FSR FG"
: preset.frameGenMethod ===
"dlss_fg"
? "DLSS FG"
: preset.frameGenMethod}
</span>
)}
</div>
{/* Proton + OS */}
<div className='flex flex-wrap items-center gap-2 text-[10px] text-text/40'>
{preset.protonVersion && (
<span>
Proton{" "}
{preset.protonVersion}
</span>
)}
{preset.osVersion && (
<span>
SteamOS {preset.osVersion}
</span>
)}
</div>
</div>
)
})}
</div>
)}
</div>
</motion.div>
</section> </section>
<AnimatePresence>
{selectedPresetId && (() => {
const preset = filteredPresets.find((p) => p.id === selectedPresetId)
if (!preset) return null
return (
<PresetDetailModal
preset={preset}
onClose={() => setSelectedPresetId(null)}
onDelete={handleDeletePreset}
onReport={handleReportPreset}
hasReported={reportedPresets.has(preset.id)}
/>
)
})()}
</AnimatePresence>
</>
) )
} }
+8
View File
@@ -17,3 +17,11 @@
/* Fonts */ /* Fonts */
--font-sans: var(--font-lexend); --font-sans: var(--font-lexend);
} }
.scrollbar-hide {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}