feat: refactor preset modal — tabbed layout, metadata groups, screenshot lightbox, remove v prefix (Task 2.1)
This commit is contained in:
@@ -20,6 +20,9 @@ import {
|
||||
PencilIcon,
|
||||
} from "lucide-react"
|
||||
import { TiptapRenderer } from "@/components/tiptap-renderer"
|
||||
import { ScreenshotLightbox } from "@/components/ui/screenshot-lightbox"
|
||||
|
||||
type TabKey = "details" | "media" | "settings" | "notes"
|
||||
|
||||
interface Preset {
|
||||
id: string
|
||||
@@ -85,6 +88,37 @@ function formatValue(value: string | number | boolean): string {
|
||||
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({
|
||||
preset,
|
||||
gameId,
|
||||
@@ -108,7 +142,9 @@ export function PresetDetailModal({
|
||||
const [userVote, setUserVote] = useState<"up" | "down" | null>(null)
|
||||
const [localUpvotes, setLocalUpvotes] = useState(preset.upvotes)
|
||||
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 isAdmin = session?.user?.role === "admin"
|
||||
@@ -162,11 +198,7 @@ export function PresetDetailModal({
|
||||
}
|
||||
|
||||
const handleReportSubmit = () => {
|
||||
onReport(
|
||||
preset.id,
|
||||
reportReason,
|
||||
reportDetails.trim() || undefined,
|
||||
)
|
||||
onReport(preset.id, reportReason, reportDetails.trim() || undefined)
|
||||
setShowReportForm(false)
|
||||
setReportDetails("")
|
||||
}
|
||||
@@ -183,6 +215,9 @@ export function PresetDetailModal({
|
||||
}
|
||||
}
|
||||
|
||||
const visibleTabs: TabKey[] = ["details", "media", "settings"]
|
||||
if (preset.userNotes) visibleTabs.push("notes")
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<>
|
||||
@@ -219,7 +254,7 @@ export function PresetDetailModal({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 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">
|
||||
<h2 className="text-base font-semibold text-text truncate">
|
||||
{preset.hardwareName}
|
||||
@@ -236,33 +271,26 @@ export function PresetDetailModal({
|
||||
|
||||
{/* Mobile tabs */}
|
||||
<div className="flex md:hidden shrink-0 border-b border-border">
|
||||
{visibleTabs.map((tab) => (
|
||||
<button
|
||||
onClick={() => setMobileTab("details")}
|
||||
className={`flex-1 py-2.5 text-sm font-medium transition-colors cursor-pointer ${
|
||||
mobileTab === "details"
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
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-text/50 hover:text-text/70"
|
||||
}`}
|
||||
>
|
||||
Details
|
||||
</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
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Two-column body */}
|
||||
<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 ${
|
||||
mobileTab === "details" ? "flex" : "hidden md:flex"
|
||||
activeTab === "details" ? "flex" : "hidden md:flex"
|
||||
}`}>
|
||||
{/* User info */}
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -323,14 +351,9 @@ export function PresetDetailModal({
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text/50">
|
||||
Are you sure?
|
||||
</span>
|
||||
<span className="text-xs text-text/50">Are you sure?</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
onDelete(preset.id)
|
||||
setShowDeleteConfirm(false)
|
||||
}}
|
||||
onClick={() => { 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"
|
||||
>
|
||||
Confirm
|
||||
@@ -345,7 +368,6 @@ export function PresetDetailModal({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(isOwner || isAdmin) && (
|
||||
<button
|
||||
onClick={() => router.push(`/game/${gameId}/submit?edit=${preset.id}`)}
|
||||
@@ -355,7 +377,6 @@ export function PresetDetailModal({
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
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"
|
||||
@@ -363,7 +384,6 @@ export function PresetDetailModal({
|
||||
<ShareIcon className="h-4 w-4" />
|
||||
{copied ? "Link copied!" : "Share"}
|
||||
</button>
|
||||
|
||||
{session && !hasReported && (
|
||||
<>
|
||||
{!showReportForm ? (
|
||||
@@ -378,31 +398,17 @@ export function PresetDetailModal({
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<select
|
||||
value={reportReason}
|
||||
onChange={(e) =>
|
||||
setReportReason(
|
||||
e.target.value as typeof reportReason,
|
||||
)
|
||||
}
|
||||
onChange={(e) => 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"
|
||||
>
|
||||
<option value="inaccurate">
|
||||
Inaccurate data
|
||||
</option>
|
||||
<option value="spam">
|
||||
Spam
|
||||
</option>
|
||||
<option value="inappropriate">
|
||||
Inappropriate
|
||||
</option>
|
||||
<option value="other">
|
||||
Other
|
||||
</option>
|
||||
<option value="inaccurate">Inaccurate data</option>
|
||||
<option value="spam">Spam</option>
|
||||
<option value="inappropriate">Inappropriate</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<textarea
|
||||
value={reportDetails}
|
||||
onChange={(e) =>
|
||||
setReportDetails(e.target.value)
|
||||
}
|
||||
onChange={(e) => setReportDetails(e.target.value)}
|
||||
placeholder="Additional details (optional)"
|
||||
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"
|
||||
@@ -415,10 +421,7 @@ export function PresetDetailModal({
|
||||
Submit Report
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowReportForm(false)
|
||||
setReportDetails("")
|
||||
}}
|
||||
onClick={() => { 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"
|
||||
>
|
||||
Cancel
|
||||
@@ -428,7 +431,6 @@ export function PresetDetailModal({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasReported && (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-text/50">
|
||||
<FlagIcon className="h-4 w-4" />
|
||||
@@ -469,51 +471,48 @@ export function PresetDetailModal({
|
||||
</button>
|
||||
</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 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-text/50 uppercase tracking-wider">
|
||||
Avg FPS
|
||||
</span>
|
||||
<div className="text-sm text-text">
|
||||
<span className="font-semibold tabular-nums">
|
||||
{preset.fpsAvg}
|
||||
</span>
|
||||
<MetaItem
|
||||
label="Avg FPS"
|
||||
value={
|
||||
<>
|
||||
<span className="font-semibold tabular-nums">{preset.fpsAvg}</span>
|
||||
{preset.fpsLow !== null && preset.fpsHigh !== null && (
|
||||
<span className="text-text/50 ml-1">
|
||||
({Math.round(preset.fpsLow)}–{Math.round(preset.fpsHigh)})
|
||||
</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} />
|
||||
<MetaItem label="OS" value={preset.osVersion} />
|
||||
{preset.fpsOnePercentLow !== null && (
|
||||
<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 && (
|
||||
<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" && (
|
||||
<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
|
||||
label="Upscaler"
|
||||
value={
|
||||
@@ -544,47 +556,62 @@ export function PresetDetailModal({
|
||||
}
|
||||
/>
|
||||
<MetaItem label="Launch Options" value={preset.launchOptions} />
|
||||
{preset.loadTimeSsd !== null && (
|
||||
<MetaItem label="Load Time (SSD)" value={`${preset.loadTimeSsd}s`} />
|
||||
)}
|
||||
{preset.loadTimeSd !== null && (
|
||||
<MetaItem label="Load Time (SD)" value={`${preset.loadTimeSd}s`} />
|
||||
{preset.customSystem && <MetaItem label="Custom System" value="Yes" />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preset.customSystem && (
|
||||
<MetaItem label="Custom System" value="Yes" />
|
||||
{/* Game Info group */}
|
||||
{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 className="h-px bg-border" />
|
||||
|
||||
{/* Screenshots */}
|
||||
{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>
|
||||
{/* Right panel — Tabbed content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Desktop tab bar */}
|
||||
<div className="hidden md:flex shrink-0 border-b border-border">
|
||||
{["media", "settings", ...(preset.userNotes ? ["notes" as TabKey] : [])].map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab as TabKey)}
|
||||
className={`px-4 py-2.5 text-sm font-medium transition-colors capitalize cursor-pointer ${
|
||||
activeTab === tab
|
||||
? "text-primary border-b-2 border-primary"
|
||||
: "text-text/50 hover:text-text/70"
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
<div className={`flex-1 overflow-y-auto p-5 ${
|
||||
mobileTab === "settings" ? "block" : "hidden md:block"
|
||||
}`}>
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{/* Media tab */}
|
||||
{activeTab === "media" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* YouTube Video */}
|
||||
{preset.youtubeVideoId && (
|
||||
<div className="mb-4">
|
||||
<div>
|
||||
<div className="relative w-full" style={{ paddingBottom: "56.25%" }}>
|
||||
<iframe
|
||||
src={`https://www.youtube-nocookie.com/embed/${preset.youtubeVideoId}`}
|
||||
@@ -599,6 +626,45 @@ export function PresetDetailModal({
|
||||
</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 ? (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
@@ -648,10 +714,7 @@ export function PresetDetailModal({
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentCategory?.settings.map((setting, sIdx) => (
|
||||
<tr
|
||||
key={sIdx}
|
||||
className="border-t border-border"
|
||||
>
|
||||
<tr key={sIdx} className="border-t border-border">
|
||||
<td className="px-2 py-1.5 md:px-3 md:py-2 text-text/70">
|
||||
{setting.title}
|
||||
</td>
|
||||
@@ -667,47 +730,31 @@ export function PresetDetailModal({
|
||||
</AnimatePresence>
|
||||
) : (
|
||||
<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 settings data
|
||||
</p>
|
||||
<p className="text-sm text-text/40">No settings data</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{/* Notes tab */}
|
||||
{activeTab === "notes" && preset.userNotes && (
|
||||
<TiptapRenderer content={preset.userNotes} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Screenshot Lightbox */}
|
||||
{lightboxOpen && preset.screenshots && preset.screenshots.length > 0 && (
|
||||
<ScreenshotLightbox
|
||||
screenshots={preset.screenshots}
|
||||
initialIndex={lightboxIndex}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user