feat: add pinned presets with admin pin/unpin and dedicated section
This commit is contained in:
@@ -109,6 +109,8 @@ interface Preset {
|
|||||||
userName: string | null
|
userName: string | null
|
||||||
userImage: string | null
|
userImage: string | null
|
||||||
verifiedAt: string | null
|
verifiedAt: string | null
|
||||||
|
isPinned: boolean
|
||||||
|
pinnedAt: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,6 +411,9 @@ export function GamePageClient({
|
|||||||
})
|
})
|
||||||
}, [presets, selectedDevices, filters])
|
}, [presets, selectedDevices, filters])
|
||||||
|
|
||||||
|
const pinnedPresets = useMemo(() => filteredPresets.filter((p) => p.isPinned), [filteredPresets])
|
||||||
|
const regularPresets = useMemo(() => filteredPresets.filter((p) => !p.isPinned), [filteredPresets])
|
||||||
|
|
||||||
const coverImage = game.capsuleImage || game.headerImage
|
const coverImage = game.capsuleImage || game.headerImage
|
||||||
|
|
||||||
const protonDbUrl = game.steamAppId
|
const protonDbUrl = game.steamAppId
|
||||||
@@ -858,7 +863,161 @@ export function GamePageClient({
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Section 4: Community Presets */}
|
{/* Section 4a: Pinned Presets */}
|
||||||
|
{pinnedPresets.length > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.2 }}
|
||||||
|
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'>
|
||||||
|
📌 Pinned Presets
|
||||||
|
</h2>
|
||||||
|
<span className='text-xs text-text/50'>
|
||||||
|
{pinnedPresets.length} pinned
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex gap-4 overflow-x-auto scrollbar-hide pb-4 px-1"
|
||||||
|
>
|
||||||
|
{pinnedPresets.map((preset) => {
|
||||||
|
const raw = isRawPerformerPreset(preset)
|
||||||
|
const fpsColor = getFpsColor(preset)
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={preset.id}
|
||||||
|
onClick={() => handlePresetOpen(preset.id)}
|
||||||
|
className={`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-yellow-500/30 bg-yellow-500/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 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-2 text-xs text-text/60 shrink-0'>
|
||||||
|
<span className='flex items-center gap-0.5'>
|
||||||
|
<ThumbsUpIcon className='h-3 w-3' />
|
||||||
|
{preset.upvotes}
|
||||||
|
</span>
|
||||||
|
<span className='flex items-center gap-0.5'>
|
||||||
|
<ThumbsDownIcon className='h-3 w-3' />
|
||||||
|
{preset.downvotes}
|
||||||
|
</span>
|
||||||
|
</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.fpsOnePercentLow !== null && (
|
||||||
|
<span className='text-text/40'>
|
||||||
|
{" "}
|
||||||
|
· {preset.fpsOnePercentLow} 1% low
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{preset.fpsLow !== null && preset.fpsHigh !== null && !preset.fpsOnePercentLow && (
|
||||||
|
<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>
|
||||||
|
{preset.osVersion}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section 4b: 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 }}
|
||||||
@@ -871,12 +1030,12 @@ export function GamePageClient({
|
|||||||
Community Presets
|
Community Presets
|
||||||
</h2>
|
</h2>
|
||||||
<span className='text-xs text-text/50'>
|
<span className='text-xs text-text/50'>
|
||||||
{filteredPresets.length} preset
|
{regularPresets.length} preset
|
||||||
{filteredPresets.length !== 1 ? "s" : ""}
|
{regularPresets.length !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredPresets.length === 0 ? (
|
{regularPresets.length === 0 ? (
|
||||||
<div className='flex flex-col items-center justify-center py-16 gap-3 rounded-xl border border-border bg-text/2'>
|
<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' />
|
<SettingsIcon className='h-10 w-10 text-text/20' />
|
||||||
<p className='text-sm text-text/40'>
|
<p className='text-sm text-text/40'>
|
||||||
@@ -888,21 +1047,19 @@ export function GamePageClient({
|
|||||||
ref={presetsRef}
|
ref={presetsRef}
|
||||||
className="flex gap-4 overflow-x-auto scrollbar-hide pb-4 px-1"
|
className="flex gap-4 overflow-x-auto scrollbar-hide pb-4 px-1"
|
||||||
>
|
>
|
||||||
{[...filteredPresets]
|
{regularPresets.map((preset) => {
|
||||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
const raw = isRawPerformerPreset(preset)
|
||||||
.map((preset) => {
|
const fpsColor = getFpsColor(preset)
|
||||||
const raw = isRawPerformerPreset(preset)
|
return (
|
||||||
const fpsColor = getFpsColor(preset)
|
<motion.div
|
||||||
return (
|
key={preset.id}
|
||||||
<motion.div
|
onClick={() => handlePresetOpen(preset.id)}
|
||||||
key={preset.id}
|
className={`shrink-0 w-72 flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer hover:border-primary/30 ${
|
||||||
onClick={() => handlePresetOpen(preset.id)}
|
raw
|
||||||
className={`shrink-0 w-72 flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer hover:border-primary/30 ${
|
? "border-green-500/30 bg-green-500/5"
|
||||||
raw
|
: "border-border bg-text/3"
|
||||||
? "border-green-500/30 bg-green-500/5"
|
}`}
|
||||||
: "border-border bg-text/3"
|
>
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className='flex items-start justify-between gap-2'>
|
<div className='flex items-start justify-between gap-2'>
|
||||||
<div className='min-w-0'>
|
<div className='min-w-0'>
|
||||||
|
|||||||
@@ -233,6 +233,8 @@ export default async function GamePage({
|
|||||||
launchOptions: performanceEntries.launchOptions,
|
launchOptions: performanceEntries.launchOptions,
|
||||||
userNotes: performanceEntries.userNotes,
|
userNotes: performanceEntries.userNotes,
|
||||||
verifiedAt: performanceEntries.verifiedAt,
|
verifiedAt: performanceEntries.verifiedAt,
|
||||||
|
isPinned: performanceEntries.isPinned,
|
||||||
|
pinnedAt: performanceEntries.pinnedAt,
|
||||||
})
|
})
|
||||||
.from(performanceEntries)
|
.from(performanceEntries)
|
||||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||||
@@ -245,7 +247,7 @@ export default async function GamePage({
|
|||||||
sql`${performanceEntries.settingsJson} IS NOT NULL`,
|
sql`${performanceEntries.settingsJson} IS NOT NULL`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(desc(performanceEntries.upvotes)),
|
.orderBy(desc(performanceEntries.isPinned), desc(performanceEntries.upvotes)),
|
||||||
])
|
])
|
||||||
|
|
||||||
// ── Sync logic: force or stale-while-revalidate ─────────────────
|
// ── Sync logic: force or stale-while-revalidate ─────────────────
|
||||||
@@ -319,6 +321,8 @@ export default async function GamePage({
|
|||||||
userImage: p.userImage,
|
userImage: p.userImage,
|
||||||
downvotes: p.downvotes,
|
downvotes: p.downvotes,
|
||||||
verifiedAt: p.verifiedAt ? p.verifiedAt.toISOString() : null,
|
verifiedAt: p.verifiedAt ? p.verifiedAt.toISOString() : null,
|
||||||
|
isPinned: p.isPinned,
|
||||||
|
pinnedAt: p.pinnedAt ? p.pinnedAt.toISOString() : null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ interface Preset {
|
|||||||
userName: string | null
|
userName: string | null
|
||||||
userImage: string | null
|
userImage: string | null
|
||||||
verifiedAt: string | null
|
verifiedAt: string | null
|
||||||
|
isPinned: boolean
|
||||||
|
pinnedAt: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +158,18 @@ export function PresetDetailModal({
|
|||||||
setReportDetails("")
|
setReportDetails("")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleTogglePin = async () => {
|
||||||
|
const method = preset.isPinned ? "DELETE" : "POST"
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/performance/${preset.id}/pin`, { method })
|
||||||
|
if (res.ok) {
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to toggle pin:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<>
|
<>
|
||||||
@@ -338,6 +352,14 @@ export function PresetDetailModal({
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex flex-wrap items-center gap-2 mt-auto">
|
<div className="flex flex-wrap items-center gap-2 mt-auto">
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={handleTogglePin}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium border border-yellow-500/20 hover:bg-yellow-500/10 text-yellow-400 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
📌 {preset.isPinned ? 'Unpin' : 'Pin'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{(isOwner || isAdmin) && (
|
{(isOwner || isAdmin) && (
|
||||||
<>
|
<>
|
||||||
{!showDeleteConfirm ? (
|
{!showDeleteConfirm ? (
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "performance_entries" ADD COLUMN "is_pinned" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "performance_entries" ADD COLUMN "pinned_at" timestamp;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,13 @@
|
|||||||
"when": 1777389114548,
|
"when": 1777389114548,
|
||||||
"tag": "0011_talented_bloodstorm",
|
"tag": "0011_talented_bloodstorm",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777389996351,
|
||||||
|
"tag": "0012_clever_thunderbolt_ross",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -138,6 +138,42 @@ export const performanceVerifyRoutes = new Elysia({
|
|||||||
params: t.Object({ id: t.String() }),
|
params: t.Object({ id: t.String() }),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
// ── Pin a preset (admin only) ──────────────────────────────────────
|
||||||
|
.post(
|
||||||
|
"/:id/pin",
|
||||||
|
async ({ request, params, set }) => {
|
||||||
|
const guard = await requireRole(request.headers, ["admin"])
|
||||||
|
if (!guard.ok) { set.status = guard.status; return { error: guard.error } }
|
||||||
|
|
||||||
|
const [entry] = await db
|
||||||
|
.update(performanceEntries)
|
||||||
|
.set({ isPinned: true, pinnedAt: new Date() })
|
||||||
|
.where(eq(performanceEntries.id, params.id))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (!entry) { set.status = 404; return { error: "Entry not found" } }
|
||||||
|
return { entry }
|
||||||
|
},
|
||||||
|
{ params: t.Object({ id: t.String() }) },
|
||||||
|
)
|
||||||
|
// ── Unpin a preset (admin only) ────────────────────────────────────
|
||||||
|
.delete(
|
||||||
|
"/:id/pin",
|
||||||
|
async ({ request, params, set }) => {
|
||||||
|
const guard = await requireRole(request.headers, ["admin"])
|
||||||
|
if (!guard.ok) { set.status = guard.status; return { error: guard.error } }
|
||||||
|
|
||||||
|
const [entry] = await db
|
||||||
|
.update(performanceEntries)
|
||||||
|
.set({ isPinned: false, pinnedAt: null })
|
||||||
|
.where(eq(performanceEntries.id, params.id))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (!entry) { set.status = 404; return { error: "Entry not found" } }
|
||||||
|
return { entry }
|
||||||
|
},
|
||||||
|
{ params: t.Object({ id: t.String() }) },
|
||||||
|
)
|
||||||
// ── User-scoped soft delete (owner or admin) ────────────────────
|
// ── User-scoped soft delete (owner or admin) ────────────────────
|
||||||
.delete(
|
.delete(
|
||||||
"/:id/user-delete",
|
"/:id/user-delete",
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ export const performanceEntries = pgTable(
|
|||||||
|
|
||||||
// Moderation
|
// Moderation
|
||||||
isRemoved: boolean("is_removed").default(false).notNull(),
|
isRemoved: boolean("is_removed").default(false).notNull(),
|
||||||
|
isPinned: boolean("is_pinned").default(false).notNull(),
|
||||||
|
pinnedAt: timestamp("pinned_at"),
|
||||||
removedReason: text("removed_reason"),
|
removedReason: text("removed_reason"),
|
||||||
|
|
||||||
// Community rating
|
// Community rating
|
||||||
|
|||||||
Reference in New Issue
Block a user