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}
|
★ {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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
@@ -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()),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user