diff --git a/app/game/[id]/game-page-client.tsx b/app/game/[id]/game-page-client.tsx index 212a094..45136cf 100644 --- a/app/game/[id]/game-page-client.tsx +++ b/app/game/[id]/game-page-client.tsx @@ -33,6 +33,9 @@ import { FpsBoxplot } from "@/components/charts/FpsBoxplot" import { FpsRangeChart } from "@/components/charts/FpsRangeChart" import { DeviceDonut } from "@/components/charts/DeviceDonut" +// Comments +import { CommentSection } from "@/components/comments/comment-section" + // Types interface Game { id: string @@ -1011,6 +1014,18 @@ export function GamePageClient({ + + {/* Section 6: Comments */} + +
+ +
+
{selectedPresetId && (() => { diff --git a/app/game/[id]/submit/page.tsx b/app/game/[id]/submit/page.tsx index 2919516..1bdc3b2 100644 --- a/app/game/[id]/submit/page.tsx +++ b/app/game/[id]/submit/page.tsx @@ -1,6 +1,6 @@ import { notFound } from "next/navigation" import { db } from "@/lib/db/index" -import { games, gameVersions } from "@/lib/db/schema" +import { games, gameVersions, performanceEntries } from "@/lib/db/schema" import { eq, sql } from "drizzle-orm" import { GameEntryWizard } from "@/components/wizard/game-entry-wizard" @@ -11,12 +11,17 @@ export const metadata = { title: "Submit Benchmark", } +interface PageProps { + params: Promise<{ id: string }> + searchParams: Promise<{ edit?: string }> +} + export default async function SubmitBenchmarkPage({ params, -}: { - params: Promise<{ id: string }> -}) { + searchParams, +}: PageProps) { const { id } = await params + const { edit } = await searchParams // Resolve game const isNumeric = /^\d+$/.test(id) @@ -63,18 +68,33 @@ export default async function SubmitBenchmarkPage({ .returning() } + // If editing, fetch the existing performance entry + let editEntry = null + if (edit) { + const [entry] = await db + .select() + .from(performanceEntries) + .where(eq(performanceEntries.id, edit)) + .limit(1) + editEntry = entry ?? null + } + return (
-

Submit Benchmark

+

+ {editEntry ? "Edit Benchmark" : "Submit Benchmark"} +

- Submit performance data for {game.title} + {editEntry ? "Update your performance data for" : "Submit performance data for"}{" "} + {game.title}

) diff --git a/components/comments/comment-item.tsx b/components/comments/comment-item.tsx new file mode 100644 index 0000000..0d572d6 --- /dev/null +++ b/components/comments/comment-item.tsx @@ -0,0 +1,348 @@ +"use client" + +import { useState, useCallback } from "react" +import Image from "next/image" +import { + ThumbsUpIcon, + ReplyIcon, + MoreHorizontalIcon, + TrashIcon, + ChevronDownIcon, + ChevronUpIcon, +} from "lucide-react" +import { useSession } from "@/lib/auth-client" +import { TiptapRenderer } from "@/components/tiptap-renderer" +import { TiptapEditor } from "@/components/tiptap-editor" + +export interface CommentData { + id: string + gameId: string + userId: string + parentId: string | null + content: Record + upvotes: number + createdAt: string + updatedAt: string + userName: string | null + userImage: string | null +} + +function formatDate(value: string | null | undefined): string { + if (!value) return "" + return new Date(value).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) +} + +function getInitial(name: string | null | undefined): string { + return name?.charAt(0)?.toUpperCase() || "?" +} + +interface CommentItemProps { + comment: CommentData + depth?: number + onReplyPosted: () => void + gameId: string +} + +export function CommentItem({ + comment, + depth = 0, + onReplyPosted, + gameId, +}: CommentItemProps) { + const { data: session } = useSession() + const [upvotes, setUpvotes] = useState(comment.upvotes) + const [hasUpvoted, setHasUpvoted] = useState(false) + const [isReplying, setIsReplying] = useState(false) + const [replyContent, setReplyContent] = useState | null>(null) + const [replySubmitting, setReplySubmitting] = useState(false) + const [showReplies, setShowReplies] = useState(false) + const [replies, setReplies] = useState([]) + const [loadingReplies, setLoadingReplies] = useState(false) + const [menuOpen, setMenuOpen] = useState(false) + const [isDeleted, setIsDeleted] = useState(false) + + const isOwner = session?.user?.id === comment.userId + const isAdmin = session?.user?.role === "admin" + const canModerate = isOwner || isAdmin + + const handleUpvote = useCallback(async () => { + if (!session) return + try { + const res = await fetch( + `/api/games/${gameId}/comments/${comment.id}/upvote`, + { method: "POST" }, + ) + if (res.ok) { + setUpvotes((prev) => (hasUpvoted ? prev - 1 : prev + 1)) + setHasUpvoted((prev) => !prev) + } + } catch (err) { + console.error("Failed to upvote comment:", err) + } + }, [session, gameId, comment.id, hasUpvoted]) + + const handleReplySubmit = useCallback(async () => { + if (!replyContent || !session) return + setReplySubmitting(true) + try { + const res = await fetch(`/api/games/${gameId}/comments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parentId: comment.id, content: replyContent }), + }) + if (res.ok) { + setIsReplying(false) + setReplyContent(null) + onReplyPosted() + if (showReplies) { + // Refresh replies + setLoadingReplies(true) + const repliesRes = await fetch( + `/api/games/${gameId}/comments/${comment.id}/replies`, + ) + if (repliesRes.ok) { + const data = (await repliesRes.json()) as CommentData[] + setReplies(data) + } + setLoadingReplies(false) + } + } + } catch (err) { + console.error("Failed to post reply:", err) + } finally { + setReplySubmitting(false) + } + }, [replyContent, session, gameId, comment.id, onReplyPosted, showReplies]) + + const handleDelete = useCallback(async () => { + if (!canModerate) return + try { + const res = await fetch( + `/api/games/${gameId}/comments/${comment.id}`, + { method: "DELETE" }, + ) + if (res.ok) { + setIsDeleted(true) + } + } catch (err) { + console.error("Failed to delete comment:", err) + } + }, [canModerate, gameId, comment.id]) + + const handleLoadReplies = useCallback(async () => { + if (showReplies) { + setShowReplies(false) + return + } + setLoadingReplies(true) + try { + const res = await fetch( + `/api/games/${gameId}/comments/${comment.id}/replies`, + ) + if (res.ok) { + const data = (await res.json()) as CommentData[] + setReplies(data) + setShowReplies(true) + } + } catch (err) { + console.error("Failed to load replies:", err) + } finally { + setLoadingReplies(false) + } + }, [showReplies, gameId, comment.id]) + + if (isDeleted) { + return ( +
+ Comment removed +
+ ) + } + + return ( +
0 ? "ml-8 border-l border-border pl-4" : ""}> +
+ {/* Avatar */} +
+ {comment.userImage ? ( + {comment.userName + ) : ( +
+ {getInitial(comment.userName)} +
+ )} +
+ + {/* Content */} +
+
+ + {comment.userName || "Unknown"} + + + {formatDate(comment.createdAt)} + +
+ +
+ +
+ + {/* Actions */} +
+ + + {session && depth === 0 && ( + + )} + + {canModerate && ( +
+ + {menuOpen && ( + <> +
setMenuOpen(false)} + /> +
+ +
+ + )} +
+ )} +
+ + {/* Reply form */} + {isReplying && ( +
+ setReplyContent(json)} + className="min-h-[100px]" + /> +
+ + +
+
+ )} + + {/* Load replies */} + {depth === 0 && ( +
+ {replies.length > 0 && !showReplies && ( + + )} + {showReplies && replies.length > 0 && ( + + )} + {replies.length === 0 && !showReplies && ( + + )} +
+ )} + + {/* Replies list */} + {depth === 0 && showReplies && replies.length > 0 && ( +
+ {replies.map((reply) => ( + + ))} +
+ )} +
+
+
+ ) +} diff --git a/components/comments/comment-section.tsx b/components/comments/comment-section.tsx new file mode 100644 index 0000000..33c728e --- /dev/null +++ b/components/comments/comment-section.tsx @@ -0,0 +1,209 @@ +"use client" + +import { useState, useCallback, useEffect } from "react" +import Link from "next/link" +import { MessageSquareIcon, Loader2 } from "lucide-react" +import { useSession } from "@/lib/auth-client" +import { TiptapEditor } from "@/components/tiptap-editor" +import { CommentItem, CommentData } from "./comment-item" + +interface CommentSectionProps { + gameId: string + initialCount: number +} + +interface CommentsApiResponse { + data: CommentData[] + total: number + limit: number + offset: number +} + +export function CommentSection({ gameId, initialCount }: CommentSectionProps) { + const { data: session } = useSession() + const [comments, setComments] = useState([]) + const [total, setTotal] = useState(initialCount) + const [offset, setOffset] = useState(0) + const [loading, setLoading] = useState(true) + const [commentContent, setCommentContent] = useState | null>(null) + const [submitting, setSubmitting] = useState(false) + const limit = 20 + + // Initial load + useEffect(() => { + let cancelled = false + async function fetchInitial() { + setLoading(true) + try { + const res = await fetch( + `/api/games/${gameId}/comments?limit=${limit}&offset=0`, + ) + if (!cancelled && res.ok) { + const json = (await res.json()) as CommentsApiResponse + setComments(json.data) + setTotal(json.total) + } + } catch (err) { + console.error("Failed to load comments:", err) + } finally { + if (!cancelled) setLoading(false) + } + } + fetchInitial() + return () => { + cancelled = true + } + }, [gameId]) + + const refreshComments = useCallback(async () => { + try { + const res = await fetch( + `/api/games/${gameId}/comments?limit=${limit}&offset=0`, + ) + if (res.ok) { + const json = (await res.json()) as CommentsApiResponse + setComments(json.data) + setTotal(json.total) + } + } catch (err) { + console.error("Failed to refresh comments:", err) + } + }, [gameId]) + + const handleSubmit = useCallback(async () => { + if (!commentContent || !session) return + setSubmitting(true) + try { + const res = await fetch(`/api/games/${gameId}/comments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: commentContent }), + }) + if (res.ok) { + setCommentContent(null) + await refreshComments() + } + } catch (err) { + console.error("Failed to post comment:", err) + } finally { + setSubmitting(false) + } + }, [commentContent, session, gameId, refreshComments]) + + const handleLoadMore = useCallback(async () => { + const newOffset = offset + limit + setLoading(true) + try { + const res = await fetch( + `/api/games/${gameId}/comments?limit=${limit}&offset=${newOffset}`, + ) + if (res.ok) { + const json = (await res.json()) as CommentsApiResponse + setComments((prev) => [...prev, ...json.data]) + setTotal(json.total) + setOffset(newOffset) + } + } catch (err) { + console.error("Failed to load more comments:", err) + } finally { + setLoading(false) + } + }, [offset, gameId]) + + const handleReplyPosted = useCallback(() => { + refreshComments() + }, [refreshComments]) + + const hasMore = comments.length < total + + return ( +
+ {/* Header */} +
+ +

Comments

+ ({total}) +
+ + {/* Compose */} + {session ? ( +
+ setCommentContent(json)} + /> +
+ +
+
+ ) : ( +
+

+ + Sign in + {" "} + to leave a comment +

+
+ )} + + {/* Comment list */} +
+ {loading && comments.length === 0 ? ( +
+ + Loading comments... +
+ ) : comments.length === 0 ? ( +
+ No comments yet. Be the first to share your thoughts! +
+ ) : ( + comments.map((comment) => ( + + )) + )} +
+ + {/* Load more */} + {hasMore && !loading && ( +
+ +
+ )} + + {loading && comments.length > 0 && ( +
+ +
+ )} +
+ ) +} diff --git a/components/wizard/game-entry-wizard.tsx b/components/wizard/game-entry-wizard.tsx index c0ffc92..dc61cb2 100644 --- a/components/wizard/game-entry-wizard.tsx +++ b/components/wizard/game-entry-wizard.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useCallback } from "react" +import { useState, useCallback, useEffect } from "react" import { useRouter } from "next/navigation" import { motion, AnimatePresence } from "motion/react" import { StepIndicator } from "@/components/wizard/step-indicator" @@ -10,6 +10,7 @@ import { SettingsStep } from "@/components/wizard/steps/settings-step" import { EnvironmentStep, type EnvironmentData } from "@/components/wizard/steps/environment-step" import { ReviewStep } from "@/components/wizard/steps/review-step" import type { SettingCategory } from "@/components/wizard/settings-editor" +import { performanceEntries } from "@/lib/db/schema" const STEPS = [ { label: "Hardware", tooltip: "Choose the hardware you tested this game on" }, @@ -22,9 +23,10 @@ const STEPS = [ interface GameEntryWizardProps { gameId: string gameVersionId: string + editEntry?: typeof performanceEntries.$inferSelect | null } -export function GameEntryWizard({ gameId, gameVersionId }: GameEntryWizardProps) { +export function GameEntryWizard({ gameId, gameVersionId, editEntry }: GameEntryWizardProps) { const router = useRouter() const [currentStep, setCurrentStep] = useState(0) const [isSubmitting, setIsSubmitting] = useState(false) @@ -32,23 +34,48 @@ export function GameEntryWizard({ gameId, gameVersionId }: GameEntryWizardProps) const [success, setSuccess] = useState(false) // Step 1: Hardware - const [hardwareSlug, setHardwareSlug] = useState("") + const [hardwareSlug, setHardwareSlug] = useState(editEntry?.hardwareSlug ?? "") const [hardwareName, setHardwareName] = useState("") // Step 2: Performance - const [performance, setPerformance] = useState({}) + const [performance, setPerformance] = useState( + editEntry + ? { + fpsAvg: editEntry.fpsAvg, + fpsLow: editEntry.fpsLow ?? undefined, + fpsHigh: editEntry.fpsHigh ?? undefined, + loadTimeSsd: editEntry.loadTimeSsd ?? undefined, + loadTimeSd: editEntry.loadTimeSd ?? undefined, + } + : {}, + ) // Step 3: Settings - const [settingsJson, setSettingsJson] = useState([]) + const [settingsJson, setSettingsJson] = useState( + editEntry?.settingsJson ?? [], + ) // Step 4: Environment - const [environment, setEnvironment] = useState({ - upscalerType: "none", - frameGenMethod: "none", - }) + const [environment, setEnvironment] = useState( + editEntry + ? { + protonVersion: editEntry.protonVersion ?? undefined, + osVersion: editEntry.osVersion ?? undefined, + upscalerType: editEntry.upscalerType ?? "none", + upscalerVersion: editEntry.upscalerVersion ?? undefined, + frameGenMethod: editEntry.frameGenMethod ?? "none", + launchOptions: editEntry.launchOptions ?? undefined, + estimatedBatteryMin: editEntry.estimatedBatteryMin ?? undefined, + customSystem: editEntry.customSystem ?? false, + } + : { + upscalerType: "none", + frameGenMethod: "none", + }, + ) // Step 5: Notes - const [userNotes, setUserNotes] = useState("") + const [userNotes, setUserNotes] = useState(editEntry?.userNotes ?? "") // Fetch hardware name when slug changes const handleHardwareChange = useCallback(async (slug: string) => { @@ -69,6 +96,26 @@ export function GameEntryWizard({ gameId, gameVersionId }: GameEntryWizardProps) } }, []) + // Fetch hardware name when in edit mode + useEffect(() => { + if (!editEntry || !hardwareSlug) return + let cancelled = false + async function fetchName() { + try { + const res = await fetch("/api/performance/hardware") + if (res.ok && !cancelled) { + const data = await res.json() as { data: Array<{ slug: string; name: string }> } + const device = data.data.find((d) => d.slug === hardwareSlug) + if (device && !cancelled) setHardwareName(device.name) + } + } catch { + // ignore + } + } + fetchName() + return () => { cancelled = true } + }, [editEntry, hardwareSlug]) + const canProceed = () => { switch (currentStep) { case 0: @@ -109,33 +156,40 @@ export function GameEntryWizard({ gameId, gameVersionId }: GameEntryWizardProps) setError(null) try { - const res = await fetch("/api/performance/submit", { - method: "POST", + const payload = { + versionId: gameVersionId, + hardwareSlug, + fpsAvg: Number(performance.fpsAvg), + fpsLow: performance.fpsLow !== undefined ? Number(performance.fpsLow) : null, + fpsHigh: performance.fpsHigh !== undefined ? Number(performance.fpsHigh) : null, + loadTimeSsd: performance.loadTimeSsd !== undefined ? Number(performance.loadTimeSsd) : null, + loadTimeSd: performance.loadTimeSd !== undefined ? Number(performance.loadTimeSd) : null, + protonVersion: environment.protonVersion || null, + osVersion: environment.osVersion || null, + upscalerType: environment.upscalerType ?? "none", + upscalerVersion: environment.upscalerVersion || null, + frameGenMethod: environment.frameGenMethod ?? "none", + launchOptions: environment.launchOptions || null, + estimatedBatteryMin: environment.estimatedBatteryMin ?? null, + customSystem: environment.customSystem ?? false, + settingsJson: settingsJson.length > 0 ? settingsJson : null, + userNotes: userNotes || null, + } + + const url = editEntry + ? `/api/performance/${editEntry.id}/edit` + : "/api/performance/submit" + const method = editEntry ? "PATCH" : "POST" + + const res = await fetch(url, { + method, headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - versionId: gameVersionId, - hardwareSlug, - fpsAvg: Number(performance.fpsAvg), - fpsLow: performance.fpsLow !== undefined ? Number(performance.fpsLow) : null, - fpsHigh: performance.fpsHigh !== undefined ? Number(performance.fpsHigh) : null, - loadTimeSsd: performance.loadTimeSsd !== undefined ? Number(performance.loadTimeSsd) : null, - loadTimeSd: performance.loadTimeSd !== undefined ? Number(performance.loadTimeSd) : null, - protonVersion: environment.protonVersion || null, - osVersion: environment.osVersion || null, - upscalerType: environment.upscalerType ?? "none", - upscalerVersion: environment.upscalerVersion || null, - frameGenMethod: environment.frameGenMethod ?? "none", - launchOptions: environment.launchOptions || null, - estimatedBatteryMin: environment.estimatedBatteryMin ?? null, - customSystem: environment.customSystem ?? false, - settingsJson: settingsJson.length > 0 ? settingsJson : null, - userNotes: userNotes || null, - }), + body: JSON.stringify(payload), }) if (!res.ok) { const data = await res.json() - throw new Error(data.error || "Failed to submit entry") + throw new Error(data.error || `Failed to ${editEntry ? "update" : "submit"} entry`) } setSuccess(true) diff --git a/components/wizard/settings-editor.tsx b/components/wizard/settings-editor.tsx index 8921251..def423d 100644 --- a/components/wizard/settings-editor.tsx +++ b/components/wizard/settings-editor.tsx @@ -42,6 +42,12 @@ const DEFAULT_CATEGORIES: SettingCategory[] = [ }, ] +const typeOptions = [ + { value: "text", label: "Abc" }, + { value: "number", label: "123" }, + { value: "boolean", label: "\u2713/\u2717" }, +] as const + interface SettingsEditorProps { value: SettingCategory[] onChange: (categories: SettingCategory[]) => void @@ -59,6 +65,7 @@ export function SettingsEditor({ const [newCategoryName, setNewCategoryName] = useState("") const [newSettingNames, setNewSettingNames] = useState>({}) const [newSettingTypes, setNewSettingTypes] = useState>({}) + const addSettingInputRefs = useRef>({}) const toggleCategory = (category: string) => { setCollapsedCategories((prev) => { @@ -106,6 +113,8 @@ export function SettingsEditor({ }) ) setNewSettingNames((prev) => ({ ...prev, [category]: "" })) + // Refocus after React re-renders + setTimeout(() => addSettingInputRefs.current[category]?.focus(), 0) } const moveCategoryUp = (category: string) => { @@ -326,6 +335,59 @@ export function SettingsEditor({ className="overflow-hidden" >
+ {/* Add setting row at the top */} +
+
+ {typeOptions.map((opt) => ( + + ))} +
+ { + addSettingInputRefs.current[cat.category] = el + }} + type="text" + value={newSettingNames[cat.category] || ""} + onChange={(e) => + setNewSettingNames((prev) => ({ + ...prev, + [cat.category]: e.target.value, + })) + } + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + addSetting(cat.category) + } + }} + placeholder="Add setting..." + className="flex-1 px-3 py-1.5 rounded-md border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors" + /> + +
+ {cat.settings.map((setting) => ( ))} - -
- - - setNewSettingNames((prev) => ({ - ...prev, - [cat.category]: e.target.value, - })) - } - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault() - addSetting(cat.category) - } - }} - placeholder="Add setting..." - className="flex-1 px-3 py-1.5 rounded-md border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors" - /> - -
)} diff --git a/components/wizard/steps/performance-step.tsx b/components/wizard/steps/performance-step.tsx index ef0e74b..a4eae5f 100644 --- a/components/wizard/steps/performance-step.tsx +++ b/components/wizard/steps/performance-step.tsx @@ -18,14 +18,18 @@ interface PerformanceStepProps { export function PerformanceStep({ value, onChange }: PerformanceStepProps) { const error = useMemo(() => { - if (value.fpsAvg !== undefined && value.fpsAvg !== null && value.fpsAvg <= 0) { + if (value.fpsAvg !== undefined && value.fpsAvg !== null && (isNaN(value.fpsAvg) || value.fpsAvg <= 0)) { return "FPS Average must be greater than 0" } return null }, [value.fpsAvg]) const update = (field: keyof PerformanceData, val: string) => { - const num = val === "" ? undefined : Number(val) + const isDecimalField = field === "loadTimeSsd" || field === "loadTimeSd" + const cleaned = isDecimalField + ? val.replace(/[^0-9.]/g, "").replace(/(\..*)\./g, "$1") + : val.replace(/[^0-9]/g, "") + const num = cleaned === "" || cleaned === "." ? undefined : Number(cleaned) onChange({ ...value, [field]: num }) } @@ -44,9 +48,9 @@ export function PerformanceStep({ value, onChange }: PerformanceStepProps) { FPS Average * update("fpsAvg", e.target.value)} placeholder="e.g. 45" @@ -58,9 +62,9 @@ export function PerformanceStep({ value, onChange }: PerformanceStepProps) {
update("fpsLow", e.target.value)} placeholder="e.g. 30" @@ -71,9 +75,9 @@ export function PerformanceStep({ value, onChange }: PerformanceStepProps) {
update("fpsHigh", e.target.value)} placeholder="e.g. 60" @@ -98,9 +102,9 @@ export function PerformanceStep({ value, onChange }: PerformanceStepProps) {
update("loadTimeSsd", e.target.value)} placeholder="e.g. 12.5" @@ -111,9 +115,9 @@ export function PerformanceStep({ value, onChange }: PerformanceStepProps) {
update("loadTimeSd", e.target.value)} placeholder="e.g. 35.0" diff --git a/lib/api/performance.ts b/lib/api/performance.ts index ab918c4..ca6b357 100644 --- a/lib/api/performance.ts +++ b/lib/api/performance.ts @@ -195,6 +195,107 @@ export const performanceVerifyRoutes = new Elysia({ body: t.Optional(t.Object({ reason: t.Optional(t.String()) })), }, ) + // ── Edit entry (owner or admin) ─────────────────────────────────── + .patch( + "/:id/edit", + async ({ params, body, request, set }) => { + const guard = await requireRole(request.headers, [ + "user", + "contributor", + "admin", + ]) + if (!guard.ok) { + set.status = guard.status + return { error: guard.error } + } + + const [entry] = await db + .select({ + id: performanceEntries.id, + userId: performanceEntries.userId, + }) + .from(performanceEntries) + .where(eq(performanceEntries.id, params.id)) + .limit(1) + + if (!entry) { + set.status = 404 + return { error: "Performance entry not found" } + } + + if (entry.userId !== guard.user.id && guard.user.role !== "admin") { + set.status = 403 + return { error: "Not authorized to edit this entry" } + } + + const updateData: Partial = { + updatedAt: new Date(), + } + + if (body.fpsAvg !== undefined) updateData.fpsAvg = body.fpsAvg + if (body.fpsLow !== undefined) updateData.fpsLow = body.fpsLow + if (body.fpsHigh !== undefined) updateData.fpsHigh = body.fpsHigh + if (body.protonVersion !== undefined) + updateData.protonVersion = body.protonVersion + if (body.osVersion !== undefined) + updateData.osVersion = body.osVersion + if (body.upscalerType !== undefined) + updateData.upscalerType = body.upscalerType ?? "none" + if (body.upscalerVersion !== undefined) + updateData.upscalerVersion = body.upscalerVersion + if (body.frameGenMethod !== undefined) + updateData.frameGenMethod = body.frameGenMethod ?? "none" + if (body.launchOptions !== undefined) + updateData.launchOptions = body.launchOptions + if (body.settingsJson !== undefined) + updateData.settingsJson = body.settingsJson + if (body.userNotes !== undefined) + updateData.userNotes = body.userNotes + + const [updated] = await db + .update(performanceEntries) + .set(updateData) + .where(eq(performanceEntries.id, params.id)) + .returning() + + return updated + }, + { + params: t.Object({ id: t.String() }), + body: t.Object({ + fpsAvg: t.Optional(t.Number()), + fpsLow: t.Optional(t.Number()), + fpsHigh: t.Optional(t.Number()), + protonVersion: t.Optional(t.Union([t.String(), t.Null()])), + osVersion: t.Optional(t.Union([t.String(), t.Null()])), + upscalerType: t.Optional( + t.Union([ + t.Literal("none"), + t.Literal("fsr"), + t.Literal("dlss"), + t.Literal("xess"), + t.Literal("lsfg"), + t.Literal("other"), + t.Null(), + ]), + ), + upscalerVersion: t.Optional(t.Union([t.String(), t.Null()])), + frameGenMethod: t.Optional( + t.Union([ + t.Literal("none"), + t.Literal("fsr_fg"), + t.Literal("dlss_fg"), + t.Literal("lsfg"), + t.Literal("other"), + t.Null(), + ]), + ), + launchOptions: t.Optional(t.Union([t.String(), t.Null()])), + settingsJson: t.Optional(t.Union([t.Array(t.Any()), t.Null()])), + userNotes: t.Optional(t.Union([t.String(), t.Null()])), + }), + }, + ) // ── Best entry: highest-rated for latest version ────────────────── .get( "/best",