feat: add pinned presets with admin pin/unpin and dedicated section

This commit is contained in:
2026-04-28 23:32:32 +08:00
parent 23fa7be89f
commit cc34fd17ef
8 changed files with 1970 additions and 20 deletions
+164 -7
View File
@@ -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,9 +1047,7 @@ 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())
.map((preset) => {
const raw = isRawPerformerPreset(preset) const raw = isRawPerformerPreset(preset)
const fpsColor = getFpsColor(preset) const fpsColor = getFpsColor(preset)
return ( return (
+5 -1
View File
@@ -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 (
+22
View File
@@ -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 ? (
+2
View File
@@ -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
+7
View File
@@ -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
} }
] ]
} }
+36
View File
@@ -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",
+2
View File
@@ -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