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
|
||||
userImage: string | null
|
||||
verifiedAt: string | null
|
||||
isPinned: boolean
|
||||
pinnedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -409,6 +411,9 @@ export function GamePageClient({
|
||||
})
|
||||
}, [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 protonDbUrl = game.steamAppId
|
||||
@@ -858,7 +863,161 @@ export function GamePageClient({
|
||||
</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
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -871,12 +1030,12 @@ export function GamePageClient({
|
||||
Community Presets
|
||||
</h2>
|
||||
<span className='text-xs text-text/50'>
|
||||
{filteredPresets.length} preset
|
||||
{filteredPresets.length !== 1 ? "s" : ""}
|
||||
{regularPresets.length} preset
|
||||
{regularPresets.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</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'>
|
||||
<SettingsIcon className='h-10 w-10 text-text/20' />
|
||||
<p className='text-sm text-text/40'>
|
||||
@@ -888,9 +1047,7 @@ export function GamePageClient({
|
||||
ref={presetsRef}
|
||||
className="flex gap-4 overflow-x-auto scrollbar-hide pb-4 px-1"
|
||||
>
|
||||
{[...filteredPresets]
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.map((preset) => {
|
||||
{regularPresets.map((preset) => {
|
||||
const raw = isRawPerformerPreset(preset)
|
||||
const fpsColor = getFpsColor(preset)
|
||||
return (
|
||||
|
||||
@@ -233,6 +233,8 @@ export default async function GamePage({
|
||||
launchOptions: performanceEntries.launchOptions,
|
||||
userNotes: performanceEntries.userNotes,
|
||||
verifiedAt: performanceEntries.verifiedAt,
|
||||
isPinned: performanceEntries.isPinned,
|
||||
pinnedAt: performanceEntries.pinnedAt,
|
||||
})
|
||||
.from(performanceEntries)
|
||||
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
|
||||
@@ -245,7 +247,7 @@ export default async function GamePage({
|
||||
sql`${performanceEntries.settingsJson} IS NOT NULL`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(performanceEntries.upvotes)),
|
||||
.orderBy(desc(performanceEntries.isPinned), desc(performanceEntries.upvotes)),
|
||||
])
|
||||
|
||||
// ── Sync logic: force or stale-while-revalidate ─────────────────
|
||||
@@ -319,6 +321,8 @@ export default async function GamePage({
|
||||
userImage: p.userImage,
|
||||
downvotes: p.downvotes,
|
||||
verifiedAt: p.verifiedAt ? p.verifiedAt.toISOString() : null,
|
||||
isPinned: p.isPinned,
|
||||
pinnedAt: p.pinnedAt ? p.pinnedAt.toISOString() : null,
|
||||
}))
|
||||
|
||||
return (
|
||||
|
||||
@@ -45,6 +45,8 @@ interface Preset {
|
||||
userName: string | null
|
||||
userImage: string | null
|
||||
verifiedAt: string | null
|
||||
isPinned: boolean
|
||||
pinnedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -156,6 +158,18 @@ export function PresetDetailModal({
|
||||
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 (
|
||||
<AnimatePresence>
|
||||
<>
|
||||
@@ -338,6 +352,14 @@ export function PresetDetailModal({
|
||||
|
||||
{/* Actions */}
|
||||
<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) && (
|
||||
<>
|
||||
{!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,
|
||||
"tag": "0011_talented_bloodstorm",
|
||||
"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() }),
|
||||
},
|
||||
)
|
||||
// ── 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) ────────────────────
|
||||
.delete(
|
||||
"/:id/user-delete",
|
||||
|
||||
@@ -87,6 +87,8 @@ export const performanceEntries = pgTable(
|
||||
|
||||
// Moderation
|
||||
isRemoved: boolean("is_removed").default(false).notNull(),
|
||||
isPinned: boolean("is_pinned").default(false).notNull(),
|
||||
pinnedAt: timestamp("pinned_at"),
|
||||
removedReason: text("removed_reason"),
|
||||
|
||||
// Community rating
|
||||
|
||||
Reference in New Issue
Block a user