feat: replace fsrVersion with upscalerType/upscalerVersion, add battery & custom system columns

This commit is contained in:
2026-04-27 07:18:43 +08:00
parent ea6407cb4f
commit 91de833417
19 changed files with 1051 additions and 655 deletions
+14 -10
View File
@@ -393,16 +393,6 @@ export function GamePageClient({
{game.metascore} {game.metascore}
</span> </span>
)} )}
<BookmarkButton gameId={game.id} />
{session && (
<Link
href={`/game/${game.id}/submit`}
className='inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer'
>
<Plus className='h-4 w-4' />
Add Benchmark
</Link>
)}
</div> </div>
{/* External links */} {/* External links */}
@@ -467,6 +457,20 @@ export function GamePageClient({
)} )}
</div> </div>
)} )}
{stats && (
<div className='flex flex-wrap items-center gap-2 mt-2'>
<BookmarkButton gameId={game.id} />
{session && (
<Link
href={`/game/${game.id}/submit`}
className='inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer'
>
<Plus className='h-4 w-4' />
Add Benchmark
</Link>
)}
</div>
)}
</div> </div>
</div> </div>
</motion.div> </motion.div>
+4 -2
View File
@@ -175,7 +175,8 @@ export default async function GamePage({
fpsAvg: performanceEntries.fpsAvg, fpsAvg: performanceEntries.fpsAvg,
fpsLow: performanceEntries.fpsLow, fpsLow: performanceEntries.fpsLow,
fpsHigh: performanceEntries.fpsHigh, fpsHigh: performanceEntries.fpsHigh,
fsrVersion: performanceEntries.fsrVersion, upscalerType: performanceEntries.upscalerType,
upscalerVersion: performanceEntries.upscalerVersion,
frameGenMethod: performanceEntries.frameGenMethod, frameGenMethod: performanceEntries.frameGenMethod,
protonVersion: performanceEntries.protonVersion, protonVersion: performanceEntries.protonVersion,
osVersion: performanceEntries.osVersion, osVersion: performanceEntries.osVersion,
@@ -240,7 +241,8 @@ export default async function GamePage({
fpsAvg: p.fpsAvg, fpsAvg: p.fpsAvg,
fpsLow: p.fpsLow, fpsLow: p.fpsLow,
fpsHigh: p.fpsHigh, fpsHigh: p.fpsHigh,
fsrVersion: p.fsrVersion, upscalerType: p.upscalerType,
upscalerVersion: p.upscalerVersion,
frameGenMethod: p.frameGenMethod, frameGenMethod: p.frameGenMethod,
protonVersion: p.protonVersion, protonVersion: p.protonVersion,
osVersion: p.osVersion, osVersion: p.osVersion,
+1 -1
View File
@@ -61,7 +61,7 @@ export default async function SubmitBenchmarkPage({
} }
return ( return (
<div className="max-w-2xl mx-auto px-4 py-8"> <div className="max-w-7xl mx-auto px-4 py-8 w-full">
<div className="mb-8"> <div className="mb-8">
<h1 className="text-2xl font-bold mb-2">Submit Benchmark</h1> <h1 className="text-2xl font-bold mb-2">Submit Benchmark</h1>
<p className="text-sm text-text/60"> <p className="text-sm text-text/60">
+2 -1
View File
@@ -60,7 +60,8 @@ export default async function ProfilePage({
fpsHigh: performanceEntries.fpsHigh, fpsHigh: performanceEntries.fpsHigh,
hardwareSlug: performanceEntries.hardwareSlug, hardwareSlug: performanceEntries.hardwareSlug,
hardwareName: hardware.name, hardwareName: hardware.name,
fsrVersion: performanceEntries.fsrVersion, upscalerType: performanceEntries.upscalerType,
upscalerVersion: performanceEntries.upscalerVersion,
frameGenMethod: performanceEntries.frameGenMethod, frameGenMethod: performanceEntries.frameGenMethod,
verifiedAt: performanceEntries.verifiedAt, verifiedAt: performanceEntries.verifiedAt,
createdAt: performanceEntries.createdAt, createdAt: performanceEntries.createdAt,
+1 -1
View File
@@ -35,7 +35,7 @@ export default function ProfilePage() {
useEffect(() => { useEffect(() => {
if (!isSessionLoading && !session) { if (!isSessionLoading && !session) {
router.push("/login") router.push(`/login?redirect=${encodeURIComponent(window.location.pathname)}`)
} }
}, [session, isSessionLoading, router]) }, [session, isSessionLoading, router])
+137 -35
View File
@@ -1,7 +1,7 @@
"use client" "use client"
import { useState, useEffect } from "react" import { useState, useEffect, useRef, useCallback } from "react"
import { Loader2 } from "lucide-react" import { Loader2, Key } from "lucide-react"
import { authClient } from "@/lib/auth-client" import { authClient } from "@/lib/auth-client"
import { import {
loginEmailSchema, loginEmailSchema,
@@ -9,22 +9,70 @@ import {
} from "@/lib/auth/validation" } from "@/lib/auth/validation"
import SocialButtons from "./social-buttons" import SocialButtons from "./social-buttons"
import Link from "next/link" 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() { export default function LoginForm() {
const router = useRouter() 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 [email, setEmail] = useState("")
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const [error, setError] = useState("") const [error, setError] = useState("")
const [isLoading, setIsLoading] = useState(false) 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(() => { useEffect(() => {
if (phase === "password" && "PublicKeyCredential" in window) { mountedRef.current = true
authClient.signIn.passkey({ autoFill: 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)
} }
}, [phase]) }).finally(() => {
suppressPasskeyErrors = false
})
}
return () => { mountedRef.current = false }
}, [handleLoginSuccess])
const handleEmailSubmit = async (e: React.FormEvent) => { const handleEmailSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -56,8 +104,9 @@ export default function LoginForm() {
return return
} }
// Email exists, move to password phase // Email exists, show password field
setPhase("password") setEmailChecked(true)
setShowPassword(true)
} catch { } catch {
setError("Something went wrong. Please try again.") setError("Something went wrong. Please try again.")
} finally { } finally {
@@ -84,13 +133,54 @@ export default function LoginForm() {
return return
} }
router.push("/") router.push(redirectTo)
} }
const handleChangeEmail = () => { const handleChangeEmail = () => {
setPhase("email") setShowPassword(false)
setPassword("") setPassword("")
setError("") 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 ( return (
@@ -100,10 +190,10 @@ export default function LoginForm() {
Welcome back Welcome back
</h1> </h1>
<p className="text-sm text-text/50 mt-1"> <p className="text-sm text-text/50 mt-1">
{phase === "email" {showPassword
? "Sign in to DeckyVault" ? "Signing in as "
: `Signing in as `} : "Sign in to DeckyVault"}
{phase === "password" && ( {showPassword && (
<> <>
<strong className="text-text">{email}</strong> <strong className="text-text">{email}</strong>
{" "} {" "}
@@ -145,14 +235,17 @@ export default function LoginForm() {
</div> </div>
)} )}
{phase === "email" ? ( {/* Single form always contains both email and password inputs
<form onSubmit={handleEmailSubmit} className="space-y-4"> so that WebAuthn conditional UI (autofill) works correctly.
The password field is visually hidden until the email is verified. */}
<form onSubmit={showPassword ? handleLogin : handleEmailSubmit} className="space-y-4">
<div> <div>
<label className="text-sm text-text/60 block mb-1.5"> <label className="text-sm text-text/60 block mb-1.5">
Email Email
</label> </label>
<input <input
type="email" type="email"
name="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com" placeholder="you@example.com"
@@ -160,33 +253,25 @@ export default function LoginForm() {
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" 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"
/> />
</div> </div>
<button {/* Always render the password input for WebAuthn conditional UI,
type="submit" but visually hide it until the email is verified */}
disabled={isLoading} <div className={showPassword ? "" : "h-0 overflow-hidden opacity-0 pointer-events-none"}>
className="w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isLoading && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
Continue
</button>
</form>
) : (
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label className="text-sm text-text/60 block mb-1.5"> <label className="text-sm text-text/60 block mb-1.5">
Password Password
</label> </label>
<input <input
type="password" type="password"
name="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password" placeholder="Enter your password"
autoComplete="current-password webauthn" autoComplete="current-password webauthn"
autoFocus 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" 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"
/> />
</div> </div>
{showPassword && (
<div className="text-right"> <div className="text-right">
<Link <Link
href="/forgot-password" href="/forgot-password"
@@ -195,6 +280,7 @@ export default function LoginForm() {
Forgot password? Forgot password?
</Link> </Link>
</div> </div>
)}
<button <button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading}
@@ -203,15 +289,31 @@ export default function LoginForm() {
{isLoading && ( {isLoading && (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
)} )}
Sign in {showPassword ? "Sign in" : "Continue"}
</button> </button>
{/* Passkey hint */} {showPassword && (
<div className="text-center p-3 rounded-lg bg-primary/5 border border-primary/10"> <div className="text-center p-3 rounded-lg bg-primary/5 border border-primary/10">
<p className="text-xs text-text/50"> <p className="text-xs text-text/50">
Your browser may offer to sign in with a passkey Your browser may offer to sign in with a passkey
</p> </p>
</div> </div>
)}
</form> </form>
{/* Explicit passkey login button */}
{"PublicKeyCredential" in window && !showPassword && (
<button
onClick={handlePasskeyLogin}
disabled={isLoading}
className="w-full flex items-center justify-center gap-2 py-3 rounded-lg border border-border bg-text/5 text-text/70 text-sm font-medium hover:bg-text/10 hover:text-text transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Key className="h-4 w-4" />
)}
Sign in with a passkey
</button>
)} )}
<p className="text-center text-sm text-text/50"> <p className="text-center text-sm text-text/50">
+46 -11
View File
@@ -5,7 +5,15 @@ import logo from "@/app/icon.png"
import Image from "next/image" import Image from "next/image"
import Link from "next/link" import Link from "next/link"
import { useEffect, useRef, useState } from "react" 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 { routes, authRoutes } from "@/lib/routes"
import { usePathname, useRouter, useSearchParams } from "next/navigation" import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useDebounce } from "@/lib/hooks/useDebounce" import { useDebounce } from "@/lib/hooks/useDebounce"
@@ -17,7 +25,11 @@ export default function Navbar() {
const searchParams = useSearchParams() const searchParams = useSearchParams()
const isLanding = pathname === "/" 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( const [searchQuery, setSearchQuery] = useState(
() => searchParams.get("q") || "", () => searchParams.get("q") || "",
@@ -140,6 +152,7 @@ export default function Navbar() {
src={logo} src={logo}
alt='DeckyVault Logo' alt='DeckyVault Logo'
className='h-6 my-1 w-auto' className='h-6 my-1 w-auto'
loading='eager'
/> />
{!isLanding && ( {!isLanding && (
<motion.span className='hidden md:inline-block'> <motion.span className='hidden md:inline-block'>
@@ -243,7 +256,9 @@ export default function Navbar() {
className='relative' className='relative'
> >
<button <button
onClick={() => setUserMenuOpen(!userMenuOpen)} onClick={() =>
setUserMenuOpen(!userMenuOpen)
}
className='text-sm font-medium hover:text-primary transition-colors uppercase cursor-pointer' className='text-sm font-medium hover:text-primary transition-colors uppercase cursor-pointer'
> >
Profile Profile
@@ -252,18 +267,30 @@ export default function Navbar() {
<> <>
<div <div
className='fixed inset-0 z-40' className='fixed inset-0 z-40'
onClick={() => setUserMenuOpen(false)} onClick={() =>
setUserMenuOpen(false)
}
/> />
<div className='absolute right-0 top-full mt-1 w-48 bg-[#1a1020] border border-white/10 rounded-lg shadow-lg z-50 py-1'> <div className='absolute right-0 top-full mt-1 w-48 bg-[#1a1020] border border-white/10 rounded-lg shadow-lg z-50 py-1'>
{authRoutes.map((route) => ( {authRoutes.map((route) => (
<Link <Link
key={route.href} key={route.href}
href={route.href} href={route.href}
onClick={() => setUserMenuOpen(false)} onClick={() =>
setUserMenuOpen(
false,
)
}
className='w-full flex items-center gap-2 px-3 py-2 text-sm text-text/70 hover:text-text hover:bg-text/5 transition-colors cursor-pointer' className='w-full flex items-center gap-2 px-3 py-2 text-sm text-text/70 hover:text-text hover:bg-text/5 transition-colors cursor-pointer'
> >
{route.icon === "User" && <User className='h-4 w-4' />} {route.icon ===
{route.icon === "Bookmark" && <Bookmark className='h-4 w-4' />} "User" && (
<User className='h-4 w-4' />
)}
{route.icon ===
"Bookmark" && (
<Bookmark className='h-4 w-4' />
)}
{route.title} {route.title}
</Link> </Link>
))} ))}
@@ -370,11 +397,17 @@ export default function Navbar() {
<Link <Link
key={route.href} key={route.href}
href={route.href} href={route.href}
onClick={() => setMobileMenuOpen(false)} onClick={() =>
setMobileMenuOpen(false)
}
className='flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-text/70 hover:text-text hover:bg-text/5 transition-colors cursor-pointer' className='flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-text/70 hover:text-text hover:bg-text/5 transition-colors cursor-pointer'
> >
{route.icon === "User" && <User className='h-4 w-4' />} {route.icon === "User" && (
{route.icon === "Bookmark" && <Bookmark className='h-4 w-4' />} <User className='h-4 w-4' />
)}
{route.icon === "Bookmark" && (
<Bookmark className='h-4 w-4' />
)}
{route.title} {route.title}
</Link> </Link>
))} ))}
@@ -393,7 +426,9 @@ export default function Navbar() {
<div className='space-y-2'> <div className='space-y-2'>
<Link <Link
href='/login' href='/login'
onClick={() => setMobileMenuOpen(false)} onClick={() =>
setMobileMenuOpen(false)
}
className='block w-full text-center px-3 py-2 rounded-lg border border-white/10 text-sm text-text hover:bg-text/5 transition-colors cursor-pointer' className='block w-full text-center px-3 py-2 rounded-lg border border-white/10 text-sm text-text hover:bg-text/5 transition-colors cursor-pointer'
> >
Login Login
+48 -20
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useState, useEffect, useRef } from "react" import { useState, useEffect } from "react"
import { User, Shield, Link as LinkIcon } from "lucide-react" import { User, Shield, Link as LinkIcon } from "lucide-react"
import { SettingsProfileTab } from "@/components/profile/settings-profile-tab" import { SettingsProfileTab } from "@/components/profile/settings-profile-tab"
import { SettingsSecurityTab } from "@/components/profile/settings-security-tab" import { SettingsSecurityTab } from "@/components/profile/settings-security-tab"
@@ -28,45 +28,73 @@ interface SettingsContainerProps {
createdAt: string createdAt: string
} }
export function SettingsContainer({ name, email, role, createdAt }: SettingsContainerProps) { export function SettingsContainer({
name,
email,
role,
createdAt,
}: SettingsContainerProps) {
const [activeSubTab, setActiveSubTab] = useState<SettingsSubTab>("profile") const [activeSubTab, setActiveSubTab] = useState<SettingsSubTab>("profile")
const [authMethods, setAuthMethods] = useState<AuthMethods | null>(null) const [authMethods, setAuthMethods] = useState<AuthMethods | null>(null)
const [isLoadingAuthMethods, setIsLoadingAuthMethods] = useState(true) const [isLoadingAuthMethods, setIsLoadingAuthMethods] = useState(true)
const fetchRef = useRef(false)
useEffect(() => { useEffect(() => {
if (fetchRef.current) return const controller = new AbortController()
fetchRef.current = true const timeoutId = setTimeout(() => controller.abort(), 10000)
let cancelled = false fetch("/api/user/me/auth-methods", {
fetch("/api/user/me/auth-methods", { credentials: "include" }) credentials: "include",
.then(r => r.ok ? r.json() : null) signal: controller.signal,
.then(data => { })
if (cancelled) return .then(async (r) => {
if (r.ok) {
return r.json()
}
// Log non-OK responses for debugging
console.warn(`[auth-methods] fetch returned ${r.status}`)
return null
})
.then((data) => {
if (data) setAuthMethods(data) if (data) setAuthMethods(data)
setIsLoadingAuthMethods(false) setIsLoadingAuthMethods(false)
}) })
.catch(() => { .catch((err) => {
if (err.name !== "AbortError") {
console.error("[auth-methods] fetch failed:", err)
}
setIsLoadingAuthMethods(false) setIsLoadingAuthMethods(false)
}) })
.finally(() => clearTimeout(timeoutId))
return () => { cancelled = true } return () => {
controller.abort()
clearTimeout(timeoutId)
}
}, []) }, [])
const refreshAuthMethods = async () => { const refreshAuthMethods = async () => {
setIsLoadingAuthMethods(true)
try { try {
const res = await fetch("/api/user/me/auth-methods", { credentials: "include" }) const res = await fetch("/api/user/me/auth-methods", {
if (res.ok) setAuthMethods(await res.json()) credentials: "include",
})
if (res.ok) {
setAuthMethods(await res.json())
} else {
setAuthMethods(null)
}
} catch { } catch {
// silently fail setAuthMethods(null)
} finally {
setIsLoadingAuthMethods(false)
} }
} }
return ( return (
<div className="flex flex-col md:flex-row gap-6"> <div className='flex flex-col md:flex-row gap-6'>
{/* Sidebar Navigation */} {/* Sidebar Navigation */}
<nav className="md:w-48 shrink-0"> <nav className='md:w-48 shrink-0'>
<div className="flex md:flex-col gap-1 overflow-x-auto md:overflow-visible pb-2 md:pb-0 md:border-r md:border-border"> <div className='flex md:flex-col gap-1 overflow-x-auto md:overflow-visible pb-2 md:pb-0 md:border-r md:border-border'>
{subTabs.map((tab) => ( {subTabs.map((tab) => (
<button <button
key={tab.id} key={tab.id}
@@ -77,7 +105,7 @@ export function SettingsContainer({ name, email, role, createdAt }: SettingsCont
: "text-text/50 hover:text-text/70 hover:bg-text/5" : "text-text/50 hover:text-text/70 hover:bg-text/5"
}`} }`}
> >
<tab.icon className="h-4 w-4 shrink-0" /> <tab.icon className='h-4 w-4 shrink-0' />
{tab.label} {tab.label}
</button> </button>
))} ))}
@@ -85,7 +113,7 @@ export function SettingsContainer({ name, email, role, createdAt }: SettingsCont
</nav> </nav>
{/* Content Area */} {/* Content Area */}
<div className="flex-1 min-w-0"> <div className='flex-1 min-w-0'>
{activeSubTab === "profile" && ( {activeSubTab === "profile" && (
<SettingsProfileTab <SettingsProfileTab
name={name} name={name}
+257 -112
View File
@@ -2,7 +2,17 @@
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { authClient } from "@/lib/auth-client" 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" import { motion, AnimatePresence } from "motion/react"
interface Passkey { interface Passkey {
@@ -25,11 +35,18 @@ interface SettingsSecurityTabProps {
onRefreshAuthMethods: () => Promise<void> onRefreshAuthMethods: () => Promise<void>
} }
export function SettingsSecurityTab({ authMethods, isLoadingAuthMethods, onRefreshAuthMethods }: SettingsSecurityTabProps) { export function SettingsSecurityTab({
authMethods,
isLoadingAuthMethods,
onRefreshAuthMethods,
}: SettingsSecurityTabProps) {
const [passkeys, setPasskeys] = useState<Passkey[]>([]) const [passkeys, setPasskeys] = useState<Passkey[]>([])
const [isLoadingPasskeys, setIsLoadingPasskeys] = useState(true) const [isLoadingPasskeys, setIsLoadingPasskeys] = useState(true)
const [passkeysError, setPasskeysError] = useState<string | null>(null) const [passkeysError, setPasskeysError] = useState<string | null>(null)
const [passwordMessage, setPasswordMessage] = useState<{ type: "success" | "error"; text: string } | null>(null) const [passwordMessage, setPasswordMessage] = useState<{
type: "success" | "error"
text: string
} | null>(null)
const [isPasswordSubmitting, setIsPasswordSubmitting] = useState(false) const [isPasswordSubmitting, setIsPasswordSubmitting] = useState(false)
// Password form state // Password form state
@@ -38,12 +55,19 @@ export function SettingsSecurityTab({ authMethods, isLoadingAuthMethods, onRefre
const [confirmPassword, setConfirmPassword] = useState("") const [confirmPassword, setConfirmPassword] = useState("")
// Passkey state // Passkey state
const [editingPasskeyId, setEditingPasskeyId] = useState<string | null>(null) const [editingPasskeyId, setEditingPasskeyId] = useState<string | null>(
null,
)
const [editingName, setEditingName] = useState("") const [editingName, setEditingName] = useState("")
const [isUpdatingPasskey, setIsUpdatingPasskey] = useState(false) const [isUpdatingPasskey, setIsUpdatingPasskey] = useState(false)
const [isDeletingPasskey, setIsDeletingPasskey] = useState<string | null>(null) const [isDeletingPasskey, setIsDeletingPasskey] = useState<string | null>(
null,
)
const [isAddingPasskey, setIsAddingPasskey] = useState(false) const [isAddingPasskey, setIsAddingPasskey] = useState(false)
const [passkeyMessage, setPasskeyMessage] = useState<{ type: "success" | "error"; text: string } | null>(null) const [passkeyMessage, setPasskeyMessage] = useState<{
type: "success" | "error"
text: string
} | null>(null)
useEffect(() => { useEffect(() => {
fetchPasskeys() fetchPasskeys()
@@ -51,7 +75,9 @@ export function SettingsSecurityTab({ authMethods, isLoadingAuthMethods, onRefre
async function fetchPasskeys() { async function fetchPasskeys() {
try { try {
const res = await fetch("/api/auth/passkey/list-user-passkeys", { credentials: "include" }) const res = await fetch("/api/auth/passkey/list-user-passkeys", {
credentials: "include",
})
if (!res.ok) { if (!res.ok) {
setPasskeysError("Failed to load passkeys") setPasskeysError("Failed to load passkeys")
setPasskeys([]) setPasskeys([])
@@ -70,7 +96,9 @@ export function SettingsSecurityTab({ authMethods, isLoadingAuthMethods, onRefre
const refreshPasskeys = async () => { const refreshPasskeys = async () => {
try { try {
const res = await fetch("/api/auth/passkey/list-user-passkeys", { credentials: "include" }) const res = await fetch("/api/auth/passkey/list-user-passkeys", {
credentials: "include",
})
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
setPasskeys(Array.isArray(data) ? data : []) setPasskeys(Array.isArray(data) ? data : [])
@@ -86,12 +114,18 @@ export function SettingsSecurityTab({ authMethods, isLoadingAuthMethods, onRefre
setPasswordMessage(null) setPasswordMessage(null)
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
setPasswordMessage({ type: "error", text: "Passwords do not match" }) setPasswordMessage({
type: "error",
text: "Passwords do not match",
})
return return
} }
if (newPassword.length < 10) { if (newPassword.length < 10) {
setPasswordMessage({ type: "error", text: "Password must be at least 10 characters" }) setPasswordMessage({
type: "error",
text: "Password must be at least 10 characters",
})
return return
} }
@@ -105,9 +139,15 @@ export function SettingsSecurityTab({ authMethods, isLoadingAuthMethods, onRefre
}) })
if (error) { if (error) {
setPasswordMessage({ type: "error", text: error.message || "Failed to change password" }) setPasswordMessage({
type: "error",
text: error.message || "Failed to change password",
})
} else { } else {
setPasswordMessage({ type: "success", text: "Password changed successfully" }) setPasswordMessage({
type: "success",
text: "Password changed successfully",
})
setCurrentPassword("") setCurrentPassword("")
setNewPassword("") setNewPassword("")
setConfirmPassword("") setConfirmPassword("")
@@ -122,17 +162,28 @@ export function SettingsSecurityTab({ authMethods, isLoadingAuthMethods, onRefre
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({ error: "Failed to set password" })) const data = await res
setPasswordMessage({ type: "error", text: data.error || "Failed to set password" }) .json()
.catch(() => ({ error: "Failed to set password" }))
setPasswordMessage({
type: "error",
text: data.error || "Failed to set password",
})
} else { } else {
setPasswordMessage({ type: "success", text: "Password set successfully" }) setPasswordMessage({
type: "success",
text: "Password set successfully",
})
setNewPassword("") setNewPassword("")
setConfirmPassword("") setConfirmPassword("")
await onRefreshAuthMethods() await onRefreshAuthMethods()
} }
} }
} catch { } catch {
setPasswordMessage({ type: "error", text: "An unexpected error occurred" }) setPasswordMessage({
type: "error",
text: "An unexpected error occurred",
})
} finally { } finally {
setIsPasswordSubmitting(false) setIsPasswordSubmitting(false)
} }
@@ -144,14 +195,23 @@ export function SettingsSecurityTab({ authMethods, isLoadingAuthMethods, onRefre
try { try {
const { error } = await authClient.passkey.addPasskey() const { error } = await authClient.passkey.addPasskey()
if (error) { if (error) {
setPasskeyMessage({ type: "error", text: error.message || "Failed to add passkey" }) setPasskeyMessage({
type: "error",
text: error.message || "Failed to add passkey",
})
} else { } else {
setPasskeyMessage({ type: "success", text: "Passkey added successfully" }) setPasskeyMessage({
type: "success",
text: "Passkey added successfully",
})
await refreshPasskeys() await refreshPasskeys()
await onRefreshAuthMethods() await onRefreshAuthMethods()
} }
} catch { } catch {
setPasskeyMessage({ type: "error", text: "Failed to add passkey. Please try again." }) setPasskeyMessage({
type: "error",
text: "Failed to add passkey. Please try again.",
})
} finally { } finally {
setIsAddingPasskey(false) setIsAddingPasskey(false)
} }
@@ -171,15 +231,23 @@ export function SettingsSecurityTab({ authMethods, isLoadingAuthMethods, onRefre
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({ message: "Failed to delete passkey" })) const data = await res
setPasskeyMessage({ type: "error", text: data.message || "Failed to delete passkey" }) .json()
.catch(() => ({ message: "Failed to delete passkey" }))
setPasskeyMessage({
type: "error",
text: data.message || "Failed to delete passkey",
})
} else { } else {
setPasskeyMessage({ type: "success", text: "Passkey deleted" }) setPasskeyMessage({ type: "success", text: "Passkey deleted" })
await refreshPasskeys() await refreshPasskeys()
await onRefreshAuthMethods() await onRefreshAuthMethods()
} }
} catch { } catch {
setPasskeyMessage({ type: "error", text: "Failed to delete passkey. Please try again." }) setPasskeyMessage({
type: "error",
text: "Failed to delete passkey. Please try again.",
})
} finally { } finally {
setIsDeletingPasskey(null) setIsDeletingPasskey(null)
} }
@@ -197,8 +265,13 @@ export function SettingsSecurityTab({ authMethods, isLoadingAuthMethods, onRefre
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({ message: "Failed to rename passkey" })) const data = await res
setPasskeyMessage({ type: "error", text: data.message || "Failed to rename passkey" }) .json()
.catch(() => ({ message: "Failed to rename passkey" }))
setPasskeyMessage({
type: "error",
text: data.message || "Failed to rename passkey",
})
} else { } else {
setPasskeyMessage({ type: "success", text: "Passkey renamed" }) setPasskeyMessage({ type: "success", text: "Passkey renamed" })
await refreshPasskeys() await refreshPasskeys()
@@ -206,7 +279,10 @@ export function SettingsSecurityTab({ authMethods, isLoadingAuthMethods, onRefre
setEditingName("") setEditingName("")
} }
} catch { } catch {
setPasskeyMessage({ type: "error", text: "Failed to rename passkey. Please try again." }) setPasskeyMessage({
type: "error",
text: "Failed to rename passkey. Please try again.",
})
} finally { } finally {
setIsUpdatingPasskey(false) setIsUpdatingPasskey(false)
} }
@@ -228,7 +304,7 @@ export function SettingsSecurityTab({ authMethods, isLoadingAuthMethods, onRefre
<motion.div <motion.div
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="space-y-6" className='space-y-6'
> >
{/* Warning for single auth method */} {/* Warning for single auth method */}
<AnimatePresence> <AnimatePresence>
@@ -237,82 +313,114 @@ export function SettingsSecurityTab({ authMethods, isLoadingAuthMethods, onRefre
initial={{ opacity: 0, height: 0 }} initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }} animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }} exit={{ opacity: 0, height: 0 }}
className="rounded-xl border border-yellow-500/30 bg-yellow-500/10 p-4 flex items-start gap-3" className='rounded-xl border border-yellow-500/30 bg-yellow-500/10 p-4 flex items-start gap-3'
> >
<Shield className="h-5 w-5 text-yellow-500 shrink-0 mt-0.5" /> <Shield className='h-5 w-5 text-yellow-500 shrink-0 mt-0.5' />
<p className="text-sm text-yellow-200/80"> <p className='text-sm text-yellow-200/80'>
Single authentication method consider adding a passkey or linking a social account Single authentication method consider adding a
passkey or linking a social account
</p> </p>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
{/* Password Section */} {/* Password Section */}
<div className="rounded-xl border border-border bg-text/[0.03] p-5"> <div className='rounded-xl border border-border bg-text/3 p-5'>
<h3 className="text-sm font-medium uppercase tracking-wider text-text/60 mb-4 flex items-center gap-2"> <h3 className='text-sm font-medium uppercase tracking-wider text-text/60 mb-4 flex items-center gap-2'>
<Key className="h-4 w-4" /> <Key className='h-4 w-4' />
Password Password
</h3> </h3>
{isLoadingAuthMethods ? ( {isLoadingAuthMethods ? (
<div className="flex items-center gap-2 text-sm text-text/50"> <div className='flex items-center gap-2 text-sm text-text/50'>
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className='h-4 w-4 animate-spin' />
Loading... Loading...
</div> </div>
) : authMethods === null ? (
<div className='flex items-center gap-2 text-sm text-red-400'>
Failed to load authentication methods.{' '}
<button
onClick={onRefreshAuthMethods}
className='underline hover:text-red-300 cursor-pointer'
>
Retry
</button>
</div>
) : ( ) : (
<form onSubmit={handlePasswordSubmit} className="space-y-3"> <form
onSubmit={handlePasswordSubmit}
className='space-y-3'
>
{authMethods?.hasPassword && ( {authMethods?.hasPassword && (
<div> <div>
<label className="block text-xs text-text/50 mb-1">Current Password</label> <label className='block text-xs text-text/50 mb-1'>
<input Current Password
type="password"
value={currentPassword}
onChange={(e) => 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
/>
</div>
)}
<div>
<label className="block text-xs text-text/50 mb-1">
{authMethods?.hasPassword ? "New Password" : "Password"}
</label> </label>
<input <input
type="password" type='password'
value={currentPassword}
onChange={(e) =>
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
/>
</div>
)}
<div>
<label className='block text-xs text-text/50 mb-1'>
{authMethods?.hasPassword
? "New Password"
: "Password"}
</label>
<input
type='password'
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => 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" 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"} placeholder={
authMethods?.hasPassword
? "Enter new password"
: "Set a password"
}
required required
/> />
</div> </div>
<div> <div>
<label className="block text-xs text-text/50 mb-1">Confirm Password</label> <label className='block text-xs text-text/50 mb-1'>
Confirm Password
</label>
<input <input
type="password" type='password'
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) =>
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" setConfirmPassword(e.target.value)
placeholder="Confirm password" }
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 required
/> />
</div> </div>
<div className="flex items-center gap-3 pt-1"> <div className='flex items-center gap-3 pt-1'>
<button <button
type="submit" type='submit'
disabled={isPasswordSubmitting} disabled={isPasswordSubmitting}
className="flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer" className='flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer'
> >
{isPasswordSubmitting ? ( {isPasswordSubmitting ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className='h-4 w-4 animate-spin' />
) : ( ) : (
<Key className="h-4 w-4" /> <Key className='h-4 w-4' />
)} )}
{authMethods?.hasPassword ? "Change Password" : "Set Password"} {authMethods?.hasPassword
? "Change Password"
: "Set Password"}
</button> </button>
{passwordMessage && ( {passwordMessage && (
<p className={`text-sm ${passwordMessage.type === "success" ? "text-green-400" : "text-red-400"}`}> <p
className={`text-sm ${passwordMessage.type === "success" ? "text-green-400" : "text-red-400"}`}
>
{passwordMessage.text} {passwordMessage.text}
</p> </p>
)} )}
@@ -322,55 +430,61 @@ export function SettingsSecurityTab({ authMethods, isLoadingAuthMethods, onRefre
</div> </div>
{/* Passkey Section */} {/* Passkey Section */}
<div className="rounded-xl border border-border bg-text/[0.03] p-5"> <div className='rounded-xl border border-border bg-text/3 p-5'>
<div className="flex items-center justify-between mb-4"> <div className='flex items-center justify-between mb-4'>
<h3 className="text-sm font-medium uppercase tracking-wider text-text/60 flex items-center gap-2"> <h3 className='text-sm font-medium uppercase tracking-wider text-text/60 flex items-center gap-2'>
<Fingerprint className="h-4 w-4" /> <Fingerprint className='h-4 w-4' />
Passkeys Passkeys
</h3> </h3>
<button <button
onClick={handleAddPasskey} onClick={handleAddPasskey}
disabled={isAddingPasskey} disabled={isAddingPasskey}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-xs font-medium hover:border-primary/40 hover:text-primary transition-colors disabled:opacity-50 cursor-pointer" className='flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-xs font-medium hover:border-primary/40 hover:text-primary transition-colors disabled:opacity-50 cursor-pointer'
> >
{isAddingPasskey ? ( {isAddingPasskey ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className='h-3.5 w-3.5 animate-spin' />
) : ( ) : (
<Plus className="h-3.5 w-3.5" /> <Plus className='h-3.5 w-3.5' />
)} )}
Add Passkey Add Passkey
</button> </button>
</div> </div>
{passkeyMessage && ( {passkeyMessage && (
<div className={`mb-3 text-sm ${passkeyMessage.type === "success" ? "text-green-400" : "text-red-400"}`}> <div
className={`mb-3 text-sm ${passkeyMessage.type === "success" ? "text-green-400" : "text-red-400"}`}
>
{passkeyMessage.text} {passkeyMessage.text}
</div> </div>
)} )}
{passkeysError ? ( {passkeysError ? (
<div className="text-center py-4"> <div className='text-center py-4'>
<p className="text-sm text-red-400 mb-2">{passkeysError}</p> <p className='text-sm text-red-400 mb-2'>
{passkeysError}
</p>
<button <button
onClick={() => { onClick={() => {
setPasskeysError(null) setPasskeysError(null)
setIsLoadingPasskeys(true) setIsLoadingPasskeys(true)
fetchPasskeys() fetchPasskeys()
}} }}
className="text-sm text-primary hover:underline cursor-pointer" className='text-sm text-primary hover:underline cursor-pointer'
> >
Retry Retry
</button> </button>
</div> </div>
) : isLoadingPasskeys ? ( ) : isLoadingPasskeys ? (
<div className="flex items-center gap-2 text-sm text-text/50"> <div className='flex items-center gap-2 text-sm text-text/50'>
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className='h-4 w-4 animate-spin' />
Loading passkeys... Loading passkeys...
</div> </div>
) : passkeys.length === 0 ? ( ) : passkeys.length === 0 ? (
<p className="text-sm text-text/50">No passkeys registered.</p> <p className='text-sm text-text/50'>
No passkeys registered.
</p>
) : ( ) : (
<div className="space-y-3"> <div className='space-y-3'>
<AnimatePresence> <AnimatePresence>
{passkeys.map((pk) => ( {passkeys.map((pk) => (
<motion.div <motion.div
@@ -378,52 +492,71 @@ export function SettingsSecurityTab({ authMethods, isLoadingAuthMethods, onRefre
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0, height: 0 }} exit={{ opacity: 0, height: 0 }}
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-text/[0.02] p-3" className='flex items-center justify-between gap-3 rounded-lg border border-border bg-text/2 p-3'
> >
<div className="flex items-center gap-3 min-w-0"> <div className='flex items-center gap-3 min-w-0'>
<Fingerprint className="h-4 w-4 text-text/40 shrink-0" /> <Fingerprint className='h-4 w-4 text-text/40 shrink-0' />
<div className="min-w-0"> <div className='min-w-0'>
{editingPasskeyId === pk.id ? ( {editingPasskeyId === pk.id ? (
<div className="flex items-center gap-2"> <div className='flex items-center gap-2'>
<input <input
type="text" type='text'
value={editingName} value={editingName}
onChange={(e) => setEditingName(e.target.value)} onChange={(e) =>
className="px-2 py-1 rounded bg-text/5 border border-border text-sm text-text focus:outline-none focus:border-primary/60" 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 autoFocus
/> />
<button <button
onClick={() => handleRenamePasskey(pk.id)} onClick={() =>
disabled={isUpdatingPasskey} handleRenamePasskey(
className="p-1 rounded hover:bg-green-500/10 text-green-400 transition-colors cursor-pointer disabled:cursor-not-allowed" pk.id,
)
}
disabled={
isUpdatingPasskey
}
className='p-1 rounded hover:bg-green-500/10 text-green-400 transition-colors cursor-pointer disabled:cursor-not-allowed'
> >
{isUpdatingPasskey ? ( {isUpdatingPasskey ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className='h-3.5 w-3.5 animate-spin' />
) : ( ) : (
<Check className="h-3.5 w-3.5" /> <Check className='h-3.5 w-3.5' />
)} )}
</button> </button>
<button <button
onClick={cancelEditingPasskey} onClick={
className="p-1 rounded hover:bg-red-500/10 text-red-400 transition-colors cursor-pointer" cancelEditingPasskey
}
className='p-1 rounded hover:bg-red-500/10 text-red-400 transition-colors cursor-pointer'
> >
<X className="h-3.5 w-3.5" /> <X className='h-3.5 w-3.5' />
</button> </button>
</div> </div>
) : ( ) : (
<> <>
<p className="text-sm font-medium text-text truncate"> <p className='text-sm font-medium text-text truncate'>
{pk.name || "Unnamed passkey"} {pk.name ||
"Unnamed passkey"}
</p> </p>
<p className="text-xs text-text/50"> <p className='text-xs text-text/50'>
{pk.deviceType} {pk.deviceType}
{pk.createdAt && ( {pk.createdAt && (
<span className="ml-1"> <span className='ml-1'>
· Added {new Date(pk.createdAt).toLocaleDateString("en-US", { · Added{" "}
{new Date(
pk.createdAt,
).toLocaleDateString(
"en-US",
{
month: "short", month: "short",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
})} },
)}
</span> </span>
)} )}
</p> </p>
@@ -432,23 +565,35 @@ export function SettingsSecurityTab({ authMethods, isLoadingAuthMethods, onRefre
</div> </div>
</div> </div>
{editingPasskeyId !== pk.id && ( {editingPasskeyId !== pk.id && (
<div className="flex items-center gap-1 shrink-0"> <div className='flex items-center gap-1 shrink-0'>
<button <button
onClick={() => startEditingPasskey(pk)} onClick={() =>
className="p-1.5 rounded hover:bg-text/5 text-text/40 hover:text-text/80 transition-colors cursor-pointer" startEditingPasskey(pk)
}
className='p-1.5 rounded hover:bg-text/5 text-text/40 hover:text-text/80 transition-colors cursor-pointer'
> >
<Pencil className="h-3.5 w-3.5" /> <Pencil className='h-3.5 w-3.5' />
</button> </button>
<button <button
onClick={() => handleDeletePasskey(pk.id)} onClick={() =>
disabled={isDeletingPasskey === pk.id || !!isSingleAuthMethod} handleDeletePasskey(pk.id)
title={isSingleAuthMethod ? "Cannot remove your only authentication method" : undefined} }
className="p-1.5 rounded hover:bg-red-500/10 text-text/40 hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer" disabled={
isDeletingPasskey ===
pk.id ||
!!isSingleAuthMethod
}
title={
isSingleAuthMethod
? "Cannot remove your only authentication method"
: undefined
}
className='p-1.5 rounded hover:bg-red-500/10 text-text/40 hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer'
> >
{isDeletingPasskey === pk.id ? ( {isDeletingPasskey === pk.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className='h-3.5 w-3.5 animate-spin' />
) : ( ) : (
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className='h-3.5 w-3.5' />
)} )}
</button> </button>
</div> </div>
@@ -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");
+7
View File
@@ -43,6 +43,13 @@
"when": 1777217663641, "when": 1777217663641,
"tag": "0005_brief_marvel_zombies", "tag": "0005_brief_marvel_zombies",
"breakpoints": true "breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1777300000000,
"tag": "0006_upscaler_battery_custom_system",
"breakpoints": true
} }
] ]
} }
+13 -6
View File
@@ -34,7 +34,8 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
fpsAvg: performanceEntries.fpsAvg, fpsAvg: performanceEntries.fpsAvg,
fpsLow: performanceEntries.fpsLow, fpsLow: performanceEntries.fpsLow,
fpsHigh: performanceEntries.fpsHigh, fpsHigh: performanceEntries.fpsHigh,
fsrVersion: performanceEntries.fsrVersion, upscalerType: performanceEntries.upscalerType,
upscalerVersion: performanceEntries.upscalerVersion,
frameGenMethod: performanceEntries.frameGenMethod, frameGenMethod: performanceEntries.frameGenMethod,
protonVersion: performanceEntries.protonVersion, protonVersion: performanceEntries.protonVersion,
osVersion: performanceEntries.osVersion, osVersion: performanceEntries.osVersion,
@@ -112,10 +113,13 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
const isRawPerformer = entries.some( const isRawPerformer = entries.some(
(e) => (e) =>
(e.fpsAvg ?? 0) >= 60 && (e.fpsAvg ?? 0) >= 60 &&
e.fsrVersion === "none" && e.upscalerType === "none" &&
e.frameGenMethod === "none", e.frameGenMethod === "none",
) )
// ── 3b. Poor Performance check ─────────────────────────────────
const isPoorPerformance = entries.some((e) => (e.fpsAvg ?? 0) < 30)
// ── 4. Boxplot per device ───────────────────────────────────── // ── 4. Boxplot per device ─────────────────────────────────────
const boxplotMap = new Map< const boxplotMap = new Map<
string, string,
@@ -185,7 +189,7 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
{ hardwareSlug: string; sum: number; count: number } { hardwareSlug: string; sum: number; count: number }
>() >()
for (const e of entries) { 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) || { const existing = upscalerMap.get(key) || {
hardwareSlug: e.hardwareSlug, hardwareSlug: e.hardwareSlug,
sum: 0, sum: 0,
@@ -198,9 +202,10 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
const upscalerStats = Array.from(upscalerMap.entries()).map( const upscalerStats = Array.from(upscalerMap.entries()).map(
([key, data]) => { ([key, data]) => {
const [fsrVersion, frameGenMethod] = key.split("|") const [upscalerType, upscalerVersion, frameGenMethod] = key.split("|")
return { return {
fsrVersion, upscalerType,
upscalerVersion: upscalerVersion || null,
frameGenMethod, frameGenMethod,
hardwareSlug: data.hardwareSlug, hardwareSlug: data.hardwareSlug,
avgFps: Math.round((data.sum / data.count) * 10) / 10, avgFps: Math.round((data.sum / data.count) * 10) / 10,
@@ -220,8 +225,9 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
fpsHigh: e.fpsHigh!, fpsHigh: e.fpsHigh!,
isRawPerformer: isRawPerformer:
(e.fpsAvg ?? 0) >= 60 && (e.fpsAvg ?? 0) >= 60 &&
e.fsrVersion === "none" && e.upscalerType === "none" &&
e.frameGenMethod === "none", e.frameGenMethod === "none",
isPoorPerformer: (e.fpsAvg ?? 0) < 30,
})) }))
// ── 8. Device breakdown ─────────────────────────────────────── // ── 8. Device breakdown ───────────────────────────────────────
@@ -261,6 +267,7 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
versionCount, versionCount,
}, },
isRawPerformer, isRawPerformer,
isPoorPerformance,
boxplot, boxplot,
historical, historical,
upscalerStats, upscalerStats,
+11 -10
View File
@@ -120,7 +120,8 @@ export const hardwareStatsRoutes = new Elysia({ prefix: "/hardware" })
fpsAvg: performanceEntries.fpsAvg, fpsAvg: performanceEntries.fpsAvg,
fpsLow: performanceEntries.fpsLow, fpsLow: performanceEntries.fpsLow,
fpsHigh: performanceEntries.fpsHigh, fpsHigh: performanceEntries.fpsHigh,
fsrVersion: performanceEntries.fsrVersion, upscalerType: performanceEntries.upscalerType,
upscalerVersion: performanceEntries.upscalerVersion,
frameGenMethod: performanceEntries.frameGenMethod, frameGenMethod: performanceEntries.frameGenMethod,
protonVersion: performanceEntries.protonVersion, protonVersion: performanceEntries.protonVersion,
osVersion: performanceEntries.osVersion, osVersion: performanceEntries.osVersion,
@@ -152,7 +153,7 @@ export const hardwareStatsRoutes = new Elysia({ prefix: "/hardware" })
topGames: [], topGames: [],
genreBreakdown: [], genreBreakdown: [],
protonBreakdown: [], protonBreakdown: [],
fsrBreakdown: [], upscalerBreakdown: [],
} }
} }
@@ -250,17 +251,17 @@ export const hardwareStatsRoutes = new Elysia({ prefix: "/hardware" })
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.map(([version, count]) => ({ version, count })) .map(([version, count]) => ({ version, count }))
// ── FSR breakdown ─────────────────────────────────── // ── Upscaler breakdown ───────────────────────────────────
const fsrMap = new Map<string, { count: number; avgFps: number }>() const upscalerMap = new Map<string, { count: number; avgFps: number }>()
for (const e of entries) { for (const e of entries) {
const key = e.fsrVersion ?? "none" const key = e.upscalerType ?? "none"
if (!fsrMap.has(key)) fsrMap.set(key, { count: 0, avgFps: 0 }) if (!upscalerMap.has(key)) upscalerMap.set(key, { count: 0, avgFps: 0 })
const f = fsrMap.get(key)! const f = upscalerMap.get(key)!
f.count++ f.count++
f.avgFps += e.fpsAvg ?? 0 f.avgFps += e.fpsAvg ?? 0
} }
const fsrBreakdown = [...fsrMap.entries()].map(([version, data]) => ({ const upscalerBreakdown = [...upscalerMap.entries()].map(([type, data]) => ({
version, upscalerType: type,
count: data.count, count: data.count,
avgFps: Math.round((data.avgFps / data.count) * 10) / 10, avgFps: Math.round((data.avgFps / data.count) * 10) / 10,
})) }))
@@ -276,7 +277,7 @@ export const hardwareStatsRoutes = new Elysia({ prefix: "/hardware" })
topGames, topGames,
genreBreakdown, genreBreakdown,
protonBreakdown, protonBreakdown,
fsrBreakdown, upscalerBreakdown,
} }
}, },
{ {
+15 -5
View File
@@ -106,7 +106,10 @@ export const performanceSubmitRoutes = new Elysia({ prefix: "/performance" })
fpsHigh: body.fpsHigh ?? null, fpsHigh: body.fpsHigh ?? null,
protonVersion: body.protonVersion ?? null, protonVersion: body.protonVersion ?? null,
osVersion: body.osVersion ?? 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", frameGenMethod: body.frameGenMethod ?? "none",
loadTimeSsd: body.loadTimeSsd ?? null, loadTimeSsd: body.loadTimeSsd ?? null,
loadTimeSd: body.loadTimeSd ?? 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()])), fpsHigh: t.Optional(t.Union([t.Number(), t.Null()])),
protonVersion: t.Optional(t.Union([t.String(), t.Null()])), protonVersion: t.Optional(t.Union([t.String(), t.Null()])),
osVersion: 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.Union([
t.Literal("none"), t.Literal("none"),
t.Literal("fsr1"), t.Literal("fsr"),
t.Literal("fsr2"), t.Literal("dlss"),
t.Literal("fsr3"), 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( frameGenMethod: t.Optional(
t.Union([ t.Union([
t.Literal("none"), t.Literal("none"),
t.Literal("fsr_fg"), t.Literal("fsr_fg"),
t.Literal("dlss_fg"), t.Literal("dlss_fg"),
t.Literal("lsfg"),
t.Literal("other"),
]), ]),
), ),
loadTimeSsd: t.Optional(t.Union([t.Number(), t.Null()])), loadTimeSsd: t.Optional(t.Union([t.Number(), t.Null()])),
+13 -7
View File
@@ -12,7 +12,7 @@ export const performanceRoutes = createCrudRoutes(performanceEntries, {
auth: { read: "public", write: "user", delete: "admin" }, auth: { read: "public", write: "user", delete: "admin" },
softDelete: true, softDelete: true,
search: { fields: ["userNotes"] }, search: { fields: ["userNotes"] },
filter: { fields: ["hardwareSlug", "fsrVersion", "frameGenMethod"] }, filter: { fields: ["hardwareSlug", "upscalerType", "upscalerVersion", "frameGenMethod"] },
}) })
// ── Verify endpoint (admin/mod) ─────────────────────────────────── // ── Verify endpoint (admin/mod) ───────────────────────────────────
@@ -183,7 +183,8 @@ export const performanceVerifyRoutes = new Elysia({
fpsAvg: performanceEntries.fpsAvg, fpsAvg: performanceEntries.fpsAvg,
fpsLow: performanceEntries.fpsLow, fpsLow: performanceEntries.fpsLow,
fpsHigh: performanceEntries.fpsHigh, fpsHigh: performanceEntries.fpsHigh,
fsrVersion: performanceEntries.fsrVersion, upscalerType: performanceEntries.upscalerType,
upscalerVersion: performanceEntries.upscalerVersion,
frameGenMethod: performanceEntries.frameGenMethod, frameGenMethod: performanceEntries.frameGenMethod,
settingsJson: performanceEntries.settingsJson, settingsJson: performanceEntries.settingsJson,
userNotes: performanceEntries.userNotes, userNotes: performanceEntries.userNotes,
@@ -227,10 +228,11 @@ export const performanceVerifyRoutes = new Elysia({
.get( .get(
"/stats", "/stats",
async ({ query, set }) => { async ({ query, set }) => {
const { gameId, hardwareSlug, fsrVersion } = query as { const { gameId, hardwareSlug, upscalerType, upscalerVersion } = query as {
gameId?: string gameId?: string
hardwareSlug?: string hardwareSlug?: string
fsrVersion?: string upscalerType?: string
upscalerVersion?: string
} }
if (!gameId) { if (!gameId) {
@@ -246,8 +248,11 @@ export const performanceVerifyRoutes = new Elysia({
if (hardwareSlug) { if (hardwareSlug) {
conditions.push(eq(performanceEntries.hardwareSlug, hardwareSlug)) conditions.push(eq(performanceEntries.hardwareSlug, hardwareSlug))
} }
if (fsrVersion) { if (upscalerType) {
conditions.push(eq(performanceEntries.fsrVersion, fsrVersion as any)) // eslint-disable-line @typescript-eslint/no-explicit-any 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 // Join through gameVersions to get to games
@@ -272,7 +277,8 @@ export const performanceVerifyRoutes = new Elysia({
query: t.Object({ query: t.Object({
gameId: t.String(), gameId: t.String(),
hardwareSlug: t.Optional(t.String()), hardwareSlug: t.Optional(t.String()),
fsrVersion: t.Optional(t.String()), upscalerType: t.Optional(t.String()),
upscalerVersion: t.Optional(t.String()),
}), }),
}, },
) )
+6 -2
View File
@@ -146,8 +146,9 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
countMap.get(c.gameId)!.comments = c.count countMap.get(c.gameId)!.comments = c.count
} }
// ── 2b. Raw Performer + best FPS ──────────────────────────────── // ── 2b. Raw Performer + Poor Performance + best FPS ────────────
const rawPerformerMap = new Map<string, boolean>() const rawPerformerMap = new Map<string, boolean>()
const poorPerformerMap = new Map<string, boolean>()
const bestFpsMap = new Map<string, number>() const bestFpsMap = new Map<string, number>()
if (localGameIds.length > 0) { if (localGameIds.length > 0) {
@@ -157,9 +158,10 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
bestFps: sql<number>`MAX(${performanceEntries.fpsAvg})::real`, bestFps: sql<number>`MAX(${performanceEntries.fpsAvg})::real`,
isRawPerformer: sql<boolean>`BOOL_OR( isRawPerformer: sql<boolean>`BOOL_OR(
${performanceEntries.fpsAvg} >= 60 ${performanceEntries.fpsAvg} >= 60
AND ${performanceEntries.fsrVersion} = 'none' AND ${performanceEntries.upscalerType} = 'none'
AND ${performanceEntries.frameGenMethod} = 'none' AND ${performanceEntries.frameGenMethod} = 'none'
)`, )`,
isPoorPerformance: sql<boolean>`BOOL_OR(${performanceEntries.fpsAvg} < 30)`,
}) })
.from(performanceEntries) .from(performanceEntries)
.innerJoin( .innerJoin(
@@ -177,6 +179,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
for (const row of perfStats) { for (const row of perfStats) {
bestFpsMap.set(row.gameId, row.bestFps) bestFpsMap.set(row.gameId, row.bestFps)
rawPerformerMap.set(row.gameId, row.isRawPerformer) 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, : null,
isRawPerformer: rawPerformerMap.get(g.id) ?? false, isRawPerformer: rawPerformerMap.get(g.id) ?? false,
isPoorPerformance: poorPerformerMap.get(g.id) ?? false,
bestFps: bestFpsMap.get(g.id) ?? null, bestFps: bestFpsMap.get(g.id) ?? null,
latestVersion: latestVersionMap.get(g.id) ?? null, latestVersion: latestVersionMap.get(g.id) ?? null,
}) })
+8 -6
View File
@@ -1,7 +1,7 @@
import { Elysia, t } from "elysia" import { Elysia, t } from "elysia"
import { auth } from "@/lib/auth" import { auth } from "@/lib/auth"
import { db } from "@/lib/db/index" 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 { eq, sql, and, desc } from "drizzle-orm"
import { hashPassword } from "better-auth/crypto" import { hashPassword } from "better-auth/crypto"
@@ -155,10 +155,11 @@ export const userRoutes = new Elysia({ prefix: "/user" })
.from(account) .from(account)
.where(eq(account.userId, session.user.id)) .where(eq(account.userId, session.user.id))
// Count passkeys // Count passkeys via direct DB query (avoids auth.api.listPasskeys hanging)
const passkeys = await auth.api.listPasskeys({ const passkeys = await db
headers: request.headers, .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") // Check if user has a password (from accounts where providerId is "credential")
const hasPassword = accounts.some((a) => a.providerId === "credential") const hasPassword = accounts.some((a) => a.providerId === "credential")
@@ -256,7 +257,8 @@ export const userRoutes = new Elysia({ prefix: "/user" })
fpsHigh: performanceEntries.fpsHigh, fpsHigh: performanceEntries.fpsHigh,
hardwareSlug: performanceEntries.hardwareSlug, hardwareSlug: performanceEntries.hardwareSlug,
hardwareName: hardware.name, hardwareName: hardware.name,
fsrVersion: performanceEntries.fsrVersion, upscalerType: performanceEntries.upscalerType,
upscalerVersion: performanceEntries.upscalerVersion,
frameGenMethod: performanceEntries.frameGenMethod, frameGenMethod: performanceEntries.frameGenMethod,
verifiedAt: performanceEntries.verifiedAt, verifiedAt: performanceEntries.verifiedAt,
createdAt: performanceEntries.createdAt, createdAt: performanceEntries.createdAt,
+18 -7
View File
@@ -13,17 +13,21 @@ import { gameVersions } from "./gameVersions"
import { hardware } from "./hardware" import { hardware } from "./hardware"
import { user } from "./auth" import { user } from "./auth"
export const fsrVersionEnum = pgEnum("fsr_version", [ export const upscalerTypeEnum = pgEnum("upscaler_type", [
"none", "none",
"fsr1", "fsr",
"fsr2", "dlss",
"fsr3", "xess",
"lsfg",
"other",
]) ])
export const frameGenMethodEnum = pgEnum("frame_gen_method", [ export const frameGenMethodEnum = pgEnum("frame_gen_method", [
"none", "none",
"fsr_fg", "fsr_fg",
"dlss_fg", "dlss_fg",
"lsfg",
"other",
]) ])
export type GameSettingCategory = { export type GameSettingCategory = {
@@ -56,8 +60,9 @@ export const performanceEntries = pgTable(
protonVersion: text("proton_version"), protonVersion: text("proton_version"),
osVersion: text("os_version"), osVersion: text("os_version"),
// Upscaler tracking (replaces isFsrEnabled boolean) // Upscaler tracking
fsrVersion: fsrVersionEnum("fsr_version").default("none").notNull(), upscalerType: upscalerTypeEnum("upscaler_type").default("none").notNull(),
upscalerVersion: text("upscaler_version"),
frameGenMethod: frameGenMethodEnum("frame_gen_method") frameGenMethod: frameGenMethodEnum("frame_gen_method")
.default("none") .default("none")
.notNull(), .notNull(),
@@ -73,6 +78,12 @@ export const performanceEntries = pgTable(
settingsJson: jsonb("settings_json").$type<GameSettingCategory[]>(), settingsJson: jsonb("settings_json").$type<GameSettingCategory[]>(),
userNotes: text("user_notes"), userNotes: text("user_notes"),
// Battery estimate (minutes)
estimatedBatteryMin: integer("estimated_battery_min"),
// Custom system flag
customSystem: boolean("custom_system").default(false).notNull(),
// Moderation // Moderation
isRemoved: boolean("is_removed").default(false).notNull(), isRemoved: boolean("is_removed").default(false).notNull(),
removedReason: text("removed_reason"), removedReason: text("removed_reason"),
@@ -91,7 +102,7 @@ export const performanceEntries = pgTable(
updatedAt: timestamp("updated_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(),
}, },
(table) => [ (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_version_idx").on(table.versionId),
index("perf_user_idx").on(table.userId), index("perf_user_idx").on(table.userId),
], ],
+2 -1
View File
@@ -11,7 +11,8 @@ export type ContributionEntry = {
fpsHigh: number | null fpsHigh: number | null
hardwareSlug: string hardwareSlug: string
hardwareName: string hardwareName: string
fsrVersion: string upscalerType: string
upscalerVersion: string | null
frameGenMethod: string frameGenMethod: string
verifiedAt: string | null verifiedAt: string | null
createdAt: string createdAt: string