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
+325 -278
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">
<button {visibleTabs.map((tab) => (
onClick={() => setMobileTab("details")} <button
className={`flex-1 py-2.5 text-sm font-medium transition-colors cursor-pointer ${ key={tab}
mobileTab === "details" onClick={() => setActiveTab(tab)}
? "text-primary border-b-2 border-primary" className={`flex-1 py-2.5 text-sm font-medium transition-colors capitalize cursor-pointer ${
: "text-text/50 hover:text-text/70" activeTab === tab
}`} ? "text-primary border-b-2 border-primary"
> : "text-text/50 hover:text-text/70"
Details }`}
</button> >
<button {tab}
onClick={() => setMobileTab("settings")} </button>
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>
</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,245 +471,290 @@ export function PresetDetailModal({
</button> </button>
</div> </div>
{/* FPS */} {/* Performance group */}
{preset.fpsAvg !== null && ( {hasPerformanceData(preset) && (
<div className="flex flex-col gap-1"> <div className="rounded-lg border border-border bg-text/3 p-3">
<span className="text-xs text-text/50 uppercase tracking-wider"> <span className="text-[10px] text-text/40 uppercase tracking-wider font-medium">
Avg FPS Performance
</span> </span>
<div className="text-sm text-text"> <div className="mt-2 flex flex-col gap-0.5">
<span className="font-semibold tabular-nums"> {preset.fpsAvg !== null && (
{preset.fpsAvg} <MetaItem
</span> label="Avg FPS"
{preset.fpsLow !== null && preset.fpsHigh !== null && ( value={
<span className="text-text/50 ml-1"> <>
({Math.round(preset.fpsLow)}{Math.round(preset.fpsHigh)}) <span className="font-semibold tabular-nums">{preset.fpsAvg}</span>
</span> {preset.fpsLow !== null && preset.fpsHigh !== null && (
<span className="text-text/50 ml-1">
({Math.round(preset.fpsLow)}{Math.round(preset.fpsHigh)})
</span>
)}
</>
}
/>
)}
{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>
</div> </div>
)} )}
{preset.fpsOnePercentLow !== null && (
<div className="flex flex-col gap-1"> {/* Hardware & Power group */}
<span className="text-[10px] text-text/50 uppercase tracking-wider">1% Low FPS</span> {hasHardwarePowerData(preset) && (
<span className="text-sm font-semibold tabular-nums text-text"> <div className="rounded-lg border border-border bg-text/3 p-3">
{preset.fpsOnePercentLow} fps <span className="text-[10px] text-text/40 uppercase tracking-wider font-medium">
Hardware & Power
</span> </span>
<div className="mt-2 flex flex-col gap-0.5">
{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`} />
)}
</div>
</div> </div>
)} )}
<div className="h-px bg-border" /> {/* 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={
preset.upscalerType && preset.upscalerType !== "none"
? `${preset.upscalerType.toUpperCase()}${preset.upscalerVersion ? ` ${preset.upscalerVersion}` : ""}`
: null
}
/>
<MetaItem
label="Frame Gen"
value={
preset.frameGenMethod && preset.frameGenMethod !== "none"
? preset.frameGenMethod === "fsr_fg"
? "FSR FG"
: preset.frameGenMethod === "dlss_fg"
? "DLSS FG"
: preset.frameGenMethod
: null
}
/>
<MetaItem label="Launch Options" value={preset.launchOptions} />
{preset.customSystem && <MetaItem label="Custom System" value="Yes" />}
</div>
</div>
)}
{/* Metadata */} {/* Game Info group */}
<div className="flex flex-col gap-3"> {hasGameInfoData(preset) && (
{preset.versionString && ( <div className="rounded-lg border border-border bg-text/3 p-3">
<MetaItem label="Version" value={`v${preset.versionString}`} /> <span className="text-[10px] text-text/40 uppercase tracking-wider font-medium">
)} Game Info
{preset.buildId && ( </span>
<MetaItem label="Build" value={String(preset.buildId)} /> <div className="mt-2 flex flex-col gap-0.5">
)} {preset.versionString && (
{preset.gameAntiCheatName && ( <MetaItem label="Version" value={preset.versionString} />
<MetaItem )}
label="Anti-Cheat" {preset.buildId && (
value={`${preset.gameAntiCheatName} (${preset.gameAntiCheatStatus ?? "unknown"})`} <MetaItem label="Build" value={String(preset.buildId)} />
/> )}
)} {preset.gameAntiCheatName && (
<MetaItem label="Proton" value={preset.protonVersion} /> <MetaItem
<MetaItem label="OS" value={preset.osVersion} /> label="Anti-Cheat"
{preset.tdpWatts !== null && ( value={`${preset.gameAntiCheatName} (${preset.gameAntiCheatStatus ?? "unknown"})`}
<MetaItem label="TDP" value={`${Math.round(preset.tdpWatts)}W`} /> />
)} )}
{preset.hardwareWattHours !== null && preset.hardwareDeviceType === "handheld" && ( </div>
<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
label="Upscaler"
value={
preset.upscalerType && preset.upscalerType !== "none"
? `${preset.upscalerType.toUpperCase()}${preset.upscalerVersion ? ` ${preset.upscalerVersion}` : ""}`
: null
}
/>
<MetaItem
label="Frame Gen"
value={
preset.frameGenMethod && preset.frameGenMethod !== "none"
? preset.frameGenMethod === "fsr_fg"
? "FSR FG"
: preset.frameGenMethod === "dlss_fg"
? "DLSS FG"
: preset.frameGenMethod
: null
}
/>
<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 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>
))}
</div> </div>
</div>
)} )}
</div> </div>
{/* Right panel */} {/* Right panel — Tabbed content */}
<div className={`flex-1 overflow-y-auto p-5 ${ <div className="flex-1 flex flex-col overflow-hidden">
mobileTab === "settings" ? "block" : "hidden md:block" {/* Desktop tab bar */}
}`}> <div className="hidden md:flex shrink-0 border-b border-border">
{/* YouTube Video */} {["media", "settings", ...(preset.userNotes ? ["notes" as TabKey] : [])].map((tab) => (
{preset.youtubeVideoId && ( <button
<div className="mb-4"> key={tab}
<div className="relative w-full" style={{ paddingBottom: "56.25%" }}> onClick={() => setActiveTab(tab as TabKey)}
<iframe className={`px-4 py-2.5 text-sm font-medium transition-colors capitalize cursor-pointer ${
src={`https://www.youtube-nocookie.com/embed/${preset.youtubeVideoId}`} activeTab === tab
className="absolute inset-0 w-full h-full rounded-lg" ? "text-primary border-b-2 border-primary"
allow="accelerometer; autoplay; encrypted-media; picture-in-picture" : "text-text/50 hover:text-text/70"
sandbox="allow-scripts allow-same-origin allow-presentation" }`}
allowFullScreen
loading="lazy"
title="Gameplay Video"
/>
</div>
</div>
)}
{hasCategories ? (
<AnimatePresence mode="wait">
<motion.div
key={currentCategory?.category ?? "empty"}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="flex flex-col gap-4"
> >
{/* Category navigation */} {tab}
<div className="flex items-center justify-between"> </button>
<button ))}
onClick={goPrevCategory} </div>
className="p-1.5 rounded-md hover:bg-text/5 transition-colors cursor-pointer"
aria-label="Previous category"
>
<ChevronLeftIcon className="h-4 w-4 text-text/50" />
</button>
<span className="text-sm font-medium text-text/80">
{currentCategory?.category}{" "}
<span className="text-text/40">
({activeCategoryIndex + 1}/{categories.length})
</span>
</span>
<button
onClick={goNextCategory}
className="p-1.5 rounded-md hover:bg-text/5 transition-colors cursor-pointer"
aria-label="Next category"
>
<ChevronRightIcon className="h-4 w-4 text-text/50" />
</button>
</div>
{/* Settings table */} {/* Tab content */}
<div className="rounded-lg border border-border overflow-hidden"> <div className="flex-1 overflow-y-auto p-5">
<table className="w-full text-sm"> {/* Media tab */}
<thead className="bg-text/3"> {activeTab === "media" && (
<tr> <div className="flex flex-col gap-4">
<th className="text-left px-2 py-1.5 md:px-3 md:py-2 text-xs font-medium uppercase tracking-wider text-text/50"> {/* YouTube Video */}
Setting {preset.youtubeVideoId && (
</th> <div>
<th className="text-right px-2 py-1.5 md:px-3 md:py-2 text-xs font-medium uppercase tracking-wider text-text/50"> <div className="relative w-full" style={{ paddingBottom: "56.25%" }}>
Value <iframe
</th> src={`https://www.youtube-nocookie.com/embed/${preset.youtubeVideoId}`}
</tr> className="absolute inset-0 w-full h-full rounded-lg"
</thead> allow="accelerometer; autoplay; encrypted-media; picture-in-picture"
<tbody> sandbox="allow-scripts allow-same-origin allow-presentation"
{currentCategory?.settings.map((setting, sIdx) => ( allowFullScreen
<tr loading="lazy"
key={sIdx} title="Gameplay Video"
className="border-t border-border" />
</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}`}
> >
<td className="px-2 py-1.5 md:px-3 md:py-2 text-text/70"> {/* eslint-disable-next-line @next/next/no-img-element */}
{setting.title} <img
</td> src={ss.url}
<td className="px-2 py-1.5 md:px-3 md:py-2 text-right font-medium text-text"> alt={`Screenshot ${i + 1}`}
{formatValue(setting.value)} className="w-24 h-16 object-cover border border-border hover:border-primary/50 transition-colors"
</td> loading="lazy"
</tr> />
</button>
))} ))}
</tbody> </div>
</table> </div>
</div> )}
</motion.div>
</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>
</div>
)}
{/* Notes */} {!preset.youtubeVideoId && (!preset.screenshots || preset.screenshots.length === 0) && (
{preset.userNotes && ( <div className="flex flex-col items-center justify-center py-16 gap-3 rounded-lg border border-border bg-text/2">
<div className="mt-4 pt-4 border-t border-border"> <p className="text-sm text-text/40">No media available</p>
<h3 className="text-sm font-medium text-text/80 mb-2"> </div>
Notes )}
</h3> </div>
)}
{/* Settings tab */}
{activeTab === "settings" && (
<>
{hasCategories ? (
<AnimatePresence mode="wait">
<motion.div
key={currentCategory?.category ?? "empty"}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="flex flex-col gap-4"
>
{/* Category navigation */}
<div className="flex items-center justify-between">
<button
onClick={goPrevCategory}
className="p-1.5 rounded-md hover:bg-text/5 transition-colors cursor-pointer"
aria-label="Previous category"
>
<ChevronLeftIcon className="h-4 w-4 text-text/50" />
</button>
<span className="text-sm font-medium text-text/80">
{currentCategory?.category}{" "}
<span className="text-text/40">
({activeCategoryIndex + 1}/{categories.length})
</span>
</span>
<button
onClick={goNextCategory}
className="p-1.5 rounded-md hover:bg-text/5 transition-colors cursor-pointer"
aria-label="Next category"
>
<ChevronRightIcon className="h-4 w-4 text-text/50" />
</button>
</div>
{/* Settings table */}
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-text/3">
<tr>
<th className="text-left px-2 py-1.5 md:px-3 md:py-2 text-xs font-medium uppercase tracking-wider text-text/50">
Setting
</th>
<th className="text-right px-2 py-1.5 md:px-3 md:py-2 text-xs font-medium uppercase tracking-wider text-text/50">
Value
</th>
</tr>
</thead>
<tbody>
{currentCategory?.settings.map((setting, sIdx) => (
<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>
<td className="px-2 py-1.5 md:px-3 md:py-2 text-right font-medium text-text">
{formatValue(setting.value)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</motion.div>
</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>
</div>
)}
</>
)}
{/* Notes tab */}
{activeTab === "notes" && preset.userNotes && (
<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>
)
}