diff --git a/app/game/[id]/game-page-client.tsx b/app/game/[id]/game-page-client.tsx index b201f72..4a4cab3 100644 --- a/app/game/[id]/game-page-client.tsx +++ b/app/game/[id]/game-page-client.tsx @@ -393,16 +393,6 @@ export function GamePageClient({ ★ {game.metascore} )} - - {session && ( - - - Add Benchmark - - )} {/* External links */} @@ -467,6 +457,20 @@ export function GamePageClient({ )} )} + {stats && ( +
+ + {session && ( + + + Add Benchmark + + )} +
+ )} diff --git a/app/game/[id]/page.tsx b/app/game/[id]/page.tsx index 3e9944c..4585cb3 100644 --- a/app/game/[id]/page.tsx +++ b/app/game/[id]/page.tsx @@ -175,7 +175,8 @@ export default async function GamePage({ fpsAvg: performanceEntries.fpsAvg, fpsLow: performanceEntries.fpsLow, fpsHigh: performanceEntries.fpsHigh, - fsrVersion: performanceEntries.fsrVersion, + upscalerType: performanceEntries.upscalerType, + upscalerVersion: performanceEntries.upscalerVersion, frameGenMethod: performanceEntries.frameGenMethod, protonVersion: performanceEntries.protonVersion, osVersion: performanceEntries.osVersion, @@ -240,7 +241,8 @@ export default async function GamePage({ fpsAvg: p.fpsAvg, fpsLow: p.fpsLow, fpsHigh: p.fpsHigh, - fsrVersion: p.fsrVersion, + upscalerType: p.upscalerType, + upscalerVersion: p.upscalerVersion, frameGenMethod: p.frameGenMethod, protonVersion: p.protonVersion, osVersion: p.osVersion, diff --git a/app/game/[id]/submit/page.tsx b/app/game/[id]/submit/page.tsx index fe1b40f..00d036b 100644 --- a/app/game/[id]/submit/page.tsx +++ b/app/game/[id]/submit/page.tsx @@ -61,7 +61,7 @@ export default async function SubmitBenchmarkPage({ } return ( -
+

Submit Benchmark

diff --git a/app/profile/[id]/page.tsx b/app/profile/[id]/page.tsx index 9b96c25..4dd39ab 100644 --- a/app/profile/[id]/page.tsx +++ b/app/profile/[id]/page.tsx @@ -60,7 +60,8 @@ export default async function ProfilePage({ fpsHigh: performanceEntries.fpsHigh, hardwareSlug: performanceEntries.hardwareSlug, hardwareName: hardware.name, - fsrVersion: performanceEntries.fsrVersion, + upscalerType: performanceEntries.upscalerType, + upscalerVersion: performanceEntries.upscalerVersion, frameGenMethod: performanceEntries.frameGenMethod, verifiedAt: performanceEntries.verifiedAt, createdAt: performanceEntries.createdAt, diff --git a/app/profile/page.tsx b/app/profile/page.tsx index d22035b..abb3d1f 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -35,7 +35,7 @@ export default function ProfilePage() { useEffect(() => { if (!isSessionLoading && !session) { - router.push("/login") + router.push(`/login?redirect=${encodeURIComponent(window.location.pathname)}`) } }, [session, isSessionLoading, router]) diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx index eea6dd9..aac5f74 100644 --- a/components/auth/login-form.tsx +++ b/components/auth/login-form.tsx @@ -1,7 +1,7 @@ "use client" -import { useState, useEffect } from "react" -import { Loader2 } from "lucide-react" +import { useState, useEffect, useRef, useCallback } from "react" +import { Loader2, Key } from "lucide-react" import { authClient } from "@/lib/auth-client" import { loginEmailSchema, @@ -9,22 +9,70 @@ import { } from "@/lib/auth/validation" import SocialButtons from "./social-buttons" import Link from "next/link" -import { useRouter } from "next/navigation" +import { useRouter, useSearchParams } from "next/navigation" + +function isWebAuthnAbortError(err: unknown): boolean { + if (err instanceof DOMException && err.name === "AbortError") return true + const msg = err instanceof Error ? err.message : String(err ?? "") + return msg.includes("abort signal") || msg.includes("AbortError") +} + +// Suppress the console.error that @better-auth/passkey logs internally +// when a WebAuthn ceremony is aborted (expected on navigation/remount). +let suppressPasskeyErrors = false +const originalConsoleError = console.error +const passkeyErrorPattern = /\[Better Auth\] Error verifying passkey/ + +console.error = (...args: unknown[]) => { + if (suppressPasskeyErrors) { + const msg = typeof args[0] === 'string' ? args[0] : '' + if (passkeyErrorPattern.test(msg) || args.some(a => isWebAuthnAbortError(a))) { + return + } + } + originalConsoleError(...args) +} export default function LoginForm() { const router = useRouter() - const [phase, setPhase] = useState<"email" | "password">("email") + const searchParams = useSearchParams() + const redirectTo = searchParams.get("redirect") || "/" + const [showPassword, setShowPassword] = useState(false) const [email, setEmail] = useState("") const [password, setPassword] = useState("") const [error, setError] = useState("") const [isLoading, setIsLoading] = useState(false) + const [emailChecked, setEmailChecked] = useState(false) + const mountedRef = useRef(true) + const passkeyInitiatedRef = useRef(false) - // Preload passkeys for conditional UI + // Redirect to the intended page after successful login + const handleLoginSuccess = useCallback(() => { + if (mountedRef.current) router.push(redirectTo) + }, [router, redirectTo]) + + // Preload passkeys for conditional UI — must be called on mount when + // both email + password fields are in the DOM. useEffect(() => { - if (phase === "password" && "PublicKeyCredential" in window) { - authClient.signIn.passkey({ autoFill: true }) + mountedRef.current = true + if ("PublicKeyCredential" in window && !passkeyInitiatedRef.current) { + passkeyInitiatedRef.current = true + suppressPasskeyErrors = true + authClient.signIn.passkey({ + autoFill: true, + fetchOptions: { + onSuccess: handleLoginSuccess, + }, + }).catch((err) => { + if (!isWebAuthnAbortError(err)) { + console.warn("[passkey-conditional-ui]", err) + } + }).finally(() => { + suppressPasskeyErrors = false + }) } - }, [phase]) + return () => { mountedRef.current = false } + }, [handleLoginSuccess]) const handleEmailSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -56,8 +104,9 @@ export default function LoginForm() { return } - // Email exists, move to password phase - setPhase("password") + // Email exists, show password field + setEmailChecked(true) + setShowPassword(true) } catch { setError("Something went wrong. Please try again.") } finally { @@ -84,13 +133,54 @@ export default function LoginForm() { return } - router.push("/") + router.push(redirectTo) } const handleChangeEmail = () => { - setPhase("email") + setShowPassword(false) setPassword("") setError("") + setEmailChecked(false) + } + + const handlePasskeyError = useCallback((ctx: { error?: { message?: string } }) => { + // Only show errors that aren't from conditional UI cancellation + // (user dismissing the browser prompt is expected and not an error) + const msg = ctx.error?.message || "" + if ( + !msg.includes("No available") && + !msg.includes("cancelled") && + !msg.includes("NotAllowed") && + !msg.includes("aborted") + ) { + setError(msg || "Passkey sign-in failed. Please try again.") + } + }, []) + + const handlePasskeyLogin = async () => { + setError("") + setIsLoading(true) + suppressPasskeyErrors = true + try { + const result = await authClient.signIn.passkey({ + autoFill: false, + fetchOptions: { + onSuccess: handleLoginSuccess, + onError: handlePasskeyError, + }, + }).catch((err) => { + if (isWebAuthnAbortError(err)) return { data: null, error: null } + return { data: null, error: { message: err?.message || "Passkey sign-in failed" } } + }) + if (result?.error) { + handlePasskeyError({ error: result.error }) + } + } catch { + setError("Passkey sign-in failed. Please try again.") + } finally { + suppressPasskeyErrors = false + setIsLoading(false) + } } return ( @@ -100,10 +190,10 @@ export default function LoginForm() { Welcome back

- {phase === "email" - ? "Sign in to DeckyVault" - : `Signing in as `} - {phase === "password" && ( + {showPassword + ? "Signing in as " + : "Sign in to DeckyVault"} + {showPassword && ( <> {email} {" "} @@ -145,48 +235,43 @@ export default function LoginForm() {

)} - {phase === "email" ? ( -
-
- - setEmail(e.target.value)} - placeholder="you@example.com" - autoComplete="username webauthn" - className="w-full px-4 py-3 rounded-lg 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" - /> -
- -
- ) : ( -
-
- - setPassword(e.target.value)} - placeholder="Enter your password" - autoComplete="current-password webauthn" - autoFocus - className="w-full px-4 py-3 rounded-lg 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" - /> -
+ {/* Single form always contains both email and password inputs + so that WebAuthn conditional UI (autofill) works correctly. + The password field is visually hidden until the email is verified. */} + +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + autoComplete="username webauthn" + className="w-full px-4 py-3 rounded-lg 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" + /> +
+ {/* Always render the password input for WebAuthn conditional UI, + but visually hide it until the email is verified */} +
+ + setPassword(e.target.value)} + placeholder="Enter your password" + autoComplete="current-password webauthn" + autoFocus={showPassword} + tabIndex={showPassword ? 0 : -1} + className="w-full px-4 py-3 rounded-lg 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" + /> +
+ {showPassword && (
- - {/* Passkey hint */} + )} + + {showPassword && (

Your browser may offer to sign in with a passkey

-
+ )} + + + {/* Explicit passkey login button */} + {"PublicKeyCredential" in window && !showPassword && ( + )}

diff --git a/components/navbar.tsx b/components/navbar.tsx index d85818e..3020118 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -5,7 +5,15 @@ import logo from "@/app/icon.png" import Image from "next/image" import Link from "next/link" import { useEffect, useRef, useState } from "react" -import { Bookmark, CircleXIcon, Gamepad2Icon, LogOut, MenuIcon, User, XIcon } from "lucide-react" +import { + Bookmark, + CircleXIcon, + Gamepad2Icon, + LogOut, + MenuIcon, + User, + XIcon, +} from "lucide-react" import { routes, authRoutes } from "@/lib/routes" import { usePathname, useRouter, useSearchParams } from "next/navigation" import { useDebounce } from "@/lib/hooks/useDebounce" @@ -17,7 +25,11 @@ export default function Navbar() { const searchParams = useSearchParams() const isLanding = pathname === "/" - const isAuthRoute = pathname.startsWith("/login") || pathname.startsWith("/signup") || pathname.startsWith("/forgot-password") || pathname.startsWith("/reset-password") + const isAuthRoute = + pathname.startsWith("/login") || + pathname.startsWith("/signup") || + pathname.startsWith("/forgot-password") || + pathname.startsWith("/reset-password") const [searchQuery, setSearchQuery] = useState( () => searchParams.get("q") || "", @@ -140,6 +152,7 @@ export default function Navbar() { src={logo} alt='DeckyVault Logo' className='h-6 my-1 w-auto' + loading='eager' /> {!isLanding && ( @@ -243,7 +256,9 @@ export default function Navbar() { className='relative' > - ))} + return ( +

+ {/* Sidebar Navigation */} + + + {/* Content Area */} +
+ {activeSubTab === "profile" && ( + + )} + {activeSubTab === "security" && ( + + )} + {activeSubTab === "accounts" && ( + + )} +
- - - {/* Content Area */} -
- {activeSubTab === "profile" && ( - - )} - {activeSubTab === "security" && ( - - )} - {activeSubTab === "accounts" && ( - - )} -
-
- ) + ) } diff --git a/components/profile/settings-security-tab.tsx b/components/profile/settings-security-tab.tsx index f35d8f8..8b0c25a 100644 --- a/components/profile/settings-security-tab.tsx +++ b/components/profile/settings-security-tab.tsx @@ -2,463 +2,608 @@ import { useState, useEffect } from "react" import { authClient } from "@/lib/auth-client" -import { Loader2, Key, Fingerprint, Plus, Trash2, Pencil, Check, X, Shield } from "lucide-react" +import { + Loader2, + Key, + Fingerprint, + Plus, + Trash2, + Pencil, + Check, + X, + Shield, +} from "lucide-react" import { motion, AnimatePresence } from "motion/react" interface Passkey { - id: string - name: string | null - deviceType: string - createdAt: string | null + id: string + name: string | null + deviceType: string + createdAt: string | null } interface AuthMethods { - hasPassword: boolean - passkeyCount: number - oauthProviders: Array<{ providerId: string; id: string }> - totalAuthMethods: number + hasPassword: boolean + passkeyCount: number + oauthProviders: Array<{ providerId: string; id: string }> + totalAuthMethods: number } interface SettingsSecurityTabProps { - authMethods: AuthMethods | null - isLoadingAuthMethods: boolean - onRefreshAuthMethods: () => Promise + authMethods: AuthMethods | null + isLoadingAuthMethods: boolean + onRefreshAuthMethods: () => Promise } -export function SettingsSecurityTab({ authMethods, isLoadingAuthMethods, onRefreshAuthMethods }: SettingsSecurityTabProps) { - const [passkeys, setPasskeys] = useState([]) - const [isLoadingPasskeys, setIsLoadingPasskeys] = useState(true) - const [passkeysError, setPasskeysError] = useState(null) - const [passwordMessage, setPasswordMessage] = useState<{ type: "success" | "error"; text: string } | null>(null) - const [isPasswordSubmitting, setIsPasswordSubmitting] = useState(false) +export function SettingsSecurityTab({ + authMethods, + isLoadingAuthMethods, + onRefreshAuthMethods, +}: SettingsSecurityTabProps) { + const [passkeys, setPasskeys] = useState([]) + const [isLoadingPasskeys, setIsLoadingPasskeys] = useState(true) + const [passkeysError, setPasskeysError] = useState(null) + const [passwordMessage, setPasswordMessage] = useState<{ + type: "success" | "error" + text: string + } | null>(null) + const [isPasswordSubmitting, setIsPasswordSubmitting] = useState(false) - // Password form state - const [currentPassword, setCurrentPassword] = useState("") - const [newPassword, setNewPassword] = useState("") - const [confirmPassword, setConfirmPassword] = useState("") + // Password form state + const [currentPassword, setCurrentPassword] = useState("") + const [newPassword, setNewPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") - // Passkey state - const [editingPasskeyId, setEditingPasskeyId] = useState(null) - const [editingName, setEditingName] = useState("") - const [isUpdatingPasskey, setIsUpdatingPasskey] = useState(false) - const [isDeletingPasskey, setIsDeletingPasskey] = useState(null) - const [isAddingPasskey, setIsAddingPasskey] = useState(false) - const [passkeyMessage, setPasskeyMessage] = useState<{ type: "success" | "error"; text: string } | null>(null) + // Passkey state + const [editingPasskeyId, setEditingPasskeyId] = useState( + null, + ) + const [editingName, setEditingName] = useState("") + const [isUpdatingPasskey, setIsUpdatingPasskey] = useState(false) + const [isDeletingPasskey, setIsDeletingPasskey] = useState( + null, + ) + const [isAddingPasskey, setIsAddingPasskey] = useState(false) + const [passkeyMessage, setPasskeyMessage] = useState<{ + type: "success" | "error" + text: string + } | null>(null) - useEffect(() => { - fetchPasskeys() - }, []) + useEffect(() => { + fetchPasskeys() + }, []) - async function fetchPasskeys() { - try { - const res = await fetch("/api/auth/passkey/list-user-passkeys", { credentials: "include" }) - if (!res.ok) { - setPasskeysError("Failed to load passkeys") - setPasskeys([]) - } else { - const data = await res.json() - setPasskeys(Array.isArray(data) ? data : []) - setPasskeysError(null) - } - } catch { - setPasskeysError("Failed to load passkeys. Please try again.") - setPasskeys([]) - } finally { - setIsLoadingPasskeys(false) - } - } - - const refreshPasskeys = async () => { - try { - const res = await fetch("/api/auth/passkey/list-user-passkeys", { credentials: "include" }) - if (res.ok) { - const data = await res.json() - setPasskeys(Array.isArray(data) ? data : []) - setPasskeysError(null) - } - } catch { - // silently fail on refresh - } - } - - const handlePasswordSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setPasswordMessage(null) - - if (newPassword !== confirmPassword) { - setPasswordMessage({ type: "error", text: "Passwords do not match" }) - return - } - - if (newPassword.length < 10) { - setPasswordMessage({ type: "error", text: "Password must be at least 10 characters" }) - return - } - - setIsPasswordSubmitting(true) - - try { - if (authMethods?.hasPassword) { - const { error } = await authClient.changePassword({ - currentPassword, - newPassword, - }) - - if (error) { - setPasswordMessage({ type: "error", text: error.message || "Failed to change password" }) - } else { - setPasswordMessage({ type: "success", text: "Password changed successfully" }) - setCurrentPassword("") - setNewPassword("") - setConfirmPassword("") - await onRefreshAuthMethods() + async function fetchPasskeys() { + try { + const res = await fetch("/api/auth/passkey/list-user-passkeys", { + credentials: "include", + }) + if (!res.ok) { + setPasskeysError("Failed to load passkeys") + setPasskeys([]) + } else { + const data = await res.json() + setPasskeys(Array.isArray(data) ? data : []) + setPasskeysError(null) + } + } catch { + setPasskeysError("Failed to load passkeys. Please try again.") + setPasskeys([]) + } finally { + setIsLoadingPasskeys(false) } - } else { - const res = await fetch("/api/user/me/set-password", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ newPassword }), - credentials: "include", - }) + } - if (!res.ok) { - const data = await res.json().catch(() => ({ error: "Failed to set password" })) - setPasswordMessage({ type: "error", text: data.error || "Failed to set password" }) - } else { - setPasswordMessage({ type: "success", text: "Password set successfully" }) - setNewPassword("") - setConfirmPassword("") - await onRefreshAuthMethods() + const refreshPasskeys = async () => { + try { + const res = await fetch("/api/auth/passkey/list-user-passkeys", { + credentials: "include", + }) + if (res.ok) { + const data = await res.json() + setPasskeys(Array.isArray(data) ? data : []) + setPasskeysError(null) + } + } catch { + // silently fail on refresh } - } - } catch { - setPasswordMessage({ type: "error", text: "An unexpected error occurred" }) - } finally { - setIsPasswordSubmitting(false) } - } - const handleAddPasskey = async () => { - setIsAddingPasskey(true) - setPasskeyMessage(null) - try { - const { error } = await authClient.passkey.addPasskey() - if (error) { - setPasskeyMessage({ type: "error", text: error.message || "Failed to add passkey" }) - } else { - setPasskeyMessage({ type: "success", text: "Passkey added successfully" }) - await refreshPasskeys() - await onRefreshAuthMethods() - } - } catch { - setPasskeyMessage({ type: "error", text: "Failed to add passkey. Please try again." }) - } finally { - setIsAddingPasskey(false) + const handlePasswordSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setPasswordMessage(null) + + if (newPassword !== confirmPassword) { + setPasswordMessage({ + type: "error", + text: "Passwords do not match", + }) + return + } + + if (newPassword.length < 10) { + setPasswordMessage({ + type: "error", + text: "Password must be at least 10 characters", + }) + return + } + + setIsPasswordSubmitting(true) + + try { + if (authMethods?.hasPassword) { + const { error } = await authClient.changePassword({ + currentPassword, + newPassword, + }) + + if (error) { + setPasswordMessage({ + type: "error", + text: error.message || "Failed to change password", + }) + } else { + setPasswordMessage({ + type: "success", + text: "Password changed successfully", + }) + setCurrentPassword("") + setNewPassword("") + setConfirmPassword("") + await onRefreshAuthMethods() + } + } else { + const res = await fetch("/api/user/me/set-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ newPassword }), + credentials: "include", + }) + + if (!res.ok) { + const data = await res + .json() + .catch(() => ({ error: "Failed to set password" })) + setPasswordMessage({ + type: "error", + text: data.error || "Failed to set password", + }) + } else { + setPasswordMessage({ + type: "success", + text: "Password set successfully", + }) + setNewPassword("") + setConfirmPassword("") + await onRefreshAuthMethods() + } + } + } catch { + setPasswordMessage({ + type: "error", + text: "An unexpected error occurred", + }) + } finally { + setIsPasswordSubmitting(false) + } } - } - const handleDeletePasskey = async (id: string) => { - if (authMethods && authMethods.totalAuthMethods <= 1) return - - setIsDeletingPasskey(id) - setPasskeyMessage(null) - try { - const res = await fetch("/api/auth/passkey/delete-passkey", { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ id }), - }) - - if (!res.ok) { - const data = await res.json().catch(() => ({ message: "Failed to delete passkey" })) - setPasskeyMessage({ type: "error", text: data.message || "Failed to delete passkey" }) - } else { - setPasskeyMessage({ type: "success", text: "Passkey deleted" }) - await refreshPasskeys() - await onRefreshAuthMethods() - } - } catch { - setPasskeyMessage({ type: "error", text: "Failed to delete passkey. Please try again." }) - } finally { - setIsDeletingPasskey(null) + const handleAddPasskey = async () => { + setIsAddingPasskey(true) + setPasskeyMessage(null) + try { + const { error } = await authClient.passkey.addPasskey() + if (error) { + setPasskeyMessage({ + type: "error", + text: error.message || "Failed to add passkey", + }) + } else { + setPasskeyMessage({ + type: "success", + text: "Passkey added successfully", + }) + await refreshPasskeys() + await onRefreshAuthMethods() + } + } catch { + setPasskeyMessage({ + type: "error", + text: "Failed to add passkey. Please try again.", + }) + } finally { + setIsAddingPasskey(false) + } } - } - const handleRenamePasskey = async (id: string) => { - setIsUpdatingPasskey(true) - setPasskeyMessage(null) - try { - const res = await fetch("/api/auth/passkey/update-passkey", { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ id, name: editingName }), - }) + const handleDeletePasskey = async (id: string) => { + if (authMethods && authMethods.totalAuthMethods <= 1) return - if (!res.ok) { - const data = await res.json().catch(() => ({ message: "Failed to rename passkey" })) - setPasskeyMessage({ type: "error", text: data.message || "Failed to rename passkey" }) - } else { - setPasskeyMessage({ type: "success", text: "Passkey renamed" }) - await refreshPasskeys() + setIsDeletingPasskey(id) + setPasskeyMessage(null) + try { + const res = await fetch("/api/auth/passkey/delete-passkey", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ id }), + }) + + if (!res.ok) { + const data = await res + .json() + .catch(() => ({ message: "Failed to delete passkey" })) + setPasskeyMessage({ + type: "error", + text: data.message || "Failed to delete passkey", + }) + } else { + setPasskeyMessage({ type: "success", text: "Passkey deleted" }) + await refreshPasskeys() + await onRefreshAuthMethods() + } + } catch { + setPasskeyMessage({ + type: "error", + text: "Failed to delete passkey. Please try again.", + }) + } finally { + setIsDeletingPasskey(null) + } + } + + const handleRenamePasskey = async (id: string) => { + setIsUpdatingPasskey(true) + setPasskeyMessage(null) + try { + const res = await fetch("/api/auth/passkey/update-passkey", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ id, name: editingName }), + }) + + if (!res.ok) { + const data = await res + .json() + .catch(() => ({ message: "Failed to rename passkey" })) + setPasskeyMessage({ + type: "error", + text: data.message || "Failed to rename passkey", + }) + } else { + setPasskeyMessage({ type: "success", text: "Passkey renamed" }) + await refreshPasskeys() + setEditingPasskeyId(null) + setEditingName("") + } + } catch { + setPasskeyMessage({ + type: "error", + text: "Failed to rename passkey. Please try again.", + }) + } finally { + setIsUpdatingPasskey(false) + } + } + + const startEditingPasskey = (passkey: Passkey) => { + setEditingPasskeyId(passkey.id) + setEditingName(passkey.name || "") + } + + const cancelEditingPasskey = () => { setEditingPasskeyId(null) setEditingName("") - } - } catch { - setPasskeyMessage({ type: "error", text: "Failed to rename passkey. Please try again." }) - } finally { - setIsUpdatingPasskey(false) } - } - const startEditingPasskey = (passkey: Passkey) => { - setEditingPasskeyId(passkey.id) - setEditingName(passkey.name || "") - } + const isSingleAuthMethod = authMethods && authMethods.totalAuthMethods <= 1 - const cancelEditingPasskey = () => { - setEditingPasskeyId(null) - setEditingName("") - } - - const isSingleAuthMethod = authMethods && authMethods.totalAuthMethods <= 1 - - return ( - - {/* Warning for single auth method */} - - {isSingleAuthMethod && ( - - -

- Single authentication method — consider adding a passkey or linking a social account -

-
- )} -
- - {/* Password Section */} -
-

- - Password -

- - {isLoadingAuthMethods ? ( -
- - Loading... -
- ) : ( -
- {authMethods?.hasPassword && ( -
- - setCurrentPassword(e.target.value)} - className="w-full px-3 py-2 rounded-lg bg-text/5 border border-border text-sm text-text focus:outline-none focus:border-primary/60 focus:ring-1 focus:ring-primary/30 transition-colors" - placeholder="Enter current password" - required - /> -
- )} -
- - setNewPassword(e.target.value)} - className="w-full px-3 py-2 rounded-lg bg-text/5 border border-border text-sm text-text focus:outline-none focus:border-primary/60 focus:ring-1 focus:ring-primary/30 transition-colors" - placeholder={authMethods?.hasPassword ? "Enter new password" : "Set a password"} - required - /> -
-
- - setConfirmPassword(e.target.value)} - className="w-full px-3 py-2 rounded-lg bg-text/5 border border-border text-sm text-text focus:outline-none focus:border-primary/60 focus:ring-1 focus:ring-primary/30 transition-colors" - placeholder="Confirm password" - required - /> -
-
- - {passwordMessage && ( -

- {passwordMessage.text} -

- )} -
-
- )} -
- - {/* Passkey Section */} -
-
-

- - Passkeys -

- -
- - {passkeyMessage && ( -
- {passkeyMessage.text} -
- )} - - {passkeysError ? ( -
-

{passkeysError}

- -
- ) : isLoadingPasskeys ? ( -
- - Loading passkeys... -
- ) : passkeys.length === 0 ? ( -

No passkeys registered.

- ) : ( -
+ return ( + + {/* Warning for single auth method */} - {passkeys.map((pk) => ( - -
- -
- {editingPasskeyId === pk.id ? ( -
- setEditingName(e.target.value)} - className="px-2 py-1 rounded bg-text/5 border border-border text-sm text-text focus:outline-none focus:border-primary/60" - autoFocus - /> - - -
- ) : ( - <> -

- {pk.name || "Unnamed passkey"} -

-

- {pk.deviceType} - {pk.createdAt && ( - - · Added {new Date(pk.createdAt).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - })} - - )} -

- - )} -
-
- {editingPasskeyId !== pk.id && ( -
- - -
- )} -
- ))} + {isSingleAuthMethod && ( + + +

+ Single authentication method — consider adding a + passkey or linking a social account +

+
+ )}
-
- )} -
-
- ) + + {/* Password Section */} +
+

+ + Password +

+ + {isLoadingAuthMethods ? ( +
+ + Loading... +
+ ) : authMethods === null ? ( +
+ Failed to load authentication methods.{' '} + +
+ ) : ( +
+ {authMethods?.hasPassword && ( +
+ + + setCurrentPassword(e.target.value) + } + className='w-full px-3 py-2 rounded-lg bg-text/5 border border-border text-sm text-text focus:outline-none focus:border-primary/60 focus:ring-1 focus:ring-primary/30 transition-colors' + placeholder='Enter current password' + required + /> +
+ )} +
+ + setNewPassword(e.target.value)} + className='w-full px-3 py-2 rounded-lg bg-text/5 border border-border text-sm text-text focus:outline-none focus:border-primary/60 focus:ring-1 focus:ring-primary/30 transition-colors' + placeholder={ + authMethods?.hasPassword + ? "Enter new password" + : "Set a password" + } + required + /> +
+
+ + + setConfirmPassword(e.target.value) + } + className='w-full px-3 py-2 rounded-lg bg-text/5 border border-border text-sm text-text focus:outline-none focus:border-primary/60 focus:ring-1 focus:ring-primary/30 transition-colors' + placeholder='Confirm password' + required + /> +
+
+ + {passwordMessage && ( +

+ {passwordMessage.text} +

+ )} +
+
+ )} +
+ + {/* Passkey Section */} +
+
+

+ + Passkeys +

+ +
+ + {passkeyMessage && ( +
+ {passkeyMessage.text} +
+ )} + + {passkeysError ? ( +
+

+ {passkeysError} +

+ +
+ ) : isLoadingPasskeys ? ( +
+ + Loading passkeys... +
+ ) : passkeys.length === 0 ? ( +

+ No passkeys registered. +

+ ) : ( +
+ + {passkeys.map((pk) => ( + +
+ +
+ {editingPasskeyId === pk.id ? ( +
+ + setEditingName( + e.target.value, + ) + } + className='px-2 py-1 rounded bg-text/5 border border-border text-sm text-text focus:outline-none focus:border-primary/60' + autoFocus + /> + + +
+ ) : ( + <> +

+ {pk.name || + "Unnamed passkey"} +

+

+ {pk.deviceType} + {pk.createdAt && ( + + · Added{" "} + {new Date( + pk.createdAt, + ).toLocaleDateString( + "en-US", + { + month: "short", + day: "numeric", + year: "numeric", + }, + )} + + )} +

+ + )} +
+
+ {editingPasskeyId !== pk.id && ( +
+ + +
+ )} +
+ ))} +
+
+ )} +
+ + ) } diff --git a/drizzle/0006_upscaler_battery_custom_system.sql b/drizzle/0006_upscaler_battery_custom_system.sql new file mode 100644 index 0000000..9f314b4 --- /dev/null +++ b/drizzle/0006_upscaler_battery_custom_system.sql @@ -0,0 +1,30 @@ +-- Add new values to existing frame_gen_method enum +ALTER TYPE "frame_gen_method" ADD VALUE 'lsfg';--> statement-breakpoint +ALTER TYPE "frame_gen_method" ADD VALUE 'other';--> statement-breakpoint + +-- Create new upscaler_type enum +CREATE TYPE "public"."upscaler_type" AS ENUM('none', 'fsr', 'dlss', 'xess', 'lsfg', 'other');--> statement-breakpoint + +-- Add new columns to performance_entries +ALTER TABLE "performance_entries" ADD COLUMN "upscaler_type" "upscaler_type" DEFAULT 'none' NOT NULL;--> statement-breakpoint +ALTER TABLE "performance_entries" ADD COLUMN "upscaler_version" text;--> statement-breakpoint +ALTER TABLE "performance_entries" ADD COLUMN "estimated_battery_min" integer;--> statement-breakpoint +ALTER TABLE "performance_entries" ADD COLUMN "custom_system" boolean DEFAULT false NOT NULL;--> statement-breakpoint + +-- Migrate existing fsr_version data to upscaler_type/upscaler_version +UPDATE "performance_entries" SET upscaler_type = 'none', upscaler_version = NULL WHERE fsr_version = 'none';--> statement-breakpoint +UPDATE "performance_entries" SET upscaler_type = 'fsr', upscaler_version = '1' WHERE fsr_version = 'fsr1';--> statement-breakpoint +UPDATE "performance_entries" SET upscaler_type = 'fsr', upscaler_version = '2' WHERE fsr_version = 'fsr2';--> statement-breakpoint +UPDATE "performance_entries" SET upscaler_type = 'fsr', upscaler_version = '3' WHERE fsr_version = 'fsr3';--> statement-breakpoint + +-- Drop old index +DROP INDEX "perf_hardware_fsr_idx";--> statement-breakpoint + +-- Drop old column +ALTER TABLE "performance_entries" DROP COLUMN "fsr_version";--> statement-breakpoint + +-- Drop old enum type +DROP TYPE "fsr_version";--> statement-breakpoint + +-- Create new index on hardware_slug and upscaler_type +CREATE INDEX "perf_hardware_upscaler_idx" ON "performance_entries" USING btree ("hardware_slug","upscaler_type"); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 82711c6..568ab98 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1777217663641, "tag": "0005_brief_marvel_zombies", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1777300000000, + "tag": "0006_upscaler_battery_custom_system", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/api/game-stats.ts b/lib/api/game-stats.ts index 02d9222..a421885 100644 --- a/lib/api/game-stats.ts +++ b/lib/api/game-stats.ts @@ -34,7 +34,8 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get( fpsAvg: performanceEntries.fpsAvg, fpsLow: performanceEntries.fpsLow, fpsHigh: performanceEntries.fpsHigh, - fsrVersion: performanceEntries.fsrVersion, + upscalerType: performanceEntries.upscalerType, + upscalerVersion: performanceEntries.upscalerVersion, frameGenMethod: performanceEntries.frameGenMethod, protonVersion: performanceEntries.protonVersion, osVersion: performanceEntries.osVersion, @@ -112,10 +113,13 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get( const isRawPerformer = entries.some( (e) => (e.fpsAvg ?? 0) >= 60 && - e.fsrVersion === "none" && + e.upscalerType === "none" && e.frameGenMethod === "none", ) + // ── 3b. Poor Performance check ───────────────────────────────── + const isPoorPerformance = entries.some((e) => (e.fpsAvg ?? 0) < 30) + // ── 4. Boxplot per device ───────────────────────────────────── const boxplotMap = new Map< string, @@ -185,7 +189,7 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get( { hardwareSlug: string; sum: number; count: number } >() for (const e of entries) { - const key = `${e.fsrVersion}|${e.frameGenMethod}|${e.hardwareSlug}` + const key = `${e.upscalerType}|${e.upscalerVersion ?? ''}|${e.frameGenMethod}|${e.hardwareSlug}` const existing = upscalerMap.get(key) || { hardwareSlug: e.hardwareSlug, sum: 0, @@ -198,9 +202,10 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get( const upscalerStats = Array.from(upscalerMap.entries()).map( ([key, data]) => { - const [fsrVersion, frameGenMethod] = key.split("|") + const [upscalerType, upscalerVersion, frameGenMethod] = key.split("|") return { - fsrVersion, + upscalerType, + upscalerVersion: upscalerVersion || null, frameGenMethod, hardwareSlug: data.hardwareSlug, avgFps: Math.round((data.sum / data.count) * 10) / 10, @@ -220,8 +225,9 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get( fpsHigh: e.fpsHigh!, isRawPerformer: (e.fpsAvg ?? 0) >= 60 && - e.fsrVersion === "none" && + e.upscalerType === "none" && e.frameGenMethod === "none", + isPoorPerformer: (e.fpsAvg ?? 0) < 30, })) // ── 8. Device breakdown ─────────────────────────────────────── @@ -261,6 +267,7 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get( versionCount, }, isRawPerformer, + isPoorPerformance, boxplot, historical, upscalerStats, diff --git a/lib/api/hardware-stats.ts b/lib/api/hardware-stats.ts index fe23102..d1f4e57 100644 --- a/lib/api/hardware-stats.ts +++ b/lib/api/hardware-stats.ts @@ -120,7 +120,8 @@ export const hardwareStatsRoutes = new Elysia({ prefix: "/hardware" }) fpsAvg: performanceEntries.fpsAvg, fpsLow: performanceEntries.fpsLow, fpsHigh: performanceEntries.fpsHigh, - fsrVersion: performanceEntries.fsrVersion, + upscalerType: performanceEntries.upscalerType, + upscalerVersion: performanceEntries.upscalerVersion, frameGenMethod: performanceEntries.frameGenMethod, protonVersion: performanceEntries.protonVersion, osVersion: performanceEntries.osVersion, @@ -152,7 +153,7 @@ export const hardwareStatsRoutes = new Elysia({ prefix: "/hardware" }) topGames: [], genreBreakdown: [], protonBreakdown: [], - fsrBreakdown: [], + upscalerBreakdown: [], } } @@ -250,17 +251,17 @@ export const hardwareStatsRoutes = new Elysia({ prefix: "/hardware" }) .sort((a, b) => b[1] - a[1]) .map(([version, count]) => ({ version, count })) - // ── FSR breakdown ─────────────────────────────────── - const fsrMap = new Map() + // ── Upscaler breakdown ─────────────────────────────────── + const upscalerMap = new Map() for (const e of entries) { - const key = e.fsrVersion ?? "none" - if (!fsrMap.has(key)) fsrMap.set(key, { count: 0, avgFps: 0 }) - const f = fsrMap.get(key)! + const key = e.upscalerType ?? "none" + if (!upscalerMap.has(key)) upscalerMap.set(key, { count: 0, avgFps: 0 }) + const f = upscalerMap.get(key)! f.count++ f.avgFps += e.fpsAvg ?? 0 } - const fsrBreakdown = [...fsrMap.entries()].map(([version, data]) => ({ - version, + const upscalerBreakdown = [...upscalerMap.entries()].map(([type, data]) => ({ + upscalerType: type, count: data.count, avgFps: Math.round((data.avgFps / data.count) * 10) / 10, })) @@ -276,7 +277,7 @@ export const hardwareStatsRoutes = new Elysia({ prefix: "/hardware" }) topGames, genreBreakdown, protonBreakdown, - fsrBreakdown, + upscalerBreakdown, } }, { diff --git a/lib/api/performance-submit.ts b/lib/api/performance-submit.ts index 9a98a06..c31259a 100644 --- a/lib/api/performance-submit.ts +++ b/lib/api/performance-submit.ts @@ -106,7 +106,10 @@ export const performanceSubmitRoutes = new Elysia({ prefix: "/performance" }) fpsHigh: body.fpsHigh ?? null, protonVersion: body.protonVersion ?? null, osVersion: body.osVersion ?? null, - fsrVersion: body.fsrVersion ?? "none", + upscalerType: body.upscalerType ?? "none", + upscalerVersion: body.upscalerVersion ?? null, + estimatedBatteryMin: body.estimatedBatteryMin ?? null, + customSystem: body.customSystem ?? false, frameGenMethod: body.frameGenMethod ?? "none", loadTimeSsd: body.loadTimeSsd ?? null, loadTimeSd: body.loadTimeSd ?? null, @@ -131,19 +134,26 @@ export const performanceSubmitRoutes = new Elysia({ prefix: "/performance" }) fpsHigh: t.Optional(t.Union([t.Number(), t.Null()])), protonVersion: t.Optional(t.Union([t.String(), t.Null()])), osVersion: t.Optional(t.Union([t.String(), t.Null()])), - fsrVersion: t.Optional( + upscalerType: t.Optional( t.Union([ t.Literal("none"), - t.Literal("fsr1"), - t.Literal("fsr2"), - t.Literal("fsr3"), + t.Literal("fsr"), + t.Literal("dlss"), + t.Literal("xess"), + t.Literal("lsfg"), + t.Literal("other"), ]), ), + upscalerVersion: t.Optional(t.Union([t.String(), t.Null()])), + estimatedBatteryMin: t.Optional(t.Union([t.Number(), t.Null()])), + customSystem: t.Optional(t.Boolean()), frameGenMethod: t.Optional( t.Union([ t.Literal("none"), t.Literal("fsr_fg"), t.Literal("dlss_fg"), + t.Literal("lsfg"), + t.Literal("other"), ]), ), loadTimeSsd: t.Optional(t.Union([t.Number(), t.Null()])), diff --git a/lib/api/performance.ts b/lib/api/performance.ts index b142245..d7a9329 100644 --- a/lib/api/performance.ts +++ b/lib/api/performance.ts @@ -12,7 +12,7 @@ export const performanceRoutes = createCrudRoutes(performanceEntries, { auth: { read: "public", write: "user", delete: "admin" }, softDelete: true, search: { fields: ["userNotes"] }, - filter: { fields: ["hardwareSlug", "fsrVersion", "frameGenMethod"] }, + filter: { fields: ["hardwareSlug", "upscalerType", "upscalerVersion", "frameGenMethod"] }, }) // ── Verify endpoint (admin/mod) ─────────────────────────────────── @@ -183,7 +183,8 @@ export const performanceVerifyRoutes = new Elysia({ fpsAvg: performanceEntries.fpsAvg, fpsLow: performanceEntries.fpsLow, fpsHigh: performanceEntries.fpsHigh, - fsrVersion: performanceEntries.fsrVersion, + upscalerType: performanceEntries.upscalerType, + upscalerVersion: performanceEntries.upscalerVersion, frameGenMethod: performanceEntries.frameGenMethod, settingsJson: performanceEntries.settingsJson, userNotes: performanceEntries.userNotes, @@ -227,10 +228,11 @@ export const performanceVerifyRoutes = new Elysia({ .get( "/stats", async ({ query, set }) => { - const { gameId, hardwareSlug, fsrVersion } = query as { + const { gameId, hardwareSlug, upscalerType, upscalerVersion } = query as { gameId?: string hardwareSlug?: string - fsrVersion?: string + upscalerType?: string + upscalerVersion?: string } if (!gameId) { @@ -246,8 +248,11 @@ export const performanceVerifyRoutes = new Elysia({ if (hardwareSlug) { conditions.push(eq(performanceEntries.hardwareSlug, hardwareSlug)) } - if (fsrVersion) { - conditions.push(eq(performanceEntries.fsrVersion, fsrVersion as any)) // eslint-disable-line @typescript-eslint/no-explicit-any + if (upscalerType) { + conditions.push(eq(performanceEntries.upscalerType, upscalerType as any)) // eslint-disable-line @typescript-eslint/no-explicit-any + } + if (upscalerVersion) { + conditions.push(eq(performanceEntries.upscalerVersion, upscalerVersion)) } // Join through gameVersions to get to games @@ -272,7 +277,8 @@ export const performanceVerifyRoutes = new Elysia({ query: t.Object({ gameId: t.String(), hardwareSlug: t.Optional(t.String()), - fsrVersion: t.Optional(t.String()), + upscalerType: t.Optional(t.String()), + upscalerVersion: t.Optional(t.String()), }), }, ) diff --git a/lib/api/search-unified.ts b/lib/api/search-unified.ts index 78aa2a5..a8dac4b 100644 --- a/lib/api/search-unified.ts +++ b/lib/api/search-unified.ts @@ -146,8 +146,9 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get( countMap.get(c.gameId)!.comments = c.count } - // ── 2b. Raw Performer + best FPS ──────────────────────────────── + // ── 2b. Raw Performer + Poor Performance + best FPS ──────────── const rawPerformerMap = new Map() + const poorPerformerMap = new Map() const bestFpsMap = new Map() if (localGameIds.length > 0) { @@ -157,9 +158,10 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get( bestFps: sql`MAX(${performanceEntries.fpsAvg})::real`, isRawPerformer: sql`BOOL_OR( ${performanceEntries.fpsAvg} >= 60 - AND ${performanceEntries.fsrVersion} = 'none' + AND ${performanceEntries.upscalerType} = 'none' AND ${performanceEntries.frameGenMethod} = 'none' )`, + isPoorPerformance: sql`BOOL_OR(${performanceEntries.fpsAvg} < 30)`, }) .from(performanceEntries) .innerJoin( @@ -177,6 +179,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get( for (const row of perfStats) { bestFpsMap.set(row.gameId, row.bestFps) rawPerformerMap.set(row.gameId, row.isRawPerformer) + poorPerformerMap.set(row.gameId, row.isPoorPerformance) } } @@ -273,6 +276,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get( } : null, isRawPerformer: rawPerformerMap.get(g.id) ?? false, + isPoorPerformance: poorPerformerMap.get(g.id) ?? false, bestFps: bestFpsMap.get(g.id) ?? null, latestVersion: latestVersionMap.get(g.id) ?? null, }) diff --git a/lib/api/user.ts b/lib/api/user.ts index 91fcdd5..40fc908 100644 --- a/lib/api/user.ts +++ b/lib/api/user.ts @@ -1,7 +1,7 @@ import { Elysia, t } from "elysia" import { auth } from "@/lib/auth" import { db } from "@/lib/db/index" -import { user, performanceEntries, games, gameVersions, hardware, account } from "@/lib/db/schema" +import { user, performanceEntries, games, gameVersions, hardware, account, passkey } from "@/lib/db/schema" import { eq, sql, and, desc } from "drizzle-orm" import { hashPassword } from "better-auth/crypto" @@ -155,10 +155,11 @@ export const userRoutes = new Elysia({ prefix: "/user" }) .from(account) .where(eq(account.userId, session.user.id)) - // Count passkeys - const passkeys = await auth.api.listPasskeys({ - headers: request.headers, - }) + // Count passkeys via direct DB query (avoids auth.api.listPasskeys hanging) + const passkeys = await db + .select({ id: passkey.id }) + .from(passkey) + .where(eq(passkey.userId, session.user.id)) // Check if user has a password (from accounts where providerId is "credential") const hasPassword = accounts.some((a) => a.providerId === "credential") @@ -256,7 +257,8 @@ export const userRoutes = new Elysia({ prefix: "/user" }) fpsHigh: performanceEntries.fpsHigh, hardwareSlug: performanceEntries.hardwareSlug, hardwareName: hardware.name, - fsrVersion: performanceEntries.fsrVersion, + upscalerType: performanceEntries.upscalerType, + upscalerVersion: performanceEntries.upscalerVersion, frameGenMethod: performanceEntries.frameGenMethod, verifiedAt: performanceEntries.verifiedAt, createdAt: performanceEntries.createdAt, diff --git a/lib/db/schema/performanceEntries.ts b/lib/db/schema/performanceEntries.ts index 2891e43..9ea2b01 100644 --- a/lib/db/schema/performanceEntries.ts +++ b/lib/db/schema/performanceEntries.ts @@ -13,17 +13,21 @@ import { gameVersions } from "./gameVersions" import { hardware } from "./hardware" import { user } from "./auth" -export const fsrVersionEnum = pgEnum("fsr_version", [ +export const upscalerTypeEnum = pgEnum("upscaler_type", [ "none", - "fsr1", - "fsr2", - "fsr3", + "fsr", + "dlss", + "xess", + "lsfg", + "other", ]) export const frameGenMethodEnum = pgEnum("frame_gen_method", [ "none", "fsr_fg", "dlss_fg", + "lsfg", + "other", ]) export type GameSettingCategory = { @@ -56,8 +60,9 @@ export const performanceEntries = pgTable( protonVersion: text("proton_version"), osVersion: text("os_version"), - // Upscaler tracking (replaces isFsrEnabled boolean) - fsrVersion: fsrVersionEnum("fsr_version").default("none").notNull(), + // Upscaler tracking + upscalerType: upscalerTypeEnum("upscaler_type").default("none").notNull(), + upscalerVersion: text("upscaler_version"), frameGenMethod: frameGenMethodEnum("frame_gen_method") .default("none") .notNull(), @@ -73,6 +78,12 @@ export const performanceEntries = pgTable( settingsJson: jsonb("settings_json").$type(), userNotes: text("user_notes"), + // Battery estimate (minutes) + estimatedBatteryMin: integer("estimated_battery_min"), + + // Custom system flag + customSystem: boolean("custom_system").default(false).notNull(), + // Moderation isRemoved: boolean("is_removed").default(false).notNull(), removedReason: text("removed_reason"), @@ -91,7 +102,7 @@ export const performanceEntries = pgTable( updatedAt: timestamp("updated_at").defaultNow().notNull(), }, (table) => [ - index("perf_hardware_fsr_idx").on(table.hardwareSlug, table.fsrVersion), + index("perf_hardware_upscaler_idx").on(table.hardwareSlug, table.upscalerType), index("perf_version_idx").on(table.versionId), index("perf_user_idx").on(table.userId), ], diff --git a/types/api.ts b/types/api.ts index e4a5421..f74f97a 100644 --- a/types/api.ts +++ b/types/api.ts @@ -11,7 +11,8 @@ export type ContributionEntry = { fpsHigh: number | null hardwareSlug: string hardwareName: string - fsrVersion: string + upscalerType: string + upscalerVersion: string | null frameGenMethod: string verifiedAt: string | null createdAt: string