feat: horizontal preset layout, detail modal integration, reorder above stats
This commit is contained in:
+236
-151
@@ -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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user