feat: refactor preset modal — tabbed layout, metadata groups, screenshot lightbox, remove v prefix (Task 2.1)

This commit is contained in:
2026-05-10 15:20:14 +08:00
parent 303971424d
commit 6211683c70
+208 -161
View File
@@ -20,6 +20,9 @@ import {
PencilIcon, PencilIcon,
} from "lucide-react" } from "lucide-react"
import { TiptapRenderer } from "@/components/tiptap-renderer" import { TiptapRenderer } from "@/components/tiptap-renderer"
import { ScreenshotLightbox } from "@/components/ui/screenshot-lightbox"
type TabKey = "details" | "media" | "settings" | "notes"
interface Preset { interface Preset {
id: string id: string
@@ -85,6 +88,37 @@ function formatValue(value: string | number | boolean): string {
return String(value) return String(value)
} }
function MetaItem({
label,
value,
}: {
label: string
value: React.ReactNode | string | null
}) {
return (
<div className="flex items-center justify-between py-1">
<span className="text-xs text-text/50">{label}</span>
<span className="text-sm text-text/80 font-medium">{value ?? "—"}</span>
</div>
)
}
function hasPerformanceData(p: Preset): boolean {
return p.fpsAvg !== null || p.fpsOnePercentLow !== null || p.loadTimeSsd !== null || p.loadTimeSd !== null
}
function hasHardwarePowerData(p: Preset): boolean {
return p.tdpWatts !== null || p.hardwareWattHours !== null
}
function hasSoftwareData(p: Preset): boolean {
return !!(p.protonVersion || p.osVersion || (p.upscalerType && p.upscalerType !== "none") || (p.frameGenMethod && p.frameGenMethod !== "none") || p.launchOptions)
}
function hasGameInfoData(p: Preset): boolean {
return !!(p.versionString || p.buildId || p.gameAntiCheatName)
}
export function PresetDetailModal({ export function PresetDetailModal({
preset, preset,
gameId, gameId,
@@ -108,7 +142,9 @@ export function PresetDetailModal({
const [userVote, setUserVote] = useState<"up" | "down" | null>(null) const [userVote, setUserVote] = useState<"up" | "down" | null>(null)
const [localUpvotes, setLocalUpvotes] = useState(preset.upvotes) const [localUpvotes, setLocalUpvotes] = useState(preset.upvotes)
const [localDownvotes, setLocalDownvotes] = useState(preset.downvotes) const [localDownvotes, setLocalDownvotes] = useState(preset.downvotes)
const [mobileTab, setMobileTab] = useState<"details" | "settings">("settings") const [activeTab, setActiveTab] = useState<TabKey>("media")
const [lightboxOpen, setLightboxOpen] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState(0)
const isOwner = session?.user?.id === preset.userId const isOwner = session?.user?.id === preset.userId
const isAdmin = session?.user?.role === "admin" const isAdmin = session?.user?.role === "admin"
@@ -162,11 +198,7 @@ export function PresetDetailModal({
} }
const handleReportSubmit = () => { const handleReportSubmit = () => {
onReport( onReport(preset.id, reportReason, reportDetails.trim() || undefined)
preset.id,
reportReason,
reportDetails.trim() || undefined,
)
setShowReportForm(false) setShowReportForm(false)
setReportDetails("") setReportDetails("")
} }
@@ -183,6 +215,9 @@ export function PresetDetailModal({
} }
} }
const visibleTabs: TabKey[] = ["details", "media", "settings"]
if (preset.userNotes) visibleTabs.push("notes")
return ( return (
<AnimatePresence> <AnimatePresence>
<> <>
@@ -219,7 +254,7 @@ export function PresetDetailModal({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Header */} {/* Header */}
<div className="flex items-start justify-between p-5 border-b border-border shrink-0"> <div className="flex items-center justify-between p-5 border-b border-border shrink-0">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<h2 className="text-base font-semibold text-text truncate"> <h2 className="text-base font-semibold text-text truncate">
{preset.hardwareName} {preset.hardwareName}
@@ -236,33 +271,26 @@ export function PresetDetailModal({
{/* Mobile tabs */} {/* Mobile tabs */}
<div className="flex md:hidden shrink-0 border-b border-border"> <div className="flex md:hidden shrink-0 border-b border-border">
{visibleTabs.map((tab) => (
<button <button
onClick={() => setMobileTab("details")} key={tab}
className={`flex-1 py-2.5 text-sm font-medium transition-colors cursor-pointer ${ onClick={() => setActiveTab(tab)}
mobileTab === "details" className={`flex-1 py-2.5 text-sm font-medium transition-colors capitalize cursor-pointer ${
activeTab === tab
? "text-primary border-b-2 border-primary" ? "text-primary border-b-2 border-primary"
: "text-text/50 hover:text-text/70" : "text-text/50 hover:text-text/70"
}`} }`}
> >
Details {tab}
</button>
<button
onClick={() => setMobileTab("settings")}
className={`flex-1 py-2.5 text-sm font-medium transition-colors cursor-pointer ${
mobileTab === "settings"
? "text-primary border-b-2 border-primary"
: "text-text/50 hover:text-text/70"
}`}
>
Settings
</button> </button>
))}
</div> </div>
{/* Two-column body */} {/* Two-column body */}
<div className="flex flex-col md:flex-row overflow-hidden flex-1"> <div className="flex flex-col md:flex-row overflow-hidden flex-1">
{/* Left panel */} {/* Left panel — Details (always visible on desktop, tab on mobile) */}
<div className={`w-full md:w-1/3 md:min-w-60 flex-col gap-3 md:gap-4 p-4 md:p-5 border-b md:border-b-0 md:border-r border-border overflow-y-auto ${ <div className={`w-full md:w-1/3 md:min-w-60 flex-col gap-3 md:gap-4 p-4 md:p-5 border-b md:border-b-0 md:border-r border-border overflow-y-auto ${
mobileTab === "details" ? "flex" : "hidden md:flex" activeTab === "details" ? "flex" : "hidden md:flex"
}`}> }`}>
{/* User info */} {/* User info */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -323,14 +351,9 @@ export function PresetDetailModal({
</button> </button>
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-text/50"> <span className="text-xs text-text/50">Are you sure?</span>
Are you sure?
</span>
<button <button
onClick={() => { onClick={() => { onDelete(preset.id); setShowDeleteConfirm(false) }}
onDelete(preset.id)
setShowDeleteConfirm(false)
}}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium text-red-400 border border-red-500/20 hover:bg-red-500/10 transition-colors cursor-pointer" className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium text-red-400 border border-red-500/20 hover:bg-red-500/10 transition-colors cursor-pointer"
> >
Confirm Confirm
@@ -345,7 +368,6 @@ export function PresetDetailModal({
)} )}
</> </>
)} )}
{(isOwner || isAdmin) && ( {(isOwner || isAdmin) && (
<button <button
onClick={() => router.push(`/game/${gameId}/submit?edit=${preset.id}`)} onClick={() => router.push(`/game/${gameId}/submit?edit=${preset.id}`)}
@@ -355,7 +377,6 @@ export function PresetDetailModal({
Edit Edit
</button> </button>
)} )}
<button <button
onClick={handleShare} onClick={handleShare}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium text-primary border border-primary/20 hover:bg-primary/10 transition-colors cursor-pointer" className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium text-primary border border-primary/20 hover:bg-primary/10 transition-colors cursor-pointer"
@@ -363,7 +384,6 @@ export function PresetDetailModal({
<ShareIcon className="h-4 w-4" /> <ShareIcon className="h-4 w-4" />
{copied ? "Link copied!" : "Share"} {copied ? "Link copied!" : "Share"}
</button> </button>
{session && !hasReported && ( {session && !hasReported && (
<> <>
{!showReportForm ? ( {!showReportForm ? (
@@ -378,31 +398,17 @@ export function PresetDetailModal({
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
<select <select
value={reportReason} value={reportReason}
onChange={(e) => onChange={(e) => setReportReason(e.target.value as typeof reportReason)}
setReportReason(
e.target.value as typeof reportReason,
)
}
className="text-sm bg-background border border-border rounded-md px-2 py-1.5 text-text/80 focus:outline-none focus:border-primary w-full max-w-xs" className="text-sm bg-background border border-border rounded-md px-2 py-1.5 text-text/80 focus:outline-none focus:border-primary w-full max-w-xs"
> >
<option value="inaccurate"> <option value="inaccurate">Inaccurate data</option>
Inaccurate data <option value="spam">Spam</option>
</option> <option value="inappropriate">Inappropriate</option>
<option value="spam"> <option value="other">Other</option>
Spam
</option>
<option value="inappropriate">
Inappropriate
</option>
<option value="other">
Other
</option>
</select> </select>
<textarea <textarea
value={reportDetails} value={reportDetails}
onChange={(e) => onChange={(e) => setReportDetails(e.target.value)}
setReportDetails(e.target.value)
}
placeholder="Additional details (optional)" placeholder="Additional details (optional)"
rows={3} rows={3}
className="text-sm bg-background border border-border rounded-md px-2 py-1.5 text-text/80 focus:outline-none focus:border-primary w-full resize-none" className="text-sm bg-background border border-border rounded-md px-2 py-1.5 text-text/80 focus:outline-none focus:border-primary w-full resize-none"
@@ -415,10 +421,7 @@ export function PresetDetailModal({
Submit Report Submit Report
</button> </button>
<button <button
onClick={() => { onClick={() => { setShowReportForm(false); setReportDetails("") }}
setShowReportForm(false)
setReportDetails("")
}}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium text-text/50 border border-border hover:bg-text/5 transition-colors cursor-pointer" className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium text-text/50 border border-border hover:bg-text/5 transition-colors cursor-pointer"
> >
Cancel Cancel
@@ -428,7 +431,6 @@ export function PresetDetailModal({
)} )}
</> </>
)} )}
{hasReported && ( {hasReported && (
<span className="inline-flex items-center gap-1.5 text-sm text-text/50"> <span className="inline-flex items-center gap-1.5 text-sm text-text/50">
<FlagIcon className="h-4 w-4" /> <FlagIcon className="h-4 w-4" />
@@ -469,51 +471,48 @@ export function PresetDetailModal({
</button> </button>
</div> </div>
{/* FPS */} {/* Performance group */}
{hasPerformanceData(preset) && (
<div className="rounded-lg border border-border bg-text/3 p-3">
<span className="text-[10px] text-text/40 uppercase tracking-wider font-medium">
Performance
</span>
<div className="mt-2 flex flex-col gap-0.5">
{preset.fpsAvg !== null && ( {preset.fpsAvg !== null && (
<div className="flex flex-col gap-1"> <MetaItem
<span className="text-xs text-text/50 uppercase tracking-wider"> label="Avg FPS"
Avg FPS value={
</span> <>
<div className="text-sm text-text"> <span className="font-semibold tabular-nums">{preset.fpsAvg}</span>
<span className="font-semibold tabular-nums">
{preset.fpsAvg}
</span>
{preset.fpsLow !== null && preset.fpsHigh !== null && ( {preset.fpsLow !== null && preset.fpsHigh !== null && (
<span className="text-text/50 ml-1"> <span className="text-text/50 ml-1">
({Math.round(preset.fpsLow)}{Math.round(preset.fpsHigh)}) ({Math.round(preset.fpsLow)}{Math.round(preset.fpsHigh)})
</span> </span>
)} )}
</div> </>
</div> }
)}
{preset.fpsOnePercentLow !== null && (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-text/50 uppercase tracking-wider">1% Low FPS</span>
<span className="text-sm font-semibold tabular-nums text-text">
{preset.fpsOnePercentLow} fps
</span>
</div>
)}
<div className="h-px bg-border" />
{/* Metadata */}
<div className="flex flex-col gap-3">
{preset.versionString && (
<MetaItem label="Version" value={`v${preset.versionString}`} />
)}
{preset.buildId && (
<MetaItem label="Build" value={String(preset.buildId)} />
)}
{preset.gameAntiCheatName && (
<MetaItem
label="Anti-Cheat"
value={`${preset.gameAntiCheatName} (${preset.gameAntiCheatStatus ?? "unknown"})`}
/> />
)} )}
<MetaItem label="Proton" value={preset.protonVersion} /> {preset.fpsOnePercentLow !== null && (
<MetaItem label="OS" value={preset.osVersion} /> <MetaItem label="1% Low FPS" value={`${preset.fpsOnePercentLow} fps`} />
)}
{preset.loadTimeSsd !== null && (
<MetaItem label="Load Time (SSD)" value={`${preset.loadTimeSsd}s`} />
)}
{preset.loadTimeSd !== null && (
<MetaItem label="Load Time (SD)" value={`${preset.loadTimeSd}s`} />
)}
</div>
</div>
)}
{/* Hardware & Power group */}
{hasHardwarePowerData(preset) && (
<div className="rounded-lg border border-border bg-text/3 p-3">
<span className="text-[10px] text-text/40 uppercase tracking-wider font-medium">
Hardware & Power
</span>
<div className="mt-2 flex flex-col gap-0.5">
{preset.tdpWatts !== null && ( {preset.tdpWatts !== null && (
<MetaItem label="TDP" value={`${Math.round(preset.tdpWatts)}W`} /> <MetaItem label="TDP" value={`${Math.round(preset.tdpWatts)}W`} />
)} )}
@@ -523,6 +522,19 @@ export function PresetDetailModal({
{preset.tdpWatts !== null && preset.hardwareWattHours !== null && preset.hardwareDeviceType === "handheld" && ( {preset.tdpWatts !== null && preset.hardwareWattHours !== null && preset.hardwareDeviceType === "handheld" && (
<MetaItem label="Est. Battery" value={`~${(preset.hardwareWattHours / preset.tdpWatts).toFixed(1)}h`} /> <MetaItem label="Est. Battery" value={`~${(preset.hardwareWattHours / preset.tdpWatts).toFixed(1)}h`} />
)} )}
</div>
</div>
)}
{/* Software group */}
{hasSoftwareData(preset) && (
<div className="rounded-lg border border-border bg-text/3 p-3">
<span className="text-[10px] text-text/40 uppercase tracking-wider font-medium">
Software
</span>
<div className="mt-2 flex flex-col gap-0.5">
<MetaItem label="Proton" value={preset.protonVersion} />
<MetaItem label="OS" value={preset.osVersion} />
<MetaItem <MetaItem
label="Upscaler" label="Upscaler"
value={ value={
@@ -544,47 +556,62 @@ export function PresetDetailModal({
} }
/> />
<MetaItem label="Launch Options" value={preset.launchOptions} /> <MetaItem label="Launch Options" value={preset.launchOptions} />
{preset.loadTimeSsd !== null && ( {preset.customSystem && <MetaItem label="Custom System" value="Yes" />}
<MetaItem label="Load Time (SSD)" value={`${preset.loadTimeSsd}s`} /> </div>
)} </div>
{preset.loadTimeSd !== null && (
<MetaItem label="Load Time (SD)" value={`${preset.loadTimeSd}s`} />
)} )}
{preset.customSystem && ( {/* Game Info group */}
<MetaItem label="Custom System" value="Yes" /> {hasGameInfoData(preset) && (
<div className="rounded-lg border border-border bg-text/3 p-3">
<span className="text-[10px] text-text/40 uppercase tracking-wider font-medium">
Game Info
</span>
<div className="mt-2 flex flex-col gap-0.5">
{preset.versionString && (
<MetaItem label="Version" value={preset.versionString} />
)}
{preset.buildId && (
<MetaItem label="Build" value={String(preset.buildId)} />
)}
{preset.gameAntiCheatName && (
<MetaItem
label="Anti-Cheat"
value={`${preset.gameAntiCheatName} (${preset.gameAntiCheatStatus ?? "unknown"})`}
/>
)}
</div>
</div>
)} )}
</div> </div>
<div className="h-px bg-border" /> {/* Right panel — Tabbed content */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Screenshots */} {/* Desktop tab bar */}
{preset.screenshots && preset.screenshots.length > 0 && ( <div className="hidden md:flex shrink-0 border-b border-border">
<div className="flex flex-col gap-1"> {["media", "settings", ...(preset.userNotes ? ["notes" as TabKey] : [])].map((tab) => (
<span className="text-[10px] text-text/50 uppercase tracking-wider">Screenshots</span> <button
<div className="flex gap-2"> key={tab}
{preset.screenshots.map((ss) => ( onClick={() => setActiveTab(tab as TabKey)}
<a key={ss.id} href={ss.url} target="_blank" rel="noopener noreferrer" className="block"> className={`px-4 py-2.5 text-sm font-medium transition-colors capitalize cursor-pointer ${
<img activeTab === tab
src={ss.url} ? "text-primary border-b-2 border-primary"
alt="Screenshot" : "text-text/50 hover:text-text/70"
className="w-20 h-12 object-cover rounded border border-border hover:border-primary/50 transition-colors" }`}
loading="lazy" >
/> {tab}
</a> </button>
))} ))}
</div> </div>
</div>
)}
</div>
{/* Right panel */} {/* Tab content */}
<div className={`flex-1 overflow-y-auto p-5 ${ <div className="flex-1 overflow-y-auto p-5">
mobileTab === "settings" ? "block" : "hidden md:block" {/* Media tab */}
}`}> {activeTab === "media" && (
<div className="flex flex-col gap-4">
{/* YouTube Video */} {/* YouTube Video */}
{preset.youtubeVideoId && ( {preset.youtubeVideoId && (
<div className="mb-4"> <div>
<div className="relative w-full" style={{ paddingBottom: "56.25%" }}> <div className="relative w-full" style={{ paddingBottom: "56.25%" }}>
<iframe <iframe
src={`https://www.youtube-nocookie.com/embed/${preset.youtubeVideoId}`} src={`https://www.youtube-nocookie.com/embed/${preset.youtubeVideoId}`}
@@ -599,6 +626,45 @@ export function PresetDetailModal({
</div> </div>
)} )}
{/* Screenshots */}
{preset.screenshots && preset.screenshots.length > 0 && (
<div className="flex flex-col gap-2">
<span className="text-xs text-text/50 uppercase tracking-wider">Screenshots</span>
<div className="flex gap-2">
{preset.screenshots.map((ss, i) => (
<button
key={ss.id}
onClick={() => {
setLightboxIndex(i)
setLightboxOpen(true)
}}
className="block cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary/50 rounded-lg overflow-hidden"
aria-label={`View screenshot ${i + 1}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={ss.url}
alt={`Screenshot ${i + 1}`}
className="w-24 h-16 object-cover border border-border hover:border-primary/50 transition-colors"
loading="lazy"
/>
</button>
))}
</div>
</div>
)}
{!preset.youtubeVideoId && (!preset.screenshots || preset.screenshots.length === 0) && (
<div className="flex flex-col items-center justify-center py-16 gap-3 rounded-lg border border-border bg-text/2">
<p className="text-sm text-text/40">No media available</p>
</div>
)}
</div>
)}
{/* Settings tab */}
{activeTab === "settings" && (
<>
{hasCategories ? ( {hasCategories ? (
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.div <motion.div
@@ -648,10 +714,7 @@ export function PresetDetailModal({
</thead> </thead>
<tbody> <tbody>
{currentCategory?.settings.map((setting, sIdx) => ( {currentCategory?.settings.map((setting, sIdx) => (
<tr <tr key={sIdx} className="border-t border-border">
key={sIdx}
className="border-t border-border"
>
<td className="px-2 py-1.5 md:px-3 md:py-2 text-text/70"> <td className="px-2 py-1.5 md:px-3 md:py-2 text-text/70">
{setting.title} {setting.title}
</td> </td>
@@ -667,47 +730,31 @@ export function PresetDetailModal({
</AnimatePresence> </AnimatePresence>
) : ( ) : (
<div className="flex flex-col items-center justify-center py-16 gap-3 rounded-lg border border-border bg-text/2"> <div className="flex flex-col items-center justify-center py-16 gap-3 rounded-lg border border-border bg-text/2">
<p className="text-sm text-text/40"> <p className="text-sm text-text/40">No settings data</p>
No settings data
</p>
</div> </div>
)} )}
</>
)}
{/* Notes */} {/* Notes tab */}
{preset.userNotes && ( {activeTab === "notes" && preset.userNotes && (
<div className="mt-4 pt-4 border-t border-border">
<h3 className="text-sm font-medium text-text/80 mb-2">
Notes
</h3>
<TiptapRenderer content={preset.userNotes} /> <TiptapRenderer content={preset.userNotes} />
</div>
)} )}
</div> </div>
</div> </div>
</div>
</motion.div> </motion.div>
</motion.div> </motion.div>
{/* Screenshot Lightbox */}
{lightboxOpen && preset.screenshots && preset.screenshots.length > 0 && (
<ScreenshotLightbox
screenshots={preset.screenshots}
initialIndex={lightboxIndex}
onClose={() => setLightboxOpen(false)}
/>
)}
</> </>
</AnimatePresence> </AnimatePresence>
) )
} }
function MetaItem({
label,
value,
className,
}: {
label: string
value: string | null
className?: string
}) {
return (
<div className={`flex flex-col gap-1 ${className || ""}`}>
<span className="text-[10px] text-text/50 uppercase tracking-wider">
{label}
</span>
<span className="text-sm text-text/80 wrap-break-words">
{value ?? "—"}
</span>
</div>
)
}