feat: replace fsrVersion with upscalerType/upscalerVersion, add battery & custom system columns
This commit is contained in:
@@ -393,16 +393,6 @@ export function GamePageClient({
|
||||
★ {game.metascore}
|
||||
</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>
|
||||
|
||||
{/* External links */}
|
||||
@@ -467,6 +457,20 @@ export function GamePageClient({
|
||||
)}
|
||||
</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>
|
||||
</motion.div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -61,7 +61,7 @@ export default async function SubmitBenchmarkPage({
|
||||
}
|
||||
|
||||
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">
|
||||
<h1 className="text-2xl font-bold mb-2">Submit Benchmark</h1>
|
||||
<p className="text-sm text-text/60">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
+172
-70
@@ -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
|
||||
</h1>
|
||||
<p className="text-sm text-text/50 mt-1">
|
||||
{phase === "email"
|
||||
? "Sign in to DeckyVault"
|
||||
: `Signing in as `}
|
||||
{phase === "password" && (
|
||||
{showPassword
|
||||
? "Signing in as "
|
||||
: "Sign in to DeckyVault"}
|
||||
{showPassword && (
|
||||
<>
|
||||
<strong className="text-text">{email}</strong>
|
||||
{" "}
|
||||
@@ -145,48 +235,43 @@ export default function LoginForm() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "email" ? (
|
||||
<form onSubmit={handleEmailSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-text/60 block mb-1.5">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
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">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<form onSubmit={showPassword ? handleLogin : handleEmailSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-text/60 block mb-1.5">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={email}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
{/* Always render the password input for WebAuthn conditional UI,
|
||||
but visually hide it until the email is verified */}
|
||||
<div className={showPassword ? "" : "h-0 overflow-hidden opacity-0 pointer-events-none"}>
|
||||
<label className="text-sm text-text/60 block mb-1.5">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={password}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
{showPassword && (
|
||||
<div className="text-right">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
@@ -195,23 +280,40 @@ export default function LoginForm() {
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
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" />
|
||||
)}
|
||||
Sign in
|
||||
</button>
|
||||
{/* Passkey hint */}
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
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" />
|
||||
)}
|
||||
{showPassword ? "Sign in" : "Continue"}
|
||||
</button>
|
||||
{showPassword && (
|
||||
<div className="text-center p-3 rounded-lg bg-primary/5 border border-primary/10">
|
||||
<p className="text-xs text-text/50">
|
||||
Your browser may offer to sign in with a passkey
|
||||
</p>
|
||||
</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">
|
||||
|
||||
+46
-11
@@ -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 && (
|
||||
<motion.span className='hidden md:inline-block'>
|
||||
@@ -243,7 +256,9 @@ export default function Navbar() {
|
||||
className='relative'
|
||||
>
|
||||
<button
|
||||
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||||
onClick={() =>
|
||||
setUserMenuOpen(!userMenuOpen)
|
||||
}
|
||||
className='text-sm font-medium hover:text-primary transition-colors uppercase cursor-pointer'
|
||||
>
|
||||
Profile
|
||||
@@ -252,18 +267,30 @@ export default function Navbar() {
|
||||
<>
|
||||
<div
|
||||
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'>
|
||||
{authRoutes.map((route) => (
|
||||
<Link
|
||||
key={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'
|
||||
>
|
||||
{route.icon === "User" && <User className='h-4 w-4' />}
|
||||
{route.icon === "Bookmark" && <Bookmark className='h-4 w-4' />}
|
||||
{route.icon ===
|
||||
"User" && (
|
||||
<User className='h-4 w-4' />
|
||||
)}
|
||||
{route.icon ===
|
||||
"Bookmark" && (
|
||||
<Bookmark className='h-4 w-4' />
|
||||
)}
|
||||
{route.title}
|
||||
</Link>
|
||||
))}
|
||||
@@ -370,11 +397,17 @@ export default function Navbar() {
|
||||
<Link
|
||||
key={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'
|
||||
>
|
||||
{route.icon === "User" && <User className='h-4 w-4' />}
|
||||
{route.icon === "Bookmark" && <Bookmark className='h-4 w-4' />}
|
||||
{route.icon === "User" && (
|
||||
<User className='h-4 w-4' />
|
||||
)}
|
||||
{route.icon === "Bookmark" && (
|
||||
<Bookmark className='h-4 w-4' />
|
||||
)}
|
||||
{route.title}
|
||||
</Link>
|
||||
))}
|
||||
@@ -393,7 +426,9 @@ export default function Navbar() {
|
||||
<div className='space-y-2'>
|
||||
<Link
|
||||
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'
|
||||
>
|
||||
Login
|
||||
|
||||
@@ -1,114 +1,142 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { User, Shield, Link as LinkIcon } from "lucide-react"
|
||||
import { SettingsProfileTab } from "@/components/profile/settings-profile-tab"
|
||||
import { SettingsSecurityTab } from "@/components/profile/settings-security-tab"
|
||||
import { SettingsAccountsTab } from "@/components/profile/settings-accounts-tab"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type SettingsSubTab = "profile" | "security" | "accounts"
|
||||
|
||||
const subTabs: { id: SettingsSubTab; label: string; icon: typeof User }[] = [
|
||||
{ id: "profile", label: "Profile", icon: User },
|
||||
{ id: "security", label: "Security", icon: Shield },
|
||||
{ id: "accounts", label: "Linked Accounts", icon: LinkIcon },
|
||||
{ id: "profile", label: "Profile", icon: User },
|
||||
{ id: "security", label: "Security", icon: Shield },
|
||||
{ id: "accounts", label: "Linked Accounts", icon: LinkIcon },
|
||||
]
|
||||
|
||||
interface SettingsContainerProps {
|
||||
name: string
|
||||
email: string
|
||||
role: string | null
|
||||
createdAt: string
|
||||
name: string
|
||||
email: string
|
||||
role: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export function SettingsContainer({ name, email, role, createdAt }: SettingsContainerProps) {
|
||||
const [activeSubTab, setActiveSubTab] = useState<SettingsSubTab>("profile")
|
||||
const [authMethods, setAuthMethods] = useState<AuthMethods | null>(null)
|
||||
const [isLoadingAuthMethods, setIsLoadingAuthMethods] = useState(true)
|
||||
export function SettingsContainer({
|
||||
name,
|
||||
email,
|
||||
role,
|
||||
createdAt,
|
||||
}: SettingsContainerProps) {
|
||||
const [activeSubTab, setActiveSubTab] = useState<SettingsSubTab>("profile")
|
||||
const [authMethods, setAuthMethods] = useState<AuthMethods | null>(null)
|
||||
const [isLoadingAuthMethods, setIsLoadingAuthMethods] = useState(true)
|
||||
|
||||
const fetchRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (fetchRef.current) return
|
||||
fetchRef.current = true
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000)
|
||||
|
||||
let cancelled = false
|
||||
fetch("/api/user/me/auth-methods", { credentials: "include" })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
if (data) setAuthMethods(data)
|
||||
setIsLoadingAuthMethods(false)
|
||||
})
|
||||
.catch(() => {
|
||||
setIsLoadingAuthMethods(false)
|
||||
})
|
||||
fetch("/api/user/me/auth-methods", {
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
})
|
||||
.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)
|
||||
setIsLoadingAuthMethods(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name !== "AbortError") {
|
||||
console.error("[auth-methods] fetch failed:", err)
|
||||
}
|
||||
setIsLoadingAuthMethods(false)
|
||||
})
|
||||
.finally(() => clearTimeout(timeoutId))
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
return () => {
|
||||
controller.abort()
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshAuthMethods = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/user/me/auth-methods", { credentials: "include" })
|
||||
if (res.ok) setAuthMethods(await res.json())
|
||||
} catch {
|
||||
// silently fail
|
||||
const refreshAuthMethods = async () => {
|
||||
setIsLoadingAuthMethods(true)
|
||||
try {
|
||||
const res = await fetch("/api/user/me/auth-methods", {
|
||||
credentials: "include",
|
||||
})
|
||||
if (res.ok) {
|
||||
setAuthMethods(await res.json())
|
||||
} else {
|
||||
setAuthMethods(null)
|
||||
}
|
||||
} catch {
|
||||
setAuthMethods(null)
|
||||
} finally {
|
||||
setIsLoadingAuthMethods(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{/* Sidebar Navigation */}
|
||||
<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">
|
||||
{subTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveSubTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors whitespace-nowrap rounded-lg md:rounded-none md:border-l-2 md:border-r-0 md:border-transparent cursor-pointer ${
|
||||
activeSubTab === tab.id
|
||||
? "bg-primary/10 text-primary md:border-l-primary md:bg-primary/10"
|
||||
: "text-text/50 hover:text-text/70 hover:bg-text/5"
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="h-4 w-4 shrink-0" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
return (
|
||||
<div className='flex flex-col md:flex-row gap-6'>
|
||||
{/* Sidebar Navigation */}
|
||||
<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'>
|
||||
{subTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveSubTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors whitespace-nowrap rounded-lg md:rounded-none md:border-l-2 md:border-r-0 md:border-transparent cursor-pointer ${
|
||||
activeSubTab === tab.id
|
||||
? "bg-primary/10 text-primary md:border-l-primary md:bg-primary/10"
|
||||
: "text-text/50 hover:text-text/70 hover:bg-text/5"
|
||||
}`}
|
||||
>
|
||||
<tab.icon className='h-4 w-4 shrink-0' />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className='flex-1 min-w-0'>
|
||||
{activeSubTab === "profile" && (
|
||||
<SettingsProfileTab
|
||||
name={name}
|
||||
email={email}
|
||||
role={role}
|
||||
createdAt={createdAt}
|
||||
/>
|
||||
)}
|
||||
{activeSubTab === "security" && (
|
||||
<SettingsSecurityTab
|
||||
authMethods={authMethods}
|
||||
isLoadingAuthMethods={isLoadingAuthMethods}
|
||||
onRefreshAuthMethods={refreshAuthMethods}
|
||||
/>
|
||||
)}
|
||||
{activeSubTab === "accounts" && (
|
||||
<SettingsAccountsTab
|
||||
authMethods={authMethods}
|
||||
isLoadingAuthMethods={isLoadingAuthMethods}
|
||||
onRefreshAuthMethods={refreshAuthMethods}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{activeSubTab === "profile" && (
|
||||
<SettingsProfileTab
|
||||
name={name}
|
||||
email={email}
|
||||
role={role}
|
||||
createdAt={createdAt}
|
||||
/>
|
||||
)}
|
||||
{activeSubTab === "security" && (
|
||||
<SettingsSecurityTab
|
||||
authMethods={authMethods}
|
||||
isLoadingAuthMethods={isLoadingAuthMethods}
|
||||
onRefreshAuthMethods={refreshAuthMethods}
|
||||
/>
|
||||
)}
|
||||
{activeSubTab === "accounts" && (
|
||||
<SettingsAccountsTab
|
||||
authMethods={authMethods}
|
||||
isLoadingAuthMethods={isLoadingAuthMethods}
|
||||
onRefreshAuthMethods={refreshAuthMethods}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
+13
-6
@@ -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,
|
||||
|
||||
+11
-10
@@ -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<string, { count: number; avgFps: number }>()
|
||||
// ── Upscaler breakdown ───────────────────────────────────
|
||||
const upscalerMap = new Map<string, { count: number; avgFps: number }>()
|
||||
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,
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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()])),
|
||||
|
||||
+13
-7
@@ -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()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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<string, boolean>()
|
||||
const poorPerformerMap = new Map<string, boolean>()
|
||||
const bestFpsMap = new Map<string, number>()
|
||||
|
||||
if (localGameIds.length > 0) {
|
||||
@@ -157,9 +158,10 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
|
||||
bestFps: sql<number>`MAX(${performanceEntries.fpsAvg})::real`,
|
||||
isRawPerformer: sql<boolean>`BOOL_OR(
|
||||
${performanceEntries.fpsAvg} >= 60
|
||||
AND ${performanceEntries.fsrVersion} = 'none'
|
||||
AND ${performanceEntries.upscalerType} = 'none'
|
||||
AND ${performanceEntries.frameGenMethod} = 'none'
|
||||
)`,
|
||||
isPoorPerformance: sql<boolean>`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,
|
||||
})
|
||||
|
||||
+8
-6
@@ -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,
|
||||
|
||||
@@ -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<GameSettingCategory[]>(),
|
||||
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),
|
||||
],
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user