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,
|
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user