merge: convert to bun workspaces monorepo
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { Shield, ShieldCheck, ShieldX, ShieldQuestion } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AntiCheatBadgeProps {
|
||||
antiCheatRelevant: boolean;
|
||||
antiCheatStatus: "none" | "supported" | "unsupported" | "unknown" | null;
|
||||
antiCheatName?: string | null;
|
||||
compact?: boolean; // for list views
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
supported: {
|
||||
icon: ShieldCheck,
|
||||
label: "Anti-Cheat: Supported",
|
||||
color: "bg-green-500/15 text-green-400 border-green-500/30",
|
||||
},
|
||||
unsupported: {
|
||||
icon: ShieldX,
|
||||
label: "Anti-Cheat: Unsupported",
|
||||
color: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
},
|
||||
unknown: {
|
||||
icon: ShieldQuestion,
|
||||
label: "Anti-Cheat: Unknown",
|
||||
color: "bg-yellow-500/15 text-yellow-400 border-yellow-500/30",
|
||||
},
|
||||
none: {
|
||||
icon: Shield,
|
||||
label: "No Anti-Cheat",
|
||||
color: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function AntiCheatBadge({
|
||||
antiCheatRelevant,
|
||||
antiCheatStatus,
|
||||
antiCheatName,
|
||||
compact = false,
|
||||
className,
|
||||
}: AntiCheatBadgeProps) {
|
||||
if (!antiCheatRelevant || !antiCheatStatus || antiCheatStatus === "none") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = statusConfig[antiCheatStatus];
|
||||
const Icon = config.icon;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||
config.color,
|
||||
className
|
||||
)}
|
||||
title={antiCheatName ? `${config.label} (${antiCheatName})` : config.label}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{antiCheatStatus === "unsupported" && "AC"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm font-medium",
|
||||
config.color,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{config.label}</span>
|
||||
{antiCheatName && (
|
||||
<span className="text-xs opacity-75">({antiCheatName})</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
describe("auth form email placeholder text", () => {
|
||||
it("all auth forms use you@deckyvault.xyz not you@example.com", () => {
|
||||
const files = [
|
||||
"components/auth/signup-form-step.tsx",
|
||||
"components/auth/login-form.tsx",
|
||||
"components/auth/forgot-password-form.tsx",
|
||||
]
|
||||
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(process.cwd(), file)
|
||||
const content = fs.readFileSync(fullPath, "utf8")
|
||||
expect(content).not.toContain('"you@example.com"')
|
||||
expect(content).toContain('"you@deckyvault.xyz"')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import { KeyRound, Loader2, ArrowLeft } from "lucide-react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import {
|
||||
forgotPasswordSchema,
|
||||
} from "@/lib/auth/validation"
|
||||
import TurnstileWidget, { type TurnstileWidgetHandle } from "./turnstile-widget"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
export default function ForgotPasswordForm() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [turnstileToken, setTurnstileToken] = useState("")
|
||||
const turnstileRef = useRef<TurnstileWidgetHandle>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
|
||||
const result = forgotPasswordSchema.safeParse({ email })
|
||||
if (!result.success) {
|
||||
setError(result.error.issues[0].message)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
const { error } = await authClient.emailOtp.requestPasswordReset({
|
||||
email,
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
"x-captcha-response": turnstileToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
setIsLoading(false)
|
||||
|
||||
if (error) {
|
||||
setError(
|
||||
error.message || "Something went wrong. Please try again.",
|
||||
)
|
||||
turnstileRef.current?.reset()
|
||||
setTurnstileToken("")
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to reset password page with email
|
||||
router.push(`/reset-password?email=${encodeURIComponent(email)}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="text-center mb-2">
|
||||
<KeyRound className="h-10 w-10 text-primary mx-auto mb-3" />
|
||||
<h1 className="text-xl font-bold text-text">
|
||||
Forgot your password?
|
||||
</h1>
|
||||
<p className="text-sm text-text/50 mt-2 leading-relaxed">
|
||||
Enter your email and we'll send you a verification code to
|
||||
reset your password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-400 text-sm text-center bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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@deckyvault.xyz"
|
||||
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 || !turnstileToken}
|
||||
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" />}
|
||||
Send verification code
|
||||
</button>
|
||||
|
||||
<TurnstileWidget
|
||||
ref={turnstileRef}
|
||||
onToken={setTurnstileToken}
|
||||
onExpire={() => setTurnstileToken("")}
|
||||
/>
|
||||
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex items-center justify-center gap-2 text-sm text-text/50 hover:text-text transition-colors cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to sign in
|
||||
</Link>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { Loader2, Key } from "lucide-react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import {
|
||||
loginEmailSchema,
|
||||
loginSchema,
|
||||
} from "@/lib/auth/validation"
|
||||
import SocialButtons from "./social-buttons"
|
||||
import TurnstileWidget, { type TurnstileWidgetHandle } from "./turnstile-widget"
|
||||
import Link from "next/link"
|
||||
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 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)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [emailChecked, setEmailChecked] = useState(false)
|
||||
const [turnstileToken, setTurnstileToken] = useState("")
|
||||
const mountedRef = useRef(true)
|
||||
const passkeyInitiatedRef = useRef(false)
|
||||
const turnstileRef = useRef<TurnstileWidgetHandle>(null)
|
||||
|
||||
// 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(() => {
|
||||
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
|
||||
})
|
||||
}
|
||||
return () => { mountedRef.current = false }
|
||||
}, [handleLoginSuccess])
|
||||
|
||||
const handleEmailSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
|
||||
const result = loginEmailSchema.safeParse({ email })
|
||||
if (!result.success) {
|
||||
setError(result.error.issues[0].message)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch("/api/auth/check-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
setError(data.error || "Something went wrong. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
if (!data.exists) {
|
||||
setError("No account found with this email.")
|
||||
return
|
||||
}
|
||||
|
||||
// Email exists, show password field
|
||||
setEmailChecked(true)
|
||||
setShowPassword(true)
|
||||
} catch {
|
||||
setError("Something went wrong. Please try again.")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
|
||||
const result = loginSchema.safeParse({ email, password })
|
||||
if (!result.success) {
|
||||
setError(result.error.issues[0].message)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
const { error } = await authClient.signIn.email({
|
||||
email,
|
||||
password,
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
"x-captcha-response": turnstileToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
setIsLoading(false)
|
||||
|
||||
if (error) {
|
||||
setError(error.message || "Invalid credentials. Please try again.")
|
||||
turnstileRef.current?.reset()
|
||||
setTurnstileToken("")
|
||||
return
|
||||
}
|
||||
|
||||
router.push(redirectTo)
|
||||
}
|
||||
|
||||
const handleChangeEmail = () => {
|
||||
setShowPassword(false)
|
||||
setPassword("")
|
||||
setError("")
|
||||
setEmailChecked(false)
|
||||
turnstileRef.current?.reset()
|
||||
setTurnstileToken("")
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-5">
|
||||
<div className="text-center mb-2">
|
||||
<h1 className="text-xl font-bold text-text">
|
||||
Welcome back
|
||||
</h1>
|
||||
<p className="text-sm text-text/50 mt-1">
|
||||
{showPassword
|
||||
? "Signing in as "
|
||||
: "Sign in to DeckyVault"}
|
||||
{showPassword && (
|
||||
<>
|
||||
<strong className="text-text">{email}</strong>
|
||||
{" "}
|
||||
<button
|
||||
onClick={handleChangeEmail}
|
||||
className="text-primary hover:underline text-xs"
|
||||
>
|
||||
change
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SocialButtons />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
<span className="text-xs text-text/40 uppercase">
|
||||
or continue with email
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-400 text-sm text-center bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">
|
||||
{error}
|
||||
{error.includes("No account found") && (
|
||||
<>
|
||||
{" "}
|
||||
<Link
|
||||
href="/signup"
|
||||
className="text-primary font-semibold hover:underline"
|
||||
>
|
||||
Create one →
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</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@deckyvault.xyz"
|
||||
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"
|
||||
className="text-sm text-text/50 hover:text-text transition-colors cursor-pointer"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{showPassword && (
|
||||
<TurnstileWidget
|
||||
ref={turnstileRef}
|
||||
onToken={setTurnstileToken}
|
||||
onExpire={() => {
|
||||
setTurnstileToken("")
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{/* Explicit passkey login button */}
|
||||
{"PublicKeyCredential" in globalThis && !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">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
href="/signup"
|
||||
className="text-primary hover:underline cursor-pointer"
|
||||
>
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client"
|
||||
|
||||
import { useRef, useCallback, useMemo } from "react"
|
||||
|
||||
interface OtpInputProps {
|
||||
length?: number
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export default function OtpInput({
|
||||
length = 6,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
error,
|
||||
}: OtpInputProps) {
|
||||
const digits = useMemo(
|
||||
() =>
|
||||
value
|
||||
.split("")
|
||||
.concat(Array(length).fill(""))
|
||||
.slice(0, length),
|
||||
[value, length],
|
||||
)
|
||||
const refs = useRef<(HTMLInputElement | null)[]>([])
|
||||
|
||||
const updateDigits = useCallback(
|
||||
(newDigits: string[]) => {
|
||||
onChange(newDigits.join(""))
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
|
||||
const handleChange = useCallback(
|
||||
(index: number, val: string) => {
|
||||
// Only allow single digit
|
||||
const digit = val.replace(/\D/g, "").slice(-1)
|
||||
const newDigits = [...digits]
|
||||
newDigits[index] = digit
|
||||
updateDigits(newDigits)
|
||||
|
||||
// Auto-advance to next box
|
||||
if (digit && index < length - 1) {
|
||||
refs.current[index + 1]?.focus()
|
||||
}
|
||||
},
|
||||
[digits, length, updateDigits],
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Backspace") {
|
||||
if (!digits[index] && index > 0) {
|
||||
// Move to previous box if current is empty
|
||||
refs.current[index - 1]?.focus()
|
||||
const newDigits = [...digits]
|
||||
newDigits[index - 1] = ""
|
||||
updateDigits(newDigits)
|
||||
}
|
||||
} else if (e.key === "ArrowLeft" && index > 0) {
|
||||
refs.current[index - 1]?.focus()
|
||||
} else if (e.key === "ArrowRight" && index < length - 1) {
|
||||
refs.current[index + 1]?.focus()
|
||||
}
|
||||
},
|
||||
[digits, length, updateDigits],
|
||||
)
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
e.preventDefault()
|
||||
const pasted = e.clipboardData
|
||||
.getData("text")
|
||||
.replace(/\D/g, "")
|
||||
.slice(0, length)
|
||||
const newDigits = Array(length).fill("")
|
||||
for (let i = 0; i < pasted.length; i++) {
|
||||
newDigits[i] = pasted[i]
|
||||
}
|
||||
updateDigits(newDigits)
|
||||
|
||||
// Focus last filled box or next empty
|
||||
const focusIndex = Math.min(pasted.length, length - 1)
|
||||
refs.current[focusIndex]?.focus()
|
||||
},
|
||||
[length, updateDigits],
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-2 justify-center">
|
||||
{Array.from({ length }).map((_, i) => (
|
||||
<input
|
||||
key={i}
|
||||
ref={(el) => {
|
||||
refs.current[i] = el
|
||||
}}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
value={digits[i]}
|
||||
onChange={(e) => handleChange(i, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(i, e)}
|
||||
onPaste={handlePaste}
|
||||
disabled={disabled}
|
||||
aria-label={`Digit ${i + 1} of ${length}`}
|
||||
className={`w-12 h-14 text-center text-xl font-bold rounded-lg border bg-text/5 text-text outline-none transition-colors ${
|
||||
error
|
||||
? "border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-500/50"
|
||||
: "border-border focus:border-primary focus:ring-2 focus:ring-primary/50"
|
||||
} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm mt-2 text-center">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { ArrowLeft, Loader2, Mail } from "lucide-react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import OtpInput from "./otp-input"
|
||||
|
||||
interface OtpVerificationStepProps {
|
||||
email: string
|
||||
onSuccess: () => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export default function OtpVerificationStep({
|
||||
email,
|
||||
onSuccess,
|
||||
onBack,
|
||||
}: OtpVerificationStepProps) {
|
||||
const [otp, setOtp] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [resendTimer, setResendTimer] = useState(300) // 5 minutes
|
||||
const canResend = resendTimer <= 0
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
if (resendTimer <= 0) return
|
||||
const interval = setInterval(() => {
|
||||
setResendTimer((prev) => prev - 1)
|
||||
}, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [resendTimer])
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
const handleVerify = useCallback(
|
||||
async (otpValue: string) => {
|
||||
if (otpValue.length !== 6) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError("")
|
||||
|
||||
const { error } = await authClient.emailOtp.verifyEmail({
|
||||
email,
|
||||
otp: otpValue,
|
||||
})
|
||||
|
||||
setIsLoading(false)
|
||||
|
||||
if (error) {
|
||||
setError(
|
||||
error.code === "TOO_MANY_ATTEMPTS"
|
||||
? "Too many attempts. Please request a new code."
|
||||
: "Invalid code. Please try again.",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
onSuccess()
|
||||
},
|
||||
[email, onSuccess],
|
||||
)
|
||||
|
||||
const handleResend = async () => {
|
||||
setError("")
|
||||
await authClient.emailOtp.sendVerificationOtp({
|
||||
email,
|
||||
type: "email-verification",
|
||||
})
|
||||
setResendTimer(300)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="text-center mb-2">
|
||||
<Mail className="h-10 w-10 text-primary mx-auto mb-3" />
|
||||
<h1 className="text-xl font-bold text-text">
|
||||
Verify your email
|
||||
</h1>
|
||||
<p className="text-sm text-text/50 mt-2">
|
||||
We sent a 6-digit code to{" "}
|
||||
<strong className="text-text">{email}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-text/60 block mb-2 text-center">
|
||||
Verification code
|
||||
</label>
|
||||
<OtpInput
|
||||
value={otp}
|
||||
onChange={(value) => {
|
||||
setOtp(value)
|
||||
if (value.length === 6) {
|
||||
handleVerify(value)
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
error={error}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
{canResend ? (
|
||||
<button
|
||||
onClick={handleResend}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Resend code
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-text/40">
|
||||
Resend code in{" "}
|
||||
<span className="text-primary font-semibold">
|
||||
{formatTime(resendTimer)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleVerify(otp)}
|
||||
disabled={isLoading || otp.length !== 6}
|
||||
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" />}
|
||||
Verify
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center justify-center gap-2 w-full text-sm text-text/50 hover:text-text transition-colors cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to signup
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { KeyRound, Check, Loader2, Fingerprint } from "lucide-react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
interface PasskeySetupStepProps {
|
||||
onSuccess: () => void
|
||||
onSkip: () => void
|
||||
}
|
||||
|
||||
export default function PasskeySetupStep({
|
||||
onSuccess,
|
||||
onSkip,
|
||||
}: PasskeySetupStepProps) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const isPasskeySupported =
|
||||
typeof window !== "undefined" &&
|
||||
"PublicKeyCredential" in window
|
||||
|
||||
const handleAddPasskey = async () => {
|
||||
setIsLoading(true)
|
||||
setError("")
|
||||
|
||||
const { error } = await authClient.passkey.addPasskey({
|
||||
name: "Primary passkey",
|
||||
})
|
||||
|
||||
setIsLoading(false)
|
||||
|
||||
if (error) {
|
||||
setError(
|
||||
error.message ||
|
||||
"Failed to set up passkey. You can try again later.",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
onSuccess()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="text-center mb-2">
|
||||
<KeyRound className="h-10 w-10 text-primary mx-auto mb-3" />
|
||||
<h1 className="text-xl font-bold text-text">
|
||||
Set up a passkey
|
||||
</h1>
|
||||
<p className="text-sm text-text/50 mt-2 leading-relaxed">
|
||||
Sign in faster with biometrics or your device's security
|
||||
key. No password needed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Benefits list */}
|
||||
<div className="bg-text/5 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-3 text-sm text-text/60">
|
||||
<Check className="h-4 w-4 text-green-400 shrink-0" />
|
||||
Faster sign-in with fingerprint or face
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-text/60">
|
||||
<Check className="h-4 w-4 text-green-400 shrink-0" />
|
||||
More secure than passwords
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-text/60">
|
||||
<Check className="h-4 w-4 text-green-400 shrink-0" />
|
||||
Works across your devices
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-400 text-sm text-center bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPasskeySupported ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleAddPasskey}
|
||||
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" />
|
||||
) : (
|
||||
<Fingerprint className="h-4 w-4" />
|
||||
)}
|
||||
Set up passkey
|
||||
</button>
|
||||
<button
|
||||
onClick={onSkip}
|
||||
className="w-full py-3 rounded-lg border border-border text-text/60 text-sm hover:bg-text/5 transition-colors cursor-pointer"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-text/40 mb-4">
|
||||
Passkeys are not supported on this device.
|
||||
</p>
|
||||
<button
|
||||
onClick={onSkip}
|
||||
className="w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
|
||||
import { ShieldCheck, ShieldAlert, ShieldQuestion } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import {
|
||||
checkPasswordStrength,
|
||||
type StrengthLevel,
|
||||
} from "@/lib/auth/password-strength"
|
||||
|
||||
interface PasswordStrengthProps {
|
||||
password: string
|
||||
}
|
||||
|
||||
const levelColors: Record<StrengthLevel, string> = {
|
||||
weak: "#ef4444",
|
||||
fair: "#f59e0b",
|
||||
good: "#eab308",
|
||||
strong: "#22c55e",
|
||||
excellent: "#10b981",
|
||||
}
|
||||
|
||||
const levelLabels: Record<StrengthLevel, string> = {
|
||||
weak: "Weak",
|
||||
fair: "Fair",
|
||||
good: "Good",
|
||||
strong: "Strong",
|
||||
excellent: "Excellent",
|
||||
}
|
||||
|
||||
const levelBars: Record<StrengthLevel, number> = {
|
||||
weak: 1,
|
||||
fair: 2,
|
||||
good: 3,
|
||||
strong: 4,
|
||||
excellent: 5,
|
||||
}
|
||||
|
||||
export default function PasswordStrengthMeter({
|
||||
password,
|
||||
}: PasswordStrengthProps) {
|
||||
if (!password) return null
|
||||
|
||||
const { level, feedback } = checkPasswordStrength(password)
|
||||
const color = levelColors[level]
|
||||
const bars = levelBars[level]
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
{/* Bar indicator */}
|
||||
<div className="flex gap-1 mb-1.5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="h-[3px] flex-1 rounded-full"
|
||||
initial={{ backgroundColor: "rgba(255,255,255,0.1)" }}
|
||||
animate={{
|
||||
backgroundColor:
|
||||
i < bars ? color : "rgba(255,255,255,0.1)",
|
||||
}}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Label and feedback */}
|
||||
<div className="flex items-start gap-1.5">
|
||||
{level === "excellent" || level === "strong" ? (
|
||||
<ShieldCheck
|
||||
className="h-3.5 w-3.5 mt-0.5 shrink-0"
|
||||
style={{ color }}
|
||||
/>
|
||||
) : level === "weak" ? (
|
||||
<ShieldAlert
|
||||
className="h-3.5 w-3.5 mt-0.5 shrink-0"
|
||||
style={{ color }}
|
||||
/>
|
||||
) : (
|
||||
<ShieldQuestion
|
||||
className="h-3.5 w-3.5 mt-0.5 shrink-0"
|
||||
style={{ color }}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-xs font-medium" style={{ color }}>
|
||||
{levelLabels[level]}
|
||||
</span>
|
||||
{feedback.length > 0 &&
|
||||
feedback[0] !== "Great password!" && (
|
||||
<span className="text-xs text-[#ebe4f1]/50 ml-1">
|
||||
— {feedback[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import {
|
||||
Mail,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
} from "lucide-react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import {
|
||||
resetPasswordSchema,
|
||||
} from "@/lib/auth/validation"
|
||||
import OtpInput from "./otp-input"
|
||||
import PasswordStrengthMeter from "./password-strength"
|
||||
import Link from "next/link"
|
||||
|
||||
interface ResetPasswordFormProps {
|
||||
email: string
|
||||
}
|
||||
|
||||
export default function ResetPasswordForm({
|
||||
email,
|
||||
}: ResetPasswordFormProps) {
|
||||
const [otp, setOtp] = useState("")
|
||||
const [newPassword, setNewPassword] = useState("")
|
||||
const [confirmPassword, setConfirmPassword] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
const [resendTimer, setResendTimer] = useState(300)
|
||||
const canResend = resendTimer <= 0
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
if (resendTimer <= 0) return
|
||||
const interval = setInterval(() => {
|
||||
setResendTimer((prev) => prev - 1)
|
||||
}, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [resendTimer])
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
|
||||
const result = resetPasswordSchema.safeParse({
|
||||
otp,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
})
|
||||
if (!result.success) {
|
||||
setError(result.error.issues[0].message)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
const { error } = await authClient.emailOtp.resetPassword({
|
||||
email,
|
||||
otp,
|
||||
password: newPassword,
|
||||
})
|
||||
setIsLoading(false)
|
||||
|
||||
if (error) {
|
||||
setError(
|
||||
error.code === "TOO_MANY_ATTEMPTS"
|
||||
? "Too many attempts. Please request a new code."
|
||||
: error.message || "Failed to reset password.",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSuccess(true)
|
||||
}
|
||||
|
||||
const handleResend = async () => {
|
||||
setError("")
|
||||
await authClient.emailOtp.requestPasswordReset({ email })
|
||||
setResendTimer(300)
|
||||
}
|
||||
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<div className="text-center space-y-5">
|
||||
<CheckCircle2 className="h-10 w-10 text-green-400 mx-auto" />
|
||||
<h1 className="text-xl font-bold text-text">
|
||||
Password reset successful
|
||||
</h1>
|
||||
<p className="text-sm text-text/50">
|
||||
Your password has been updated. You can now sign in with
|
||||
your new password.
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-block w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors text-center cursor-pointer"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="text-center mb-2">
|
||||
<Mail className="h-10 w-10 text-primary mx-auto mb-3" />
|
||||
<h1 className="text-xl font-bold text-text">
|
||||
Check your email
|
||||
</h1>
|
||||
<p className="text-sm text-text/50 mt-2">
|
||||
We sent a 6-digit code to{" "}
|
||||
<strong className="text-text">{email}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-text/60 block mb-2 text-center">
|
||||
Verification code
|
||||
</label>
|
||||
<OtpInput
|
||||
value={otp}
|
||||
onChange={setOtp}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
{canResend ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Resend code
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-text/40">
|
||||
Resend code in{" "}
|
||||
<span className="text-primary font-semibold">
|
||||
{formatTime(resendTimer)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
<span className="text-xs text-text/40">
|
||||
then set new password
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-400 text-sm text-center bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-text/60 block mb-1.5">
|
||||
New password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Min. 10 characters"
|
||||
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"
|
||||
/>
|
||||
<PasswordStrengthMeter password={newPassword} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-text/60 block mb-1.5">
|
||||
Confirm new password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Re-enter password"
|
||||
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"
|
||||
/>
|
||||
{confirmPassword &&
|
||||
newPassword !== confirmPassword && (
|
||||
<p className="text-red-400 text-sm mt-1">
|
||||
Passwords do not match
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || otp.length !== 6}
|
||||
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" />}
|
||||
Reset password
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex items-center justify-center gap-2 text-sm text-text/50 hover:text-text transition-colors cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to sign in
|
||||
</Link>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { signupSchema } from "@/lib/auth/validation"
|
||||
import SocialButtons from "./social-buttons"
|
||||
import PasswordStrengthMeter from "./password-strength"
|
||||
import TurnstileWidget, { type TurnstileWidgetHandle } from "./turnstile-widget"
|
||||
import Link from "next/link"
|
||||
|
||||
interface SignupFormStepProps {
|
||||
onSuccess: (email: string) => void
|
||||
}
|
||||
|
||||
export default function SignupFormStep({ onSuccess }: SignupFormStepProps) {
|
||||
const [name, setName] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [serverError, setServerError] = useState("")
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [turnstileToken, setTurnstileToken] = useState("")
|
||||
const turnstileRef = useRef<TurnstileWidgetHandle>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setErrors({})
|
||||
setServerError("")
|
||||
|
||||
const result = signupSchema.safeParse({ name, email, password })
|
||||
if (!result.success) {
|
||||
const fieldErrors: Record<string, string> = {}
|
||||
result.error.issues.forEach((issue) => {
|
||||
const field = issue.path[0] as string
|
||||
fieldErrors[field] = issue.message
|
||||
})
|
||||
setErrors(fieldErrors)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
const { error } = await authClient.signUp.email({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
"x-captcha-response": turnstileToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
setIsLoading(false)
|
||||
|
||||
if (error) {
|
||||
setServerError(
|
||||
error.message || "Something went wrong. Please try again.",
|
||||
)
|
||||
turnstileRef.current?.reset()
|
||||
setTurnstileToken("")
|
||||
return
|
||||
}
|
||||
|
||||
onSuccess(email)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="text-center mb-2">
|
||||
<h1 className="text-xl font-bold text-text">
|
||||
Create your account
|
||||
</h1>
|
||||
<p className="text-sm text-text/50 mt-1">
|
||||
Join DeckyVault and start exploring
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SocialButtons callbackURL="/signup?step=passkey" />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
<span className="text-xs text-text/40 uppercase">
|
||||
or use email
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
|
||||
{serverError && (
|
||||
<div className="text-red-400 text-sm text-center bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">
|
||||
{serverError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-text/60 block mb-1.5">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
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"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-red-400 text-sm mt-1">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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@deckyvault.xyz"
|
||||
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"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-red-400 text-sm mt-1">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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="Min. 10 characters"
|
||||
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"
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-red-400 text-sm mt-1">
|
||||
{errors.password}
|
||||
</p>
|
||||
)}
|
||||
<PasswordStrengthMeter password={password} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !turnstileToken}
|
||||
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" />}
|
||||
Create account
|
||||
</button>
|
||||
|
||||
<TurnstileWidget
|
||||
ref={turnstileRef}
|
||||
onToken={setTurnstileToken}
|
||||
onExpire={() => setTurnstileToken("")}
|
||||
/>
|
||||
|
||||
<p className="text-center text-sm text-text/50">
|
||||
Already have an account?{" "}
|
||||
<Link href="/login" className="text-primary hover:underline cursor-pointer">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import StepIndicator from "./step-indicator"
|
||||
import SignupFormStep from "./signup-form-step"
|
||||
import OtpVerificationStep from "./otp-verification-step"
|
||||
import PasskeySetupStep from "./passkey-setup-step"
|
||||
|
||||
export default function SignupWizard() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const stepParam = searchParams.get("step")
|
||||
|
||||
const [step, setStep] = useState<1 | 2 | 3>(() => {
|
||||
if (stepParam === "otp") return 2
|
||||
if (stepParam === "passkey") return 3
|
||||
return 1
|
||||
})
|
||||
const [email, setEmail] = useState("")
|
||||
|
||||
// Update URL when step changes
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams()
|
||||
if (step === 2) params.set("step", "otp")
|
||||
if (step === 3) params.set("step", "passkey")
|
||||
const query = params.toString()
|
||||
router.replace(`/signup${query ? `?${query}` : ""}`, {
|
||||
scroll: false,
|
||||
})
|
||||
}, [step, router])
|
||||
|
||||
const handleSignupSuccess = (userEmail: string) => {
|
||||
setEmail(userEmail)
|
||||
setStep(2)
|
||||
}
|
||||
|
||||
const handleOtpSuccess = () => {
|
||||
setStep(3)
|
||||
}
|
||||
|
||||
const handlePasskeyComplete = () => {
|
||||
router.push("/")
|
||||
}
|
||||
|
||||
const handlePasskeySkip = () => {
|
||||
router.push("/")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<StepIndicator currentStep={step} totalSteps={3} />
|
||||
|
||||
{step === 1 && (
|
||||
<SignupFormStep onSuccess={handleSignupSuccess} />
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<OtpVerificationStep
|
||||
email={email}
|
||||
onSuccess={handleOtpSuccess}
|
||||
onBack={() => setStep(1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<PasskeySetupStep
|
||||
onSuccess={handlePasskeyComplete}
|
||||
onSkip={handlePasskeySkip}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
interface SocialButtonsProps {
|
||||
callbackURL?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function SocialButtons({
|
||||
callbackURL = "/",
|
||||
disabled = false,
|
||||
}: SocialButtonsProps) {
|
||||
const [loadingProvider, setLoadingProvider] = useState<
|
||||
"google" | "discord" | null
|
||||
>(null)
|
||||
|
||||
const handleSocialLogin = async (provider: "google" | "discord") => {
|
||||
setLoadingProvider(provider)
|
||||
await authClient.signIn.social(
|
||||
{
|
||||
provider,
|
||||
callbackURL,
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
setLoadingProvider(null)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const isLoading = loadingProvider !== null
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSocialLogin("google")}
|
||||
disabled={disabled || isLoading}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm font-medium hover:bg-text/10 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loadingProvider === "google" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
Google
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSocialLogin("discord")}
|
||||
disabled={disabled || isLoading}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm font-medium hover:bg-text/10 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loadingProvider === "discord" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="#5865F2">
|
||||
<path d="M19.27 5.33C17.94 4.71 16.5 4.26 15 4a.09.09 0 00-.07.03c-.18.33-.39.76-.53 1.09a16.09 16.09 0 00-4.8 0c-.14-.34-.35-.76-.54-1.09-.01-.01-.04-.03-.07-.03-1.5.26-2.93.71-4.27 1.33-.01 0-.02.01-.03.02-2.72 4.07-3.47 8.03-3.1 11.95 0 .01.01.03.02.04 1.69 1.24 3.33 1.99 4.95 2.49.03.01.06 0 .07-.02.38-.52.72-1.07 1.01-1.65.02-.04 0-.08-.04-.09-.55-.2-1.08-.45-1.59-.73-.04-.02-.04-.08 0-.1.11-.08.22-.17.33-.26.02-.02.05-.02.07-.01 3.44 1.57 7.15 1.57 10.55 0 .02-.01.05-.01.07.01.11.09.22.17.33.26.04.02.04.08 0 .1-.51.28-1.04.53-1.59.73-.04.01-.05.06-.04.09.29.58.64 1.13 1.01 1.65.03.01.06.02.09.01 1.62-.5 3.27-1.25 4.96-2.49.01-.01.02-.03.02-.04.44-4.53-.73-8.46-3.1-11.95-.01-.01-.02-.02-.04-.02zM8.52 14.91c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12 0 1.17-.84 2.12-1.89 2.12zm6.97 0c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12 0 1.17-.83 2.12-1.89 2.12z" />
|
||||
</svg>
|
||||
)}
|
||||
Discord
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "motion/react"
|
||||
|
||||
interface StepIndicatorProps {
|
||||
currentStep: number
|
||||
totalSteps: number
|
||||
}
|
||||
|
||||
export default function StepIndicator({
|
||||
currentStep,
|
||||
totalSteps,
|
||||
}: StepIndicatorProps) {
|
||||
return (
|
||||
<div className="flex gap-2 justify-center">
|
||||
{Array.from({ length: totalSteps }).map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="h-1 w-12 rounded-full"
|
||||
initial={false}
|
||||
animate={{
|
||||
backgroundColor:
|
||||
i < currentStep - 1
|
||||
? "#22c55e" // completed - green
|
||||
: i === currentStep - 1
|
||||
? "#eb3779" // current - primary
|
||||
: "rgba(235,228,241,0.1)", // upcoming - border color
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useImperativeHandle, forwardRef, useId } from "react"
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (container: string | HTMLElement, options: TurnstileOptions) => string
|
||||
reset: (widgetId: string) => void
|
||||
remove: (widgetId: string) => void
|
||||
getResponse: (widgetId: string) => string | undefined
|
||||
}
|
||||
onloadTurnstileCallback?: () => void
|
||||
}
|
||||
}
|
||||
|
||||
interface TurnstileOptions {
|
||||
sitekey: string
|
||||
theme?: "light" | "dark" | "auto"
|
||||
callback?: (token: string) => void
|
||||
"expired-callback"?: () => void
|
||||
"error-callback"?: () => void
|
||||
}
|
||||
|
||||
export interface TurnstileWidgetHandle {
|
||||
reset: () => void
|
||||
getToken: () => string | undefined
|
||||
}
|
||||
|
||||
interface TurnstileWidgetProps {
|
||||
onToken: (token: string) => void
|
||||
onExpire?: () => void
|
||||
onError?: () => void
|
||||
theme?: "light" | "dark" | "auto"
|
||||
}
|
||||
|
||||
const SCRIPT_ID = "cf-turnstile-script"
|
||||
const SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadTurnstileCallback&render=explicit"
|
||||
|
||||
export default forwardRef<TurnstileWidgetHandle, TurnstileWidgetProps>(
|
||||
function TurnstileWidget({ onToken, onExpire, onError, theme = "auto" }, ref) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const widgetIdRef = useRef<string | null>(null)
|
||||
const scriptLoadedRef = useRef(false)
|
||||
const id = useId()
|
||||
|
||||
// Expose reset and getToken to parent
|
||||
useImperativeHandle(ref, () => ({
|
||||
reset: () => {
|
||||
if (widgetIdRef.current && window.turnstile) {
|
||||
window.turnstile.reset(widgetIdRef.current)
|
||||
}
|
||||
},
|
||||
getToken: () => {
|
||||
if (widgetIdRef.current && window.turnstile) {
|
||||
return window.turnstile.getResponse(widgetIdRef.current)
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const siteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
||||
if (!siteKey) {
|
||||
console.warn("[Turnstile] NEXT_PUBLIC_TURNSTILE_SITE_KEY is not set")
|
||||
return
|
||||
}
|
||||
|
||||
function renderWidget() {
|
||||
if (!window.turnstile || !container || !siteKey) return
|
||||
// Clear any previous content
|
||||
container.innerHTML = ""
|
||||
const widgetId = window.turnstile.render(container, {
|
||||
sitekey: siteKey,
|
||||
theme,
|
||||
callback: (token: string) => onToken(token),
|
||||
"expired-callback": () => {
|
||||
widgetIdRef.current = null
|
||||
onExpire?.()
|
||||
},
|
||||
"error-callback": () => {
|
||||
onError?.()
|
||||
},
|
||||
})
|
||||
widgetIdRef.current = widgetId
|
||||
scriptLoadedRef.current = true
|
||||
}
|
||||
|
||||
// If script is already loaded, render immediately
|
||||
if (window.turnstile) {
|
||||
renderWidget()
|
||||
return
|
||||
}
|
||||
|
||||
// Set up the onload callback before adding the script
|
||||
window.onloadTurnstileCallback = renderWidget
|
||||
|
||||
// Avoid injecting the script twice
|
||||
if (!document.getElementById(SCRIPT_ID)) {
|
||||
const script = document.createElement("script")
|
||||
script.id = SCRIPT_ID
|
||||
script.src = SRC
|
||||
script.async = true
|
||||
script.defer = true
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup widget on unmount
|
||||
if (widgetIdRef.current && window.turnstile) {
|
||||
window.turnstile.remove(widgetIdRef.current)
|
||||
widgetIdRef.current = null
|
||||
}
|
||||
}
|
||||
// Only run on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return <div ref={containerRef} id={`turnstile-container-${id}`} className="flex justify-center min-h-[65px]" />
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client"
|
||||
|
||||
import { EChartWrapper, getDeviceColor } from "./EChartWrapper"
|
||||
|
||||
interface BatteryLifePoint {
|
||||
id: string
|
||||
hardwareSlug: string
|
||||
tdpWatts: number
|
||||
estimatedBatteryHours: number
|
||||
wattHours: number | null
|
||||
tdpMax: number | null
|
||||
estimatedAtMaxTdpMin: number | null
|
||||
}
|
||||
|
||||
interface BatteryLifeChartProps {
|
||||
data: BatteryLifePoint[]
|
||||
deviceNames?: Record<string, string>
|
||||
}
|
||||
|
||||
export function BatteryLifeChart({ data, deviceNames }: BatteryLifeChartProps) {
|
||||
if (!data || data.length === 0) {
|
||||
return <div className="flex items-center justify-center h-48 text-sm text-text/40">No battery data available</div>
|
||||
}
|
||||
|
||||
// Group by device for separate series
|
||||
const deviceGroups = new Map<string, BatteryLifePoint[]>()
|
||||
for (const point of data) {
|
||||
const existing = deviceGroups.get(point.hardwareSlug) || []
|
||||
existing.push(point)
|
||||
deviceGroups.set(point.hardwareSlug, existing)
|
||||
}
|
||||
|
||||
// Build trend lines: for each device, compute wattHours / tdp = hours for a range of TDPs
|
||||
const series: Array<Record<string, unknown>> = []
|
||||
let seriesIdx = 0
|
||||
|
||||
for (const [slug, points] of deviceGroups.entries()) {
|
||||
const color = getDeviceColor(seriesIdx)
|
||||
const name = deviceNames?.[slug] || slug
|
||||
|
||||
// Scatter points: TDP vs battery hours
|
||||
series.push({
|
||||
name,
|
||||
type: "scatter" as const,
|
||||
data: points.map((p) => [p.tdpWatts, p.estimatedBatteryHours]),
|
||||
itemStyle: { color },
|
||||
symbolSize: 10,
|
||||
})
|
||||
|
||||
// Trend line: compute theoretical curve using average wattHours for this device
|
||||
const avgWh = points.reduce((sum, p) => sum + (p.wattHours ?? 0), 0) / points.length
|
||||
if (avgWh > 0) {
|
||||
const tdpRange = [2, 5, 8, 10, 12, 15, 18, 20, 25, 30].filter(
|
||||
(tdp) => tdp <= (points[0].tdpMax ?? 30),
|
||||
)
|
||||
series.push({
|
||||
name: `${name} (est.)`,
|
||||
type: "line" as const,
|
||||
data: tdpRange.map((tdp) => [tdp, Math.round((avgWh / tdp) * 10) / 10]),
|
||||
lineStyle: { color, type: "dashed" as const, width: 1 },
|
||||
symbol: "none",
|
||||
silent: true,
|
||||
})
|
||||
}
|
||||
|
||||
seriesIdx++
|
||||
}
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: "item" as const,
|
||||
formatter: (params: unknown) => {
|
||||
const p = params as { seriesName?: string; value?: [number, number] }
|
||||
if (!p.seriesName || p.seriesName.includes("(est.)")) return ""
|
||||
return `${p.seriesName}<br/>TDP: ${p.value?.[0]}W<br/>Battery: ~${p.value?.[1]}h`
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
textStyle: { color: "#999" },
|
||||
top: 0,
|
||||
},
|
||||
grid: { left: 60, right: 20, top: 40, bottom: 40 },
|
||||
xAxis: {
|
||||
type: "value" as const,
|
||||
name: "TDP (W)",
|
||||
nameTextStyle: { color: "#999" },
|
||||
splitLine: { lineStyle: { color: "#333" } },
|
||||
axisLabel: { color: "#999" },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value" as const,
|
||||
name: "Battery (h)",
|
||||
nameTextStyle: { color: "#999" },
|
||||
splitLine: { lineStyle: { color: "#333" } },
|
||||
axisLabel: { color: "#999" },
|
||||
},
|
||||
series,
|
||||
}
|
||||
|
||||
return <EChartWrapper option={option} height={300} />
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { EChartWrapper, CHART_THEME, getDeviceColor } from "./EChartWrapper"
|
||||
import type { EChartsOption } from "echarts"
|
||||
|
||||
interface DeviceEntry {
|
||||
hardwareSlug: string
|
||||
hardwareName: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export function DeviceDonut({
|
||||
data,
|
||||
className,
|
||||
}: {
|
||||
data: DeviceEntry[]
|
||||
className?: string
|
||||
}) {
|
||||
const option = useMemo<EChartsOption>(() => {
|
||||
const total = data.reduce((sum, d) => sum + d.count, 0)
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
backgroundColor: "#1a1025",
|
||||
borderColor: CHART_THEME.border,
|
||||
textStyle: { color: CHART_THEME.text, fontSize: 12 },
|
||||
formatter: "{b}: {c} ({d}%)",
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "pie",
|
||||
radius: ["50%", "75%"],
|
||||
center: ["50%", "55%"],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 6,
|
||||
borderColor: "#100b14",
|
||||
borderWidth: 2,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: "center",
|
||||
formatter: `{total|${total}}\n{label|entries}`,
|
||||
rich: {
|
||||
total: {
|
||||
fontSize: 22,
|
||||
fontWeight: "bold",
|
||||
color: CHART_THEME.text,
|
||||
lineHeight: 30,
|
||||
},
|
||||
label: {
|
||||
fontSize: 11,
|
||||
color: CHART_THEME.textMuted,
|
||||
},
|
||||
},
|
||||
},
|
||||
emphasis: {
|
||||
label: { show: true },
|
||||
},
|
||||
data: data.map((d, idx) => ({
|
||||
name: d.hardwareName,
|
||||
value: d.count,
|
||||
itemStyle: { color: getDeviceColor(idx) },
|
||||
})),
|
||||
},
|
||||
],
|
||||
}
|
||||
}, [data])
|
||||
|
||||
if (data.length === 0) return null
|
||||
|
||||
return <EChartWrapper option={option} height={220} className={className} />
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client"
|
||||
|
||||
import { useRef, useCallback } from "react"
|
||||
import ReactECharts from "echarts-for-react"
|
||||
import type { EChartsOption } from "echarts"
|
||||
|
||||
// Project theme colors matching globals.css
|
||||
export const CHART_THEME = {
|
||||
bg: "transparent",
|
||||
text: "#ebe4f1",
|
||||
textMuted: "#6b5a7d",
|
||||
textSubtle: "#4a3a5c",
|
||||
border: "#3d2d52",
|
||||
primary: "#eb3779",
|
||||
secondary: "#571b8b",
|
||||
accent: "#fb793c",
|
||||
success: "#22c55e",
|
||||
info: "#3b82f6",
|
||||
warning: "#f59e0b",
|
||||
// Device-specific colors
|
||||
deviceColors: [
|
||||
"#eb3779", // primary (OLED)
|
||||
"#571b8b", // secondary (LCD)
|
||||
"#fb793c", // accent (Steam Machine)
|
||||
"#22c55e",
|
||||
"#3b82f6",
|
||||
"#f59e0b",
|
||||
"#a78bfa",
|
||||
"#ec4899",
|
||||
],
|
||||
}
|
||||
|
||||
export function getDeviceColor(index: number): string {
|
||||
return CHART_THEME.deviceColors[index % CHART_THEME.deviceColors.length]
|
||||
}
|
||||
|
||||
export function EChartWrapper({
|
||||
option,
|
||||
height = 300,
|
||||
className = "",
|
||||
}: {
|
||||
option: EChartsOption
|
||||
height?: number
|
||||
className?: string
|
||||
}) {
|
||||
const chartRef = useRef<ReactECharts>(null)
|
||||
|
||||
const onEvents = useCallback(
|
||||
() => ({
|
||||
// Placeholder for future event handlers
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={className} style={{ height }}>
|
||||
<ReactECharts
|
||||
ref={chartRef}
|
||||
option={option}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
opts={{ renderer: "canvas" }}
|
||||
onEvents={onEvents()}
|
||||
theme={undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { EChartWrapper, CHART_THEME } from "./EChartWrapper"
|
||||
import type { EChartsOption } from "echarts"
|
||||
|
||||
interface BoxplotEntry {
|
||||
hardwareSlug: string
|
||||
hardwareName: string
|
||||
min: number
|
||||
q1: number
|
||||
median: number
|
||||
q3: number
|
||||
max: number
|
||||
}
|
||||
|
||||
export function FpsBoxplot({
|
||||
data,
|
||||
className,
|
||||
}: {
|
||||
data: BoxplotEntry[]
|
||||
className?: string
|
||||
}) {
|
||||
const option = useMemo<EChartsOption>(() => {
|
||||
const categories = data.map((d) => d.hardwareName)
|
||||
const boxData = data.map((d) => [d.min, d.q1, d.median, d.q3, d.max])
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
backgroundColor: "#1a1025",
|
||||
borderColor: CHART_THEME.border,
|
||||
textStyle: { color: CHART_THEME.text, fontSize: 12 },
|
||||
formatter: (params) => {
|
||||
const d = (params as { data: number[] }).data
|
||||
if (!Array.isArray(d)) return ""
|
||||
return `Min: ${d[0]}<br/>Q1: ${d[1]}<br/>Median: ${d[2]}<br/>Q3: ${d[3]}<br/>Max: ${d[4]}`
|
||||
},
|
||||
},
|
||||
grid: { top: 16, right: 16, bottom: 24, left: 40 },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: categories,
|
||||
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
|
||||
axisLine: { lineStyle: { color: CHART_THEME.border } },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
name: "FPS",
|
||||
nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 10 },
|
||||
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
|
||||
splitLine: { lineStyle: { color: CHART_THEME.border, opacity: 0.3 } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "boxplot",
|
||||
data: boxData,
|
||||
itemStyle: {
|
||||
color: CHART_THEME.primary + "20",
|
||||
borderColor: CHART_THEME.primary,
|
||||
borderWidth: 2,
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
borderColor: CHART_THEME.accent,
|
||||
borderWidth: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}, [data])
|
||||
|
||||
if (data.length === 0) return null
|
||||
|
||||
return <EChartWrapper option={option} height={220} className={className} />
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { EChartWrapper, CHART_THEME, getDeviceColor } from "./EChartWrapper"
|
||||
import type { EChartsOption } from "echarts"
|
||||
|
||||
interface RangeEntry {
|
||||
id: string
|
||||
hardwareSlug: string
|
||||
fpsLow: number
|
||||
fpsAvg: number
|
||||
fpsHigh: number
|
||||
isRawPerformer: boolean
|
||||
}
|
||||
|
||||
export function FpsRangeChart({
|
||||
data,
|
||||
className,
|
||||
}: {
|
||||
data: RangeEntry[]
|
||||
className?: string
|
||||
}) {
|
||||
const option = useMemo<EChartsOption>(() => {
|
||||
const devices = [...new Set(data.map((d) => d.hardwareSlug))]
|
||||
const sorted = [...data].sort((a, b) => b.fpsAvg - a.fpsAvg)
|
||||
const labels = sorted.map((_, i) => `#${i + 1}`)
|
||||
|
||||
const series = devices.map((device, idx) => ({
|
||||
name: device.replace(/-/g, " "),
|
||||
type: "bar" as const,
|
||||
stack: "range",
|
||||
data: sorted.map((entry) => {
|
||||
if (entry.hardwareSlug !== device) return 0
|
||||
return entry.fpsHigh - entry.fpsLow
|
||||
}),
|
||||
itemStyle: {
|
||||
color: getDeviceColor(idx),
|
||||
borderRadius: [2, 2, 0, 0],
|
||||
},
|
||||
barWidth: "60%",
|
||||
}))
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
backgroundColor: "#1a1025",
|
||||
borderColor: CHART_THEME.border,
|
||||
textStyle: { color: CHART_THEME.text, fontSize: 12 },
|
||||
},
|
||||
legend: {
|
||||
top: 0,
|
||||
textStyle: { color: CHART_THEME.textMuted, fontSize: 11 },
|
||||
},
|
||||
grid: { top: 30, right: 16, bottom: 24, left: 40 },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: labels,
|
||||
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
|
||||
axisLine: { lineStyle: { color: CHART_THEME.border } },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
name: "FPS Range",
|
||||
nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 10 },
|
||||
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
|
||||
splitLine: { lineStyle: { color: CHART_THEME.border, opacity: 0.3 } },
|
||||
},
|
||||
series,
|
||||
}
|
||||
}, [data])
|
||||
|
||||
if (data.length === 0) return null
|
||||
|
||||
return <EChartWrapper option={option} height={220} className={className} />
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { EChartWrapper, CHART_THEME, getDeviceColor } from "./EChartWrapper"
|
||||
import type { EChartsOption } from "echarts"
|
||||
|
||||
interface HistoricalEntry {
|
||||
period: string
|
||||
entries: Array<{
|
||||
hardwareSlug: string
|
||||
avgFps: number
|
||||
count: number
|
||||
}>
|
||||
}
|
||||
|
||||
export function HistoricalAreaChart({
|
||||
data,
|
||||
className,
|
||||
}: {
|
||||
data: HistoricalEntry[]
|
||||
className?: string
|
||||
}) {
|
||||
const option = useMemo<EChartsOption>(() => {
|
||||
const periods = data.map((d) => d.period)
|
||||
const deviceSlugs = [
|
||||
...new Set(data.flatMap((d) => d.entries.map((e) => e.hardwareSlug))),
|
||||
]
|
||||
|
||||
const series = deviceSlugs.map((slug, idx) => ({
|
||||
name: slug.replace(/-/g, " "),
|
||||
type: "line" as const,
|
||||
stack: "total",
|
||||
areaStyle: { opacity: 0.3 },
|
||||
emphasis: { focus: "series" as const },
|
||||
smooth: true,
|
||||
data: periods.map((period) => {
|
||||
const entry = data
|
||||
.find((d) => d.period === period)
|
||||
?.entries.find((e) => e.hardwareSlug === slug)
|
||||
return entry?.avgFps ?? null
|
||||
}),
|
||||
itemStyle: { color: getDeviceColor(idx) },
|
||||
lineStyle: { color: getDeviceColor(idx) },
|
||||
}))
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
backgroundColor: "#1a1025",
|
||||
borderColor: CHART_THEME.border,
|
||||
textStyle: { color: CHART_THEME.text, fontSize: 12 },
|
||||
},
|
||||
legend: {
|
||||
top: 0,
|
||||
textStyle: { color: CHART_THEME.textMuted, fontSize: 11 },
|
||||
},
|
||||
grid: { top: 30, right: 16, bottom: 24, left: 40 },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: periods,
|
||||
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
|
||||
axisLine: { lineStyle: { color: CHART_THEME.border } },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
name: "AVG FPS",
|
||||
nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 10 },
|
||||
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
|
||||
splitLine: { lineStyle: { color: CHART_THEME.border, opacity: 0.3 } },
|
||||
},
|
||||
series,
|
||||
}
|
||||
}, [data])
|
||||
|
||||
if (data.length === 0) return null
|
||||
|
||||
return <EChartWrapper option={option} height={280} className={className} />
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import { EChartWrapper } from "./EChartWrapper"
|
||||
|
||||
interface TierData {
|
||||
hardwareSlug: string
|
||||
hardwareName?: string
|
||||
unplayable: number
|
||||
playable: number
|
||||
smooth: number
|
||||
excellent: number
|
||||
}
|
||||
|
||||
export function PerformanceTierChart({ data }: { data: TierData[] }) {
|
||||
if (!data || data.length === 0) {
|
||||
return <div className="flex items-center justify-center h-48 text-sm text-text/40">No tier data</div>
|
||||
}
|
||||
|
||||
const labels = data.map((d) => d.hardwareName || d.hardwareSlug)
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: "axis" as const,
|
||||
axisPointer: { type: "shadow" as const },
|
||||
},
|
||||
legend: {
|
||||
data: ["<30 fps", "30-59", "60-119", "≥120"],
|
||||
textStyle: { color: "#999" },
|
||||
top: 0,
|
||||
},
|
||||
grid: { left: 100, right: 20, top: 40, bottom: 30 },
|
||||
xAxis: { type: "value" as const, splitLine: { lineStyle: { color: "#333" } } },
|
||||
yAxis: {
|
||||
type: "category" as const,
|
||||
data: labels,
|
||||
axisLine: { lineStyle: { color: "#555" } },
|
||||
axisLabel: { color: "#999" },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "<30 fps",
|
||||
type: "bar" as const,
|
||||
stack: "total",
|
||||
data: data.map((d) => d.unplayable),
|
||||
itemStyle: { color: "#ef4444" },
|
||||
},
|
||||
{
|
||||
name: "30-59",
|
||||
type: "bar" as const,
|
||||
stack: "total",
|
||||
data: data.map((d) => d.playable),
|
||||
itemStyle: { color: "#eab308" },
|
||||
},
|
||||
{
|
||||
name: "60-119",
|
||||
type: "bar" as const,
|
||||
stack: "total",
|
||||
data: data.map((d) => d.smooth),
|
||||
itemStyle: { color: "#22c55e" },
|
||||
},
|
||||
{
|
||||
name: "≥120",
|
||||
type: "bar" as const,
|
||||
stack: "total",
|
||||
data: data.map((d) => d.excellent),
|
||||
itemStyle: { color: "#3b82f6" },
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return <EChartWrapper option={option} height={250} />
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
import { EChartWrapper } from "./EChartWrapper"
|
||||
|
||||
interface ScatterPoint {
|
||||
id: string
|
||||
hardwareSlug: string
|
||||
fpsAvg: number
|
||||
fpsOnePercentLow: number
|
||||
stabilityRatio: number
|
||||
}
|
||||
|
||||
export function StabilityScatterChart({ data, deviceNames }: { data: ScatterPoint[]; deviceNames?: Record<string, string> }) {
|
||||
if (!data || data.length === 0) {
|
||||
return <div className="flex items-center justify-center h-48 text-sm text-text/40">No stability data yet</div>
|
||||
}
|
||||
|
||||
// Group by device
|
||||
const deviceGroups = new Map<string, ScatterPoint[]>()
|
||||
for (const point of data) {
|
||||
const existing = deviceGroups.get(point.hardwareSlug) || []
|
||||
existing.push(point)
|
||||
deviceGroups.set(point.hardwareSlug, existing)
|
||||
}
|
||||
|
||||
const colors = ["#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6"]
|
||||
const series = Array.from(deviceGroups.entries()).map(([slug, points], idx) => ({
|
||||
name: deviceNames?.[slug] || slug,
|
||||
type: "scatter" as const,
|
||||
data: points.map((p) => [p.fpsAvg, p.fpsOnePercentLow]),
|
||||
itemStyle: { color: colors[idx % colors.length] },
|
||||
symbolSize: 8,
|
||||
}))
|
||||
|
||||
// Perfect stability line (y = x)
|
||||
const maxFps = Math.max(...data.map((d) => d.fpsAvg))
|
||||
const perfectLine = {
|
||||
name: "Perfect Stability",
|
||||
type: "line" as const,
|
||||
data: [
|
||||
[0, 0],
|
||||
[maxFps, maxFps],
|
||||
],
|
||||
lineStyle: { color: "#555", type: "dashed" as const },
|
||||
symbol: "none",
|
||||
silent: true,
|
||||
}
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: "item" as const,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
formatter: (params: any) => {
|
||||
if (params.seriesName === "Perfect Stability") return ""
|
||||
return `${params.seriesName}<br/>Avg: ${params.value[0]} fps<br/>1% Low: ${params.value[1]} fps`
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
textStyle: { color: "#999" },
|
||||
top: 0,
|
||||
},
|
||||
grid: { left: 60, right: 20, top: 40, bottom: 40 },
|
||||
xAxis: {
|
||||
type: "value" as const,
|
||||
name: "Avg FPS",
|
||||
nameTextStyle: { color: "#999" },
|
||||
splitLine: { lineStyle: { color: "#333" } },
|
||||
axisLabel: { color: "#999" },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value" as const,
|
||||
name: "1% Low FPS",
|
||||
nameTextStyle: { color: "#999" },
|
||||
splitLine: { lineStyle: { color: "#333" } },
|
||||
axisLabel: { color: "#999" },
|
||||
},
|
||||
series: [...series, perfectLine],
|
||||
}
|
||||
|
||||
return <EChartWrapper option={option} height={300} />
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { EChartWrapper, CHART_THEME, getDeviceColor } from "./EChartWrapper"
|
||||
import type { EChartsOption } from "echarts"
|
||||
|
||||
interface UpscalerStat {
|
||||
upscalerType: string
|
||||
upscalerVersion?: string | null
|
||||
frameGenMethod: string
|
||||
hardwareSlug: string
|
||||
avgFps: number
|
||||
count: number
|
||||
}
|
||||
|
||||
function formatCombo(upscalerType: string, upscalerVersion: string | null | undefined, fg: string): string {
|
||||
const parts: string[] = []
|
||||
if (upscalerType !== "none") {
|
||||
const upscalerLabel = upscalerVersion
|
||||
? `${upscalerType.toUpperCase()} ${upscalerVersion}`
|
||||
: upscalerType.toUpperCase()
|
||||
parts.push(upscalerLabel)
|
||||
}
|
||||
if (fg !== "none") {
|
||||
if (fg === "fsr_fg") parts.push("FSR FG")
|
||||
else if (fg === "dlss_fg") parts.push("DLSS FG")
|
||||
else if (fg === "lsfg") parts.push("LSFG")
|
||||
else parts.push(fg.toUpperCase())
|
||||
}
|
||||
return parts.length > 0 ? parts.join(" + ") : "Native"
|
||||
}
|
||||
|
||||
export function UpscalerBarChart({
|
||||
data,
|
||||
className,
|
||||
}: {
|
||||
data: UpscalerStat[]
|
||||
className?: string
|
||||
}) {
|
||||
const option = useMemo<EChartsOption>(() => {
|
||||
const combos = [
|
||||
...new Set(
|
||||
data.map((d) => formatCombo(d.upscalerType, d.upscalerVersion, d.frameGenMethod)),
|
||||
),
|
||||
]
|
||||
const deviceSlugs = [...new Set(data.map((d) => d.hardwareSlug))]
|
||||
|
||||
const series = deviceSlugs.map((slug, idx) => ({
|
||||
name: slug.replace(/-/g, " "),
|
||||
type: "bar" as const,
|
||||
data: combos.map((combo) => {
|
||||
const match = data.find(
|
||||
(d) =>
|
||||
formatCombo(d.upscalerType, d.upscalerVersion, d.frameGenMethod) === combo &&
|
||||
d.hardwareSlug === slug,
|
||||
)
|
||||
return match?.avgFps ?? 0
|
||||
}),
|
||||
itemStyle: { color: getDeviceColor(idx), borderRadius: [4, 4, 0, 0] },
|
||||
barGap: "10%",
|
||||
}))
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
backgroundColor: "#1a1025",
|
||||
borderColor: CHART_THEME.border,
|
||||
textStyle: { color: CHART_THEME.text, fontSize: 12 },
|
||||
},
|
||||
legend: {
|
||||
top: 0,
|
||||
textStyle: { color: CHART_THEME.textMuted, fontSize: 11 },
|
||||
},
|
||||
grid: { top: 30, right: 16, bottom: 50, left: 40 },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: combos,
|
||||
axisLabel: {
|
||||
color: CHART_THEME.textMuted,
|
||||
fontSize: 10,
|
||||
rotate: 30,
|
||||
},
|
||||
axisLine: { lineStyle: { color: CHART_THEME.border } },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
name: "AVG FPS",
|
||||
nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 10 },
|
||||
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
|
||||
splitLine: { lineStyle: { color: CHART_THEME.border, opacity: 0.3 } },
|
||||
},
|
||||
series,
|
||||
}
|
||||
}, [data])
|
||||
|
||||
if (data.length === 0) return null
|
||||
|
||||
return <EChartWrapper option={option} height={280} className={className} />
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import Image from "next/image"
|
||||
import {
|
||||
ThumbsUpIcon,
|
||||
ReplyIcon,
|
||||
MoreHorizontalIcon,
|
||||
TrashIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
} from "lucide-react"
|
||||
import { useSession } from "@/lib/auth-client"
|
||||
import { TiptapRenderer } from "@/components/tiptap-renderer"
|
||||
import { TiptapEditor } from "@/components/tiptap-editor"
|
||||
|
||||
const MAX_DEPTH = 3
|
||||
|
||||
export interface CommentData {
|
||||
id: string
|
||||
gameId: string
|
||||
userId: string
|
||||
parentId: string | null
|
||||
content: Record<string, unknown>
|
||||
upvotes: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
userName: string | null
|
||||
userImage: string | null
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return ""
|
||||
return new Date(value).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
function getInitial(name: string | null | undefined): string {
|
||||
return name?.charAt(0)?.toUpperCase() || "?"
|
||||
}
|
||||
|
||||
interface CommentItemProps {
|
||||
comment: CommentData
|
||||
depth?: number
|
||||
onReplyPosted: () => void
|
||||
gameId: string
|
||||
}
|
||||
|
||||
export function CommentItem({
|
||||
comment,
|
||||
depth = 0,
|
||||
onReplyPosted,
|
||||
gameId,
|
||||
}: CommentItemProps) {
|
||||
const { data: session } = useSession()
|
||||
const [upvotes, setUpvotes] = useState(comment.upvotes)
|
||||
const [hasUpvoted, setHasUpvoted] = useState(false)
|
||||
const [isReplying, setIsReplying] = useState(false)
|
||||
const [replyContent, setReplyContent] = useState<Record<string, unknown> | null>(null)
|
||||
const [replySubmitting, setReplySubmitting] = useState(false)
|
||||
const [showReplies, setShowReplies] = useState(false)
|
||||
const [replies, setReplies] = useState<CommentData[]>([])
|
||||
const [loadingReplies, setLoadingReplies] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [isDeleted, setIsDeleted] = useState(false)
|
||||
|
||||
const isOwner = session?.user?.id === comment.userId
|
||||
const isAdmin = session?.user?.role === "admin"
|
||||
const canModerate = isOwner || isAdmin
|
||||
|
||||
const handleUpvote = useCallback(async () => {
|
||||
if (!session) return
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/games/${gameId}/comments/${comment.id}/upvote`,
|
||||
{ method: "POST" },
|
||||
)
|
||||
if (res.ok) {
|
||||
setUpvotes((prev) => (hasUpvoted ? prev - 1 : prev + 1))
|
||||
setHasUpvoted((prev) => !prev)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to upvote comment:", err)
|
||||
}
|
||||
}, [session, gameId, comment.id, hasUpvoted])
|
||||
|
||||
const handleReplySubmit = useCallback(async () => {
|
||||
if (!replyContent || !session) return
|
||||
setReplySubmitting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/games/${gameId}/comments`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ parentId: comment.id, content: replyContent }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setIsReplying(false)
|
||||
setReplyContent(null)
|
||||
onReplyPosted()
|
||||
if (showReplies) {
|
||||
// Refresh replies
|
||||
setLoadingReplies(true)
|
||||
const repliesRes = await fetch(
|
||||
`/api/games/${gameId}/comments/${comment.id}/replies`,
|
||||
)
|
||||
if (repliesRes.ok) {
|
||||
const data = (await repliesRes.json()) as CommentData[]
|
||||
setReplies(data)
|
||||
}
|
||||
setLoadingReplies(false)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to post reply:", err)
|
||||
} finally {
|
||||
setReplySubmitting(false)
|
||||
}
|
||||
}, [replyContent, session, gameId, comment.id, onReplyPosted, showReplies])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!canModerate) return
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/games/${gameId}/comments/${comment.id}`,
|
||||
{ method: "DELETE" },
|
||||
)
|
||||
if (res.ok) {
|
||||
setIsDeleted(true)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to delete comment:", err)
|
||||
}
|
||||
}, [canModerate, gameId, comment.id])
|
||||
|
||||
const handleLoadReplies = useCallback(async () => {
|
||||
if (showReplies) {
|
||||
setShowReplies(false)
|
||||
return
|
||||
}
|
||||
setLoadingReplies(true)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/games/${gameId}/comments/${comment.id}/replies`,
|
||||
)
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as CommentData[]
|
||||
setReplies(data)
|
||||
setShowReplies(true)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load replies:", err)
|
||||
} finally {
|
||||
setLoadingReplies(false)
|
||||
}
|
||||
}, [showReplies, gameId, comment.id])
|
||||
|
||||
if (isDeleted) {
|
||||
return (
|
||||
<div className="py-3 text-sm text-text/40 italic">
|
||||
Comment removed
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={depth > 0 ? "ml-4 border-l border-border pl-3" : ""}>
|
||||
<div className="flex gap-3 py-3">
|
||||
{/* Avatar */}
|
||||
<div className="shrink-0">
|
||||
{comment.userImage ? (
|
||||
<Image
|
||||
src={comment.userImage}
|
||||
alt={comment.userName || "User"}
|
||||
width={36}
|
||||
height={36}
|
||||
className="h-9 w-9 rounded-full object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="h-9 w-9 rounded-full bg-text/10 flex items-center justify-center text-sm font-medium text-text/70">
|
||||
{getInitial(comment.userName)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-text">
|
||||
{comment.userName || "Unknown"}
|
||||
</span>
|
||||
<span className="text-xs text-text/40">
|
||||
{formatDate(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1">
|
||||
<TiptapRenderer content={JSON.stringify(comment.content)} />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<button
|
||||
onClick={handleUpvote}
|
||||
className={`flex items-center gap-1 text-xs transition-colors cursor-pointer ${
|
||||
hasUpvoted
|
||||
? "text-primary"
|
||||
: "text-text/50 hover:text-text/80"
|
||||
}`}
|
||||
title="Upvote"
|
||||
>
|
||||
<ThumbsUpIcon className="h-3.5 w-3.5" />
|
||||
<span>{upvotes}</span>
|
||||
</button>
|
||||
|
||||
{session && depth < MAX_DEPTH && (
|
||||
<button
|
||||
onClick={() => setIsReplying((prev) => !prev)}
|
||||
className="flex items-center gap-1 text-xs text-text/50 hover:text-text/80 transition-colors cursor-pointer"
|
||||
title="Reply"
|
||||
>
|
||||
<ReplyIcon className="h-3.5 w-3.5" />
|
||||
<span>Reply</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canModerate && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
className="flex items-center gap-1 text-xs text-text/50 hover:text-text/80 transition-colors cursor-pointer"
|
||||
title="More options"
|
||||
>
|
||||
<MoreHorizontalIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 z-20 mt-1 w-32 rounded-md border border-border bg-background shadow-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
handleDelete()
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer"
|
||||
>
|
||||
<TrashIcon className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reply form */}
|
||||
{isReplying && (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<TiptapEditor
|
||||
placeholder="Write a reply..."
|
||||
onChange={(json) => setReplyContent(json)}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleReplySubmit}
|
||||
disabled={!replyContent || replySubmitting}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium bg-primary text-white hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{replySubmitting ? "Posting..." : "Post Reply"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsReplying(false)
|
||||
setReplyContent(null)
|
||||
}}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text/70 hover:bg-text/10 transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load replies */}
|
||||
{depth < MAX_DEPTH && (
|
||||
<div className="mt-2">
|
||||
{replies.length > 0 && !showReplies && (
|
||||
<button
|
||||
onClick={() => setShowReplies(true)}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors cursor-pointer"
|
||||
>
|
||||
<ChevronDownIcon className="h-3.5 w-3.5" />
|
||||
Show {replies.length} replies
|
||||
</button>
|
||||
)}
|
||||
{showReplies && replies.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowReplies(false)}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors cursor-pointer"
|
||||
>
|
||||
<ChevronUpIcon className="h-3.5 w-3.5" />
|
||||
Hide replies
|
||||
</button>
|
||||
)}
|
||||
{replies.length === 0 && !showReplies && (
|
||||
<button
|
||||
onClick={handleLoadReplies}
|
||||
disabled={loadingReplies}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{loadingReplies ? (
|
||||
"Loading..."
|
||||
) : (
|
||||
<>
|
||||
<ChevronDownIcon className="h-3.5 w-3.5" />
|
||||
Load replies
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Replies list */}
|
||||
{depth < MAX_DEPTH && showReplies && replies.length > 0 && (
|
||||
<div className="mt-2">
|
||||
{replies.map((reply) => (
|
||||
<CommentItem
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
depth={depth + 1}
|
||||
onReplyPosted={onReplyPosted}
|
||||
gameId={gameId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback, useEffect } from "react"
|
||||
import Link from "next/link"
|
||||
import { MessageSquareIcon, Loader2 } from "lucide-react"
|
||||
import { useSession } from "@/lib/auth-client"
|
||||
import { TiptapEditor } from "@/components/tiptap-editor"
|
||||
import { CommentItem, CommentData } from "./comment-item"
|
||||
|
||||
interface CommentSectionProps {
|
||||
gameId: string
|
||||
initialCount: number
|
||||
}
|
||||
|
||||
interface CommentsApiResponse {
|
||||
data: CommentData[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export function CommentSection({ gameId, initialCount }: CommentSectionProps) {
|
||||
const { data: session } = useSession()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [comments, setComments] = useState<CommentData[]>([])
|
||||
const [total, setTotal] = useState(initialCount)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [commentContent, setCommentContent] = useState<Record<string, unknown> | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const limit = 20
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function fetchInitial() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/games/${gameId}/comments?limit=${limit}&offset=0`,
|
||||
)
|
||||
if (!cancelled && res.ok) {
|
||||
const json = (await res.json()) as CommentsApiResponse
|
||||
setComments(json.data)
|
||||
setTotal(json.total)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load comments:", err)
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchInitial()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [gameId])
|
||||
|
||||
const refreshComments = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/games/${gameId}/comments?limit=${limit}&offset=0`,
|
||||
)
|
||||
if (res.ok) {
|
||||
const json = (await res.json()) as CommentsApiResponse
|
||||
setComments(json.data)
|
||||
setTotal(json.total)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to refresh comments:", err)
|
||||
}
|
||||
}, [gameId])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!commentContent || !session) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/games/${gameId}/comments`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: commentContent }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setCommentContent(null)
|
||||
await refreshComments()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to post comment:", err)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [commentContent, session, gameId, refreshComments])
|
||||
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
const newOffset = offset + limit
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/games/${gameId}/comments?limit=${limit}&offset=${newOffset}`,
|
||||
)
|
||||
if (res.ok) {
|
||||
const json = (await res.json()) as CommentsApiResponse
|
||||
setComments((prev) => [...prev, ...json.data])
|
||||
setTotal(json.total)
|
||||
setOffset(newOffset)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load more comments:", err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [offset, gameId])
|
||||
|
||||
const handleReplyPosted = useCallback(() => {
|
||||
refreshComments()
|
||||
}, [refreshComments])
|
||||
|
||||
const hasMore = comments.length < total
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquareIcon className="h-5 w-5 text-text/70" />
|
||||
<h2 className="text-lg font-semibold text-text">Comments</h2>
|
||||
<span className="text-sm text-text/50">({total})</span>
|
||||
</div>
|
||||
|
||||
{/* Compose — suppress until mounted to avoid hydration mismatch */}
|
||||
{mounted && session ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<TiptapEditor
|
||||
placeholder="Leave a comment..."
|
||||
onChange={(json) => setCommentContent(json)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!commentContent || submitting}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Posting...
|
||||
</>
|
||||
) : (
|
||||
"Post Comment"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : mounted ? (
|
||||
<div className="p-4 rounded-lg border border-border bg-text/3 text-center">
|
||||
<p className="text-sm text-text/70">
|
||||
<Link
|
||||
href="/auth/sign-in"
|
||||
className="text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>{" "}
|
||||
to leave a comment
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[72px] rounded-lg border border-border bg-text/[0.02] animate-pulse" />
|
||||
)}
|
||||
|
||||
{/* Comment list */}
|
||||
<div className="flex flex-col">
|
||||
{loading && comments.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-text/50">
|
||||
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />
|
||||
Loading comments...
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-text/50">
|
||||
No comments yet. Be the first to share your thoughts!
|
||||
</div>
|
||||
) : (
|
||||
comments.map((comment) => (
|
||||
<CommentItem
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
depth={0}
|
||||
onReplyPosted={handleReplyPosted}
|
||||
gameId={gameId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Load more */}
|
||||
{hasMore && !loading && (
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-text/5 text-text/70 hover:bg-text/10 transition-colors cursor-pointer"
|
||||
>
|
||||
Load more comments
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && comments.length > 0 && (
|
||||
<div className="py-4 text-center text-sm text-text/50">
|
||||
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { MessageSquarePlus, Send, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CommunitySuggestionFormProps {
|
||||
gameId: string;
|
||||
gameTitle: string;
|
||||
editableFields: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
currentValue: string;
|
||||
}>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CommunitySuggestionForm({
|
||||
gameId,
|
||||
gameTitle,
|
||||
editableFields,
|
||||
className,
|
||||
}: CommunitySuggestionFormProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedField, setSelectedField] = useState("");
|
||||
const [proposedValue, setProposedValue] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedField || !proposedValue.trim()) return;
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/community-suggestions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
gameId,
|
||||
fieldName: selectedField,
|
||||
proposedValue: proposedValue.trim(),
|
||||
reason: reason.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Failed to submit suggestion");
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setSuccess(false);
|
||||
setSelectedField("");
|
||||
setProposedValue("");
|
||||
setReason("");
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg border border-zinc-700 px-3 py-2 text-sm text-zinc-300 hover:bg-zinc-800",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<MessageSquarePlus className="h-4 w-4" />
|
||||
Suggest Edit
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border border-zinc-700 bg-zinc-900 p-4", className)}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h4 className="font-medium">Suggest an Edit for {gameTitle}</h4>
|
||||
<button onClick={() => setIsOpen(false)} className="text-zinc-400 hover:text-zinc-200">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{success ? (
|
||||
<div className="rounded-md bg-green-500/10 p-3 text-sm text-green-400">
|
||||
Suggestion submitted! A moderator will review it.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-zinc-400">Field</label>
|
||||
<select
|
||||
value={selectedField}
|
||||
onChange={(e) => {
|
||||
setSelectedField(e.target.value);
|
||||
const field = editableFields.find((f) => f.name === e.target.value);
|
||||
setProposedValue(field?.currentValue || "");
|
||||
}}
|
||||
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Select a field...</option>
|
||||
{editableFields.map((field) => (
|
||||
<option key={field.name} value={field.name}>
|
||||
{field.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedField && (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-zinc-400">Proposed Value</label>
|
||||
<textarea
|
||||
value={proposedValue}
|
||||
onChange={(e) => setProposedValue(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm"
|
||||
placeholder="Enter the corrected value..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-zinc-400">Reason (optional)</label>
|
||||
<input
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm"
|
||||
placeholder="Why should this be changed?"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!selectedField || !proposedValue.trim() || submitting}
|
||||
className="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-50"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{submitting ? "Submitting..." : "Submit Suggestion"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import { EChartWrapper } from "@/components/charts/EChartWrapper"
|
||||
|
||||
interface GameWithStats {
|
||||
id: string
|
||||
title: string
|
||||
stats: {
|
||||
avgFps: number | null
|
||||
avgOnePercentLow: number | null
|
||||
deviceBreakdown: Array<{ hardwareSlug: string; count: number; avgFps: number }>
|
||||
}
|
||||
}
|
||||
|
||||
export function FpsComparisonChart({ games }: { games: GameWithStats[] }) {
|
||||
if (games.length === 0) return null
|
||||
|
||||
// Collect all unique devices across games
|
||||
const allDevices = [...new Set(games.flatMap(g => g.stats.deviceBreakdown.map(d => d.hardwareSlug)))]
|
||||
|
||||
const colors = ["#3b82f6", "#22c55e", "#f59e0b", "#ef4444"]
|
||||
|
||||
// Build series: one series per game, data points per device
|
||||
const series = games.map((game, idx) => ({
|
||||
name: game.title,
|
||||
type: "bar" as const,
|
||||
data: allDevices.map(slug => {
|
||||
const device = game.stats.deviceBreakdown.find(d => d.hardwareSlug === slug)
|
||||
return device ? device.avgFps : null
|
||||
}),
|
||||
itemStyle: { color: colors[idx % colors.length] },
|
||||
barGap: "10%",
|
||||
}))
|
||||
|
||||
const option = {
|
||||
tooltip: { trigger: "axis" as const, axisPointer: { type: "shadow" as const } },
|
||||
legend: {
|
||||
data: games.map(g => g.title),
|
||||
textStyle: { color: "#999" },
|
||||
top: 0,
|
||||
},
|
||||
grid: { left: 60, right: 20, top: 40, bottom: 30 },
|
||||
xAxis: {
|
||||
type: "category" as const,
|
||||
data: allDevices.map(s => s.replace(/-/g, " ")),
|
||||
axisLine: { lineStyle: { color: "#555" } },
|
||||
axisLabel: { color: "#999" },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value" as const,
|
||||
name: "Avg FPS",
|
||||
nameTextStyle: { color: "#999" },
|
||||
splitLine: { lineStyle: { color: "#333" } },
|
||||
axisLabel: { color: "#999" },
|
||||
},
|
||||
series,
|
||||
}
|
||||
|
||||
return <EChartWrapper option={option} height={300} />
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import Image from "next/image"
|
||||
import { SearchIcon, XIcon, Gamepad2Icon } from "lucide-react"
|
||||
import { useDebounce } from "@/lib/hooks/useDebounce"
|
||||
|
||||
interface SearchResult {
|
||||
id: string
|
||||
appId: number | null
|
||||
title: string
|
||||
image: string | null
|
||||
source: string
|
||||
}
|
||||
|
||||
interface GameSelectorProps {
|
||||
selectedGames: SearchResult[]
|
||||
onSelect: (game: SearchResult) => void
|
||||
onRemove: (gameId: string) => void
|
||||
maxSelections?: number
|
||||
}
|
||||
|
||||
export function GameSelector({ selectedGames, onSelect, onRemove, maxSelections = 4 }: GameSelectorProps) {
|
||||
const [query, setQuery] = useState("")
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const debouncedQuery = useDebounce(query, 300)
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedQuery.length < 2) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
async function fetchResults() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/search/unified?q=${encodeURIComponent(debouncedQuery)}`)
|
||||
if (!res.ok) throw new Error("Search failed")
|
||||
const data = await res.json() as { results: Array<{ id?: string; appId?: number; title: string; image: string | null; source: string }> }
|
||||
if (!cancelled) {
|
||||
setResults(
|
||||
(data.results || [])
|
||||
.filter((r) => !selectedGames.some(sg => sg.id === (r.id || `steam-${r.appId}`)))
|
||||
.slice(0, 8)
|
||||
.map((r) => ({
|
||||
id: r.id || `steam-${r.appId}`,
|
||||
appId: r.appId ?? null,
|
||||
title: r.title,
|
||||
image: r.image,
|
||||
source: r.source,
|
||||
}))
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setResults([])
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchResults()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [debouncedQuery, selectedGames])
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick)
|
||||
return () => document.removeEventListener("mousedown", handleClick)
|
||||
}, [])
|
||||
|
||||
const canAdd = selectedGames.length < maxSelections
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3" ref={wrapperRef}>
|
||||
{/* Selected games chips */}
|
||||
{selectedGames.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedGames.map(game => (
|
||||
<div
|
||||
key={game.id}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-primary/30 bg-primary/5 text-sm"
|
||||
>
|
||||
{game.image ? (
|
||||
<Image src={game.image} alt={game.title} width={20} height={30} className="rounded" />
|
||||
) : (
|
||||
<Gamepad2Icon className="h-4 w-4 text-text/30" />
|
||||
)}
|
||||
<span className="text-text/80 max-w-40 truncate">{game.title}</span>
|
||||
<button
|
||||
onClick={() => onRemove(game.id)}
|
||||
className="text-text/40 hover:text-text/80 transition-colors cursor-pointer"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search input */}
|
||||
{canAdd && (
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border bg-text/5">
|
||||
<SearchIcon className="h-4 w-4 text-text/40" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => { setQuery(e.target.value); setOpen(true) }}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder="Search for games to compare..."
|
||||
className="flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text/40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dropdown results */}
|
||||
{open && (debouncedQuery.length >= 2) && (
|
||||
<div className="absolute z-50 top-full left-0 right-0 mt-1 rounded-lg border border-border bg-background shadow-lg max-h-64 overflow-y-auto">
|
||||
{loading && (
|
||||
<div className="px-4 py-3 text-xs text-text/40">Searching...</div>
|
||||
)}
|
||||
{!loading && results.length === 0 && (
|
||||
<div className="px-4 py-3 text-xs text-text/40">No results found</div>
|
||||
)}
|
||||
{!loading && results.map(game => (
|
||||
<button
|
||||
key={game.id}
|
||||
onClick={() => {
|
||||
onSelect(game)
|
||||
setQuery("")
|
||||
setOpen(false)
|
||||
}}
|
||||
className="w-full flex items-center gap-3 px-4 py-2 hover:bg-text/5 transition-colors cursor-pointer text-left"
|
||||
>
|
||||
{game.image ? (
|
||||
<Image src={game.image} alt={game.title} width={24} height={36} className="rounded" />
|
||||
) : (
|
||||
<Gamepad2Icon className="h-4 w-4 text-text/30" />
|
||||
)}
|
||||
<span className="text-sm text-text/80 truncate">{game.title}</span>
|
||||
{game.source !== "steam" && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-400 capitalize ml-auto">
|
||||
{game.source}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!canAdd && (
|
||||
<p className="text-xs text-text/40">Maximum {maxSelections} games can be compared</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { EChartWrapper } from "@/components/charts/EChartWrapper"
|
||||
|
||||
interface GameWithStats {
|
||||
id: string
|
||||
title: string
|
||||
stats: {
|
||||
avgFps: number | null
|
||||
avgOnePercentLow: number | null
|
||||
avgStability: number | null
|
||||
medianFps: number | null
|
||||
bestFps: number | null
|
||||
}
|
||||
}
|
||||
|
||||
export function StabilityRadar({ games }: { games: GameWithStats[] }) {
|
||||
if (games.length === 0) return null
|
||||
|
||||
const colors = ["#3b82f6", "#22c55e", "#f59e0b", "#ef4444"]
|
||||
|
||||
// Normalize values to 0-100 scale for radar
|
||||
const maxFps = Math.max(...games.map(g => g.stats.bestFps ?? 0), 60)
|
||||
|
||||
const indicators = [
|
||||
{ name: "Avg FPS", max: maxFps },
|
||||
{ name: "1% Low", max: maxFps },
|
||||
{ name: "Stability", max: 100 },
|
||||
{ name: "Median FPS", max: maxFps },
|
||||
]
|
||||
|
||||
const series = games.map((game, idx) => ({
|
||||
value: [
|
||||
game.stats.avgFps ?? 0,
|
||||
game.stats.avgOnePercentLow ?? 0,
|
||||
game.stats.avgStability ?? 0,
|
||||
game.stats.medianFps ?? 0,
|
||||
],
|
||||
name: game.title,
|
||||
lineStyle: { color: colors[idx % colors.length] },
|
||||
itemStyle: { color: colors[idx % colors.length] },
|
||||
areaStyle: { color: colors[idx % colors.length], opacity: 0.1 },
|
||||
}))
|
||||
|
||||
const option = {
|
||||
tooltip: { trigger: "item" as const },
|
||||
legend: {
|
||||
data: games.map(g => g.title),
|
||||
textStyle: { color: "#999" },
|
||||
bottom: 0,
|
||||
},
|
||||
radar: {
|
||||
indicator: indicators,
|
||||
splitLine: { lineStyle: { color: "#333" } },
|
||||
splitArea: { areaStyle: { color: ["transparent"] } },
|
||||
axisLine: { lineStyle: { color: "#555" } },
|
||||
axisName: { color: "#999" },
|
||||
},
|
||||
series: [{
|
||||
type: "radar" as const,
|
||||
data: series,
|
||||
}],
|
||||
}
|
||||
|
||||
return <EChartWrapper option={option} height={350} />
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client"
|
||||
|
||||
interface GameWithStats {
|
||||
id: string
|
||||
title: string
|
||||
stats: {
|
||||
totalEntries: number
|
||||
avgFps: number | null
|
||||
medianFps: number | null
|
||||
bestFps: number | null
|
||||
avgOnePercentLow: number | null
|
||||
avgStability: number | null
|
||||
bestDevice: string | null
|
||||
tierBreakdown: { unplayable: number; playable: number; smooth: number; excellent: number } | null
|
||||
deviceBreakdown: Array<{ hardwareSlug: string; count: number; avgFps: number }>
|
||||
}
|
||||
}
|
||||
|
||||
function StatRow({ label, values }: { label: string; values: (string | number | null)[] }) {
|
||||
return (
|
||||
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(${values.length}, 1fr)` }}>
|
||||
{values.map((val, i) => (
|
||||
<div key={i} className="flex flex-col gap-1 p-3 rounded-lg border border-border bg-text/3">
|
||||
<span className="text-[10px] text-text/50 uppercase tracking-wider text-center">{i === 0 ? label : ""}</span>
|
||||
<span className="text-sm font-semibold tabular-nums text-center">
|
||||
{val !== null && val !== undefined ? val : "—"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatsComparison({ games }: { games: GameWithStats[] }) {
|
||||
if (games.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Header row with game titles */}
|
||||
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(${games.length}, 1fr)` }}>
|
||||
{games.map(game => (
|
||||
<div key={game.id} className="text-center">
|
||||
<h3 className="text-sm font-semibold text-text truncate">{game.title}</h3>
|
||||
<span className="text-xs text-text/50">{game.stats.totalEntries} entries</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border" />
|
||||
|
||||
<StatRow label="Avg FPS" values={games.map(g => g.stats.avgFps ? `${g.stats.avgFps}` : null)} />
|
||||
<StatRow label="Median FPS" values={games.map(g => g.stats.medianFps ? `${g.stats.medianFps}` : null)} />
|
||||
<StatRow label="Best FPS" values={games.map(g => g.stats.bestFps ? `${g.stats.bestFps}` : null)} />
|
||||
<StatRow label="Avg 1% Low" values={games.map(g => g.stats.avgOnePercentLow ? `${g.stats.avgOnePercentLow}` : null)} />
|
||||
<StatRow label="Stability" values={games.map(g => g.stats.avgStability != null ? `${g.stats.avgStability}%` : null)} />
|
||||
<StatRow label="Best Device" values={games.map(g => g.stats.bestDevice ?? null)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
|
||||
interface FilterDrawerProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function FilterDrawer({ isOpen, onClose, children }: FilterDrawerProps) {
|
||||
const drawerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose()
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
return () => document.removeEventListener("keydown", handleKeyDown)
|
||||
}, [isOpen, onClose])
|
||||
|
||||
// Prevent body scroll when open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden"
|
||||
} else {
|
||||
document.body.style.overflow = ""
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = ""
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Focus trap
|
||||
useEffect(() => {
|
||||
if (isOpen && drawerRef.current) {
|
||||
const firstFocusable = drawerRef.current.querySelector<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
)
|
||||
firstFocusable?.focus()
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className='fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden'
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<motion.div
|
||||
ref={drawerRef}
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
damping: 30,
|
||||
stiffness: 300,
|
||||
}}
|
||||
className='fixed top-0 right-0 bottom-0 z-50 w-[85vw] max-w-sm overflow-y-auto bg-background border-l border-border lg:hidden'
|
||||
>
|
||||
<div className='sticky top-0 z-10 flex items-center justify-between p-4 border-b border-border bg-background'>
|
||||
<h2 className='text-sm font-semibold'>Filters</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='p-1.5 rounded-md hover:bg-text/5 transition-colors cursor-pointer'
|
||||
aria-label='Close filters'
|
||||
>
|
||||
<XIcon className='h-4 w-4 text-text/50' />
|
||||
</button>
|
||||
</div>
|
||||
<div className='p-4'>{children}</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { TrendingUp, Users, Monitor, Tag } from "lucide-react"
|
||||
import ReactEChartsCore from "echarts-for-react/lib/core"
|
||||
import * as echarts from "echarts/core"
|
||||
import { LineChart, BarChart } from "echarts/charts"
|
||||
import {
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
} from "echarts/components"
|
||||
import { CanvasRenderer } from "echarts/renderers"
|
||||
|
||||
echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer])
|
||||
|
||||
interface AnalyticsData {
|
||||
benchmarkTimeline: Array<{ day: string; count: number }>
|
||||
userTimeline: Array<{ day: string; count: number }>
|
||||
deviceDistribution: Array<{ hardwareSlug: string; hardwareName: string; count: number }>
|
||||
genrePopularity: Array<{ genre: string; count: number }>
|
||||
commentTimeline: Array<{ day: string; count: number }>
|
||||
}
|
||||
|
||||
export function AnalyticsClient() {
|
||||
const [data, setData] = useState<AnalyticsData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/analytics/overview")
|
||||
.then((res) => res.json())
|
||||
.then((d) => { setData(d); setLoading(false) })
|
||||
.catch(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return <div className="animate-pulse h-96 rounded-lg bg-zinc-800" />
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div className="text-center py-12 text-text/50">Failed to load analytics</div>
|
||||
}
|
||||
|
||||
const benchmarkOption = {
|
||||
tooltip: { trigger: "axis" as const },
|
||||
xAxis: { type: "category" as const, data: data.benchmarkTimeline.map((d) => d.day), axisLabel: { fontSize: 10 } },
|
||||
yAxis: { type: "value" as const },
|
||||
series: [{ name: "Benchmarks", type: "line", data: data.benchmarkTimeline.map((d) => d.count), smooth: true, itemStyle: { color: "#eb3779" } }],
|
||||
grid: { left: 40, right: 16, top: 16, bottom: 24 },
|
||||
}
|
||||
|
||||
const userOption = {
|
||||
tooltip: { trigger: "axis" as const },
|
||||
xAxis: { type: "category" as const, data: data.userTimeline.map((d) => d.day), axisLabel: { fontSize: 10 } },
|
||||
yAxis: { type: "value" as const },
|
||||
series: [{ name: "Registrations", type: "line", data: data.userTimeline.map((d) => d.count), smooth: true, itemStyle: { color: "#a78bfa" }, areaStyle: { color: "rgba(167,139,250,0.1)" } }],
|
||||
grid: { left: 40, right: 16, top: 16, bottom: 24 },
|
||||
}
|
||||
|
||||
const deviceOption = {
|
||||
tooltip: { trigger: "axis" as const },
|
||||
xAxis: { type: "category" as const, data: data.deviceDistribution.map((d) => d.hardwareName), axisLabel: { fontSize: 10 } },
|
||||
yAxis: { type: "value" as const },
|
||||
series: [{ name: "Benchmarks", type: "bar", data: data.deviceDistribution.map((d) => d.count), itemStyle: { color: "#fb793c" } }],
|
||||
grid: { left: 40, right: 16, top: 16, bottom: 24 },
|
||||
}
|
||||
|
||||
const genreOption = {
|
||||
tooltip: { trigger: "axis" as const },
|
||||
xAxis: { type: "category" as const, data: data.genrePopularity.map((d) => d.genre), axisLabel: { fontSize: 10, rotate: 30 } },
|
||||
yAxis: { type: "value" as const },
|
||||
series: [{ name: "Benchmarks", type: "bar", data: data.genrePopularity.map((d) => d.count), itemStyle: { color: "#22c55e" } }],
|
||||
grid: { left: 40, right: 16, top: 16, bottom: 60 },
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Analytics</h2>
|
||||
<p className="text-sm text-zinc-400">90-day trends and distributions</p>
|
||||
</div>
|
||||
|
||||
{/* Benchmark Submissions Over Time */}
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<TrendingUp className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold">Benchmark Submissions (90d)</h3>
|
||||
</div>
|
||||
<ReactEChartsCore echarts={echarts} option={benchmarkOption} style={{ height: 240 }} />
|
||||
</div>
|
||||
|
||||
{/* User Registrations Over Time */}
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Users className="h-4 w-4 text-purple-400" />
|
||||
<h3 className="text-sm font-semibold">User Registrations (90d)</h3>
|
||||
</div>
|
||||
<ReactEChartsCore echarts={echarts} option={userOption} style={{ height: 240 }} />
|
||||
</div>
|
||||
|
||||
{/* Device Distribution + Genre Popularity (side by side) */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Monitor className="h-4 w-4 text-accent" />
|
||||
<h3 className="text-sm font-semibold">Device Distribution</h3>
|
||||
</div>
|
||||
<ReactEChartsCore echarts={echarts} option={deviceOption} style={{ height: 240 }} />
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Tag className="h-4 w-4 text-green-400" />
|
||||
<h3 className="text-sm font-semibold">Genre Popularity</h3>
|
||||
</div>
|
||||
<ReactEChartsCore echarts={echarts} option={genreOption} style={{ height: 240 }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
// Future: ECharts integration for trends over time
|
||||
// For now, the overview stats are sufficient
|
||||
export function DashboardCharts() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Gamepad2,
|
||||
BarChart3,
|
||||
Users,
|
||||
AlertTriangle,
|
||||
MessageSquarePlus,
|
||||
TrendingUp,
|
||||
Shield,
|
||||
ShieldX,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DashboardStats {
|
||||
overview: {
|
||||
totalGames: number;
|
||||
totalBenchmarks: number;
|
||||
totalUsers: number;
|
||||
pendingReports: number;
|
||||
pendingSuggestions: number;
|
||||
};
|
||||
recent: {
|
||||
benchmarksLast30Days: number;
|
||||
gamesLast30Days: number;
|
||||
};
|
||||
topContributors: Array<{
|
||||
userId: string;
|
||||
name: string;
|
||||
count: number;
|
||||
}>;
|
||||
syncHealth: Record<string, number>;
|
||||
gamesBySource: Record<string, number>;
|
||||
playabilityDistribution: Record<string, number>;
|
||||
}
|
||||
|
||||
export function DashboardOverview() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/dashboard/stats")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setStats(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="animate-pulse h-64 rounded-lg bg-zinc-800" />;
|
||||
}
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
label: "Total Games",
|
||||
value: stats.overview.totalGames,
|
||||
icon: Gamepad2,
|
||||
color: "text-blue-400",
|
||||
},
|
||||
{
|
||||
label: "Total Benchmarks",
|
||||
value: stats.overview.totalBenchmarks,
|
||||
icon: BarChart3,
|
||||
color: "text-green-400",
|
||||
},
|
||||
{
|
||||
label: "Total Users",
|
||||
value: stats.overview.totalUsers,
|
||||
icon: Users,
|
||||
color: "text-purple-400",
|
||||
},
|
||||
{
|
||||
label: "Pending Reports",
|
||||
value: stats.overview.pendingReports,
|
||||
icon: AlertTriangle,
|
||||
color: "text-red-400",
|
||||
highlight: stats.overview.pendingReports > 0,
|
||||
},
|
||||
{
|
||||
label: "Pending Suggestions",
|
||||
value: stats.overview.pendingSuggestions,
|
||||
icon: MessageSquarePlus,
|
||||
color: "text-amber-400",
|
||||
highlight: stats.overview.pendingSuggestions > 0,
|
||||
},
|
||||
{
|
||||
label: "Benchmarks (30d)",
|
||||
value: stats.recent.benchmarksLast30Days,
|
||||
icon: TrendingUp,
|
||||
color: "text-emerald-400",
|
||||
},
|
||||
{
|
||||
label: "New Games (30d)",
|
||||
value: stats.recent.gamesLast30Days,
|
||||
icon: Gamepad2,
|
||||
color: "text-cyan-400",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 lg:grid-cols-7">
|
||||
{statCards.map((card) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<div
|
||||
key={card.label}
|
||||
className={cn(
|
||||
"rounded-lg border bg-zinc-900 p-4",
|
||||
card.highlight ? "border-amber-500/30" : "border-zinc-800"
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Icon className={cn("h-4 w-4", card.color)} />
|
||||
<span className="text-xs text-zinc-500">{card.label}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{card.value.toLocaleString()}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Grid: Top Contributors + Playability Distribution */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Top Contributors */}
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<h3 className="mb-4 font-semibold">Top Contributors</h3>
|
||||
<div className="space-y-2">
|
||||
{stats.topContributors.slice(0, 5).map((contributor, i) => (
|
||||
<div
|
||||
key={contributor.userId}
|
||||
className="flex items-center justify-between rounded-md bg-zinc-800/50 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-zinc-500">#{i + 1}</span>
|
||||
<span className="text-sm">{contributor.name}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-zinc-400">
|
||||
{contributor.count} entries
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playability Distribution */}
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<h3 className="mb-4 font-semibold">Playability Distribution</h3>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(stats.playabilityDistribution).map(([status, count]) => {
|
||||
const config: Record<string, { label: string; color: string }> = {
|
||||
great: { label: "Plays Great", color: "bg-emerald-500" },
|
||||
playable: { label: "Playable", color: "bg-blue-500" },
|
||||
needs_tweaks: { label: "Needs Tweaks", color: "bg-amber-500" },
|
||||
unplayable: { label: "Unplayable", color: "bg-red-500" },
|
||||
unknown: { label: "Unknown", color: "bg-zinc-500" },
|
||||
};
|
||||
const c = config[status] ?? config.unknown;
|
||||
|
||||
return (
|
||||
<div key={status} className="flex items-center gap-3">
|
||||
<span className={cn("h-3 w-3 rounded-full", c.color)} />
|
||||
<span className="flex-1 text-sm">{c.label}</span>
|
||||
<span className="text-sm font-medium">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync Health */}
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<h3 className="mb-4 font-semibold">Steam Sync Health</h3>
|
||||
<div className="flex gap-4">
|
||||
{Object.entries(stats.syncHealth).map(([status, count]) => {
|
||||
const icons: Record<string, typeof Shield> = {
|
||||
synced: ShieldCheck,
|
||||
failed: ShieldX,
|
||||
pending: Shield,
|
||||
};
|
||||
const colors: Record<string, string> = {
|
||||
synced: "text-green-400",
|
||||
failed: "text-red-400",
|
||||
pending: "text-yellow-400",
|
||||
};
|
||||
const Icon = icons[status] ?? Shield;
|
||||
const color = colors[status] ?? "text-zinc-400";
|
||||
|
||||
return (
|
||||
<div key={status} className="flex items-center gap-2">
|
||||
<Icon className={cn("h-5 w-5", color)} />
|
||||
<div>
|
||||
<p className="text-lg font-bold">{count}</p>
|
||||
<p className="text-xs capitalize text-zinc-500">{status}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useSession } from "@/lib/auth-client"
|
||||
import {
|
||||
UsersIcon,
|
||||
CpuIcon,
|
||||
Gamepad2Icon,
|
||||
HardDriveIcon,
|
||||
MessageSquareIcon,
|
||||
MessageSquarePlusIcon,
|
||||
FlagIcon,
|
||||
BarChart3Icon,
|
||||
LayoutDashboardIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
type NavItem =
|
||||
| { type: "section"; label: string }
|
||||
| { type: "divider" }
|
||||
| { type: "link"; href: string; label: string; icon: React.ElementType; adminOnly?: boolean }
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ type: "section", label: "Overview" },
|
||||
{
|
||||
type: "link",
|
||||
href: "/manage",
|
||||
label: "Dashboard",
|
||||
icon: LayoutDashboardIcon,
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
href: "/manage/analytics",
|
||||
label: "Analytics",
|
||||
icon: BarChart3Icon,
|
||||
},
|
||||
{ type: "divider" },
|
||||
{ type: "section", label: "Management" },
|
||||
{ type: "link", href: "/manage/users", label: "Users", icon: UsersIcon, adminOnly: true },
|
||||
{
|
||||
type: "link",
|
||||
href: "/manage/hardware",
|
||||
label: "Hardware",
|
||||
icon: CpuIcon,
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
href: "/manage/storage",
|
||||
label: "Storage",
|
||||
icon: HardDriveIcon,
|
||||
adminOnly: true,
|
||||
},
|
||||
{ type: "link", href: "/manage/games", label: "Games", icon: Gamepad2Icon },
|
||||
{ type: "divider" },
|
||||
{ type: "section", label: "Moderation" },
|
||||
{ type: "link", href: "/manage/reports", label: "Reports", icon: FlagIcon },
|
||||
{
|
||||
type: "link",
|
||||
href: "/manage/suggestions",
|
||||
label: "Suggestions",
|
||||
icon: MessageSquarePlusIcon,
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
href: "/manage/benchmarks",
|
||||
label: "Benchmarks",
|
||||
icon: BarChart3Icon,
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
href: "/manage/comments",
|
||||
label: "Comments",
|
||||
icon: MessageSquareIcon,
|
||||
},
|
||||
]
|
||||
|
||||
export function ManageSidebar() {
|
||||
const pathname = usePathname()
|
||||
const { data: session } = useSession()
|
||||
const role = session?.user?.role ?? "user"
|
||||
const isAdmin = role === "admin"
|
||||
|
||||
return (
|
||||
<nav className='md:w-56 shrink-0'>
|
||||
<div className='hidden md:flex items-center gap-2 px-3 py-2 mb-2 text-sm font-semibold text-text/70'>
|
||||
<LayoutDashboardIcon className='h-4 w-4' />
|
||||
Manage Panel
|
||||
</div>
|
||||
|
||||
<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 md:pr-3'>
|
||||
{navItems.map((item, index) => {
|
||||
// Hide admin-only items for non-admins
|
||||
if (item.type === "link" && item.adminOnly && !isAdmin) return null
|
||||
|
||||
if (item.type === "section") {
|
||||
return (
|
||||
<div
|
||||
key={`section-${item.label}`}
|
||||
className='hidden md:block px-3 py-1 text-[10px] font-semibold uppercase tracking-widest text-text/30'
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.type === "divider") {
|
||||
return [
|
||||
<div
|
||||
key={`div-m-${index}`}
|
||||
className='w-px bg-border shrink-0 self-stretch md:hidden'
|
||||
/>,
|
||||
<div
|
||||
key={`div-d-${index}`}
|
||||
className='hidden md:block h-px bg-border'
|
||||
/>,
|
||||
]
|
||||
}
|
||||
|
||||
const isActive = pathname === item.href
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-2 px-4 md:px-3 py-2.5 text-sm font-medium transition-colors whitespace-nowrap rounded-lg ${
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text/50 hover:text-text/70 hover:bg-text/5"
|
||||
}`}
|
||||
>
|
||||
<item.icon className='h-4 w-4 shrink-0' />
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
"use client"
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import logo from "@/app/icon.png"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { useEffect, useSyncExternalStore, useRef, useState } from "react"
|
||||
import {
|
||||
Bookmark,
|
||||
CircleXIcon,
|
||||
Gamepad2Icon,
|
||||
LogOut,
|
||||
MenuIcon,
|
||||
ShieldIcon,
|
||||
User,
|
||||
XIcon,
|
||||
} from "lucide-react"
|
||||
import { routes, authRoutes } from "@/lib/routes"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { useDebounce } from "@/lib/hooks/useDebounce"
|
||||
import { authClient, useSession } from "@/lib/auth-client"
|
||||
|
||||
export default function Navbar() {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const isLanding = pathname === "/"
|
||||
const isAuthRoute =
|
||||
pathname.startsWith("/login") ||
|
||||
pathname.startsWith("/signup") ||
|
||||
pathname.startsWith("/forgot-password") ||
|
||||
pathname.startsWith("/reset-password")
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(
|
||||
() => searchParams.get("q") || "",
|
||||
)
|
||||
const debouncedQuery = useDebounce(searchQuery, 300)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const [forceFocusStyles, setForceFocusStyles] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// When syncing state from the URL (e.g. after navigating from the landing
|
||||
// page), skip the next URL-write cycle so the stale/empty debounced value
|
||||
// doesn't overwrite the URL params before it catches up.
|
||||
const skipNextUrlWrite = useRef(false)
|
||||
|
||||
const { data: session, isPending: isSessionLoading } = useSession()
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
|
||||
// Prevent hydration mismatch: useSession resolves differently on
|
||||
// server (isPending=true) vs client. We delay rendering the
|
||||
// auth-dependent UI until after the first client paint.
|
||||
// useSyncExternalStore avoids the React lint warning about setState in effects.
|
||||
const subscribe = () => () => {}
|
||||
const mounted = useSyncExternalStore(subscribe, () => true, () => false)
|
||||
const showAuth = mounted && !isSessionLoading
|
||||
|
||||
// Sync search query with URL ?q= param
|
||||
const searchQueryRef = useRef(searchQuery)
|
||||
useEffect(() => {
|
||||
searchQueryRef.current = searchQuery
|
||||
}, [searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
const q = searchParams.get("q") || ""
|
||||
if (q === searchQueryRef.current) return
|
||||
// If the URL has a different value than our state, we're syncing after
|
||||
// a navigation — skip the next URL-write to avoid clearing the param
|
||||
skipNextUrlWrite.current = true
|
||||
const id = setTimeout(() => setSearchQuery(q), 0)
|
||||
return () => clearTimeout(id)
|
||||
}, [searchParams])
|
||||
|
||||
// Update URL when debounced query changes (skip if already matches)
|
||||
useEffect(() => {
|
||||
// Skip one cycle after syncing from URL so the stale/empty debounced
|
||||
// value doesn't overwrite the URL params before it catches up
|
||||
if (skipNextUrlWrite.current) {
|
||||
skipNextUrlWrite.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (isLanding) return
|
||||
const currentQ = searchParams.get("q") || ""
|
||||
if (debouncedQuery === currentQ) return
|
||||
// Don't overwrite URL if the typed query hasn't debounced yet
|
||||
if (searchQuery !== debouncedQuery) return
|
||||
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (debouncedQuery) {
|
||||
params.set("q", debouncedQuery)
|
||||
} else {
|
||||
params.delete("q")
|
||||
}
|
||||
router.replace(`/search?${params.toString()}`, { scroll: false })
|
||||
}, [debouncedQuery, isLanding, router, searchParams, searchQuery])
|
||||
|
||||
// Maintain focus & styles when flying from landing page search
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isLanding &&
|
||||
searchQuery &&
|
||||
sessionStorage.getItem("focusSearch") === "true"
|
||||
) {
|
||||
sessionStorage.removeItem("focusSearch")
|
||||
const focusTimeout = setTimeout(() => setForceFocusStyles(true), 0)
|
||||
// Focus the input after the layout animation element mounts
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus()
|
||||
})
|
||||
const timer = setTimeout(() => setForceFocusStyles(false), 450)
|
||||
return () => {
|
||||
clearTimeout(focusTimeout)
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [isLanding, searchQuery])
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value)
|
||||
}
|
||||
|
||||
const handleSearchSubmit = () => {
|
||||
if (searchQuery.trim()) {
|
||||
router.push(`/search?q=${encodeURIComponent(searchQuery.trim())}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearchSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
const navbarRoutes = routes.filter(
|
||||
(route) => route.href !== "/" && route.href !== "/search",
|
||||
)
|
||||
|
||||
// Hide navbar on auth routes
|
||||
if (isAuthRoute) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
<motion.nav
|
||||
key='navbar'
|
||||
initial={{ opacity: 0, y: "-100%" }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: "-100%" }}
|
||||
className='w-full flex flex-row px-4 md:px-[10svw] py-2 items-center gap-4 md:gap-8 justify-between border-b border-border sticky top-0 z-50 backdrop-blur-sm bg-background/80'
|
||||
>
|
||||
<Link
|
||||
href='/'
|
||||
className='flex flex-row gap-2 items-center font-bold text-lg py-1 shrink-0'
|
||||
>
|
||||
<Image
|
||||
src={logo}
|
||||
alt='DeckyVault Logo'
|
||||
className='h-6 my-1 w-auto'
|
||||
loading='eager'
|
||||
/>
|
||||
{!isLanding && (
|
||||
<motion.span className='hidden md:inline-block'>
|
||||
DeckyVault
|
||||
</motion.span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<AnimatePresence>
|
||||
{!isLanding && (
|
||||
<motion.label
|
||||
key='search-bar'
|
||||
layoutId='search-bar'
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className={`flex flex-row items-center gap-2 flex-1 bg-text/5 px-2 py-1 rounded-md border placeholder:text-text/60 group transition-colors cursor-text ${
|
||||
isFocused || forceFocusStyles
|
||||
? "border-primary/80 ring-2 ring-primary/50 ring-offset-2 ring-offset-background"
|
||||
: "border-border hover:border-border-active"
|
||||
}`}
|
||||
>
|
||||
<Gamepad2Icon
|
||||
className={`h-4 w-4 transition-colors shrink-0 ${
|
||||
isFocused || forceFocusStyles
|
||||
? "stroke-accent"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type='text'
|
||||
placeholder='search by game or appid...'
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
className='flex-1 outline-none bg-transparent text-sm min-w-0'
|
||||
/>
|
||||
<motion.button>
|
||||
<CircleXIcon
|
||||
onClick={() => {
|
||||
setSearchQuery("")
|
||||
if (!isLanding) {
|
||||
const params =
|
||||
new URLSearchParams(
|
||||
searchParams.toString(),
|
||||
)
|
||||
params.delete("q")
|
||||
router.replace(
|
||||
`/search?${params.toString()}`,
|
||||
{
|
||||
scroll: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
}}
|
||||
className={`h-3 w-3 transition-color cursor-default hover:stroke-accent transition-all ${
|
||||
isFocused || forceFocusStyles
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</motion.button>
|
||||
</motion.label>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Desktop Navigation Links */}
|
||||
<ul className='hidden md:flex flex-row items-center gap-6 shrink-0'>
|
||||
{navbarRoutes.map((route) => (
|
||||
<Link
|
||||
key={route.href}
|
||||
href={route.href}
|
||||
title={`Navigate to ${route.title}`}
|
||||
className='text-sm font-medium hover:text-primary transition-colors uppercase cursor-pointer'
|
||||
>
|
||||
{route.title}
|
||||
</Link>
|
||||
))}
|
||||
<AnimatePresence
|
||||
mode='popLayout'
|
||||
initial={false}
|
||||
>
|
||||
{!showAuth ? (
|
||||
<motion.li
|
||||
key='loading-placeholder'
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className='w-20 h-8 rounded-lg bg-white/3 animate-pulse'
|
||||
/>
|
||||
) : session ? (
|
||||
<motion.li
|
||||
key='profile-menu'
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className='relative'
|
||||
>
|
||||
<button
|
||||
onClick={() =>
|
||||
setUserMenuOpen(!userMenuOpen)
|
||||
}
|
||||
className='text-sm font-medium hover:text-primary transition-colors uppercase cursor-pointer'
|
||||
>
|
||||
Profile
|
||||
</button>
|
||||
{userMenuOpen && (
|
||||
<>
|
||||
<div
|
||||
className='fixed inset-0 z-40'
|
||||
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,
|
||||
)
|
||||
}
|
||||
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.title}
|
||||
</Link>
|
||||
))}
|
||||
{session.user.role === "admin" && (
|
||||
<>
|
||||
<div className='my-1 border-t border-white/10' />
|
||||
<Link
|
||||
href="/manage"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-primary/80 hover:text-primary hover:bg-primary/5 transition-colors cursor-pointer"
|
||||
>
|
||||
<ShieldIcon className="h-4 w-4" />
|
||||
Manage
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<div className='my-1 border-t border-white/10' />
|
||||
<button
|
||||
onClick={async () => {
|
||||
setUserMenuOpen(false)
|
||||
await authClient.signOut()
|
||||
}}
|
||||
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'
|
||||
>
|
||||
<LogOut className='h-4 w-4' />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.li>
|
||||
) : (
|
||||
<motion.li
|
||||
key='sign-in-link'
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<Link
|
||||
href='/login'
|
||||
className='text-sm font-medium hover:text-primary transition-colors uppercase cursor-pointer'
|
||||
>
|
||||
login
|
||||
</Link>
|
||||
</motion.li>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</ul>
|
||||
|
||||
{/* Mobile Hamburger Button */}
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
className='flex md:hidden flex-row items-center justify-center p-2 -mr-2 rounded-md hover:bg-text/5 transition-colors cursor-pointer'
|
||||
aria-label='Open menu'
|
||||
>
|
||||
<MenuIcon className='h-5 w-5' />
|
||||
</button>
|
||||
</motion.nav>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Mobile Sidebar Overlay */}
|
||||
<AnimatePresence>
|
||||
{mobileMenuOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className='fixed inset-0 bg-black/50 z-60 md:hidden'
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
<motion.aside
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
damping: 25,
|
||||
stiffness: 250,
|
||||
}}
|
||||
className='fixed top-0 right-0 bottom-0 w-64 bg-background border-l border-border z-70 md:hidden flex flex-col'
|
||||
>
|
||||
<div className='flex flex-row items-center justify-between px-4 py-2 border-b border-border'>
|
||||
<span className='font-bold text-lg'>Menu</span>
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className='p-2 -mr-2 rounded-md hover:bg-text/5 transition-colors cursor-pointer'
|
||||
aria-label='Close menu'
|
||||
>
|
||||
<XIcon className='h-5 w-5' />
|
||||
</button>
|
||||
</div>
|
||||
<nav className='flex flex-col p-4 gap-2'>
|
||||
{routes.map((route) => (
|
||||
<Link
|
||||
key={route.href}
|
||||
href={route.href}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={`text-sm font-medium hover:text-primary transition-colors uppercase px-3 py-2 rounded-md hover:bg-text/5 cursor-pointer ${
|
||||
pathname === route.href
|
||||
? "text-primary bg-text/5"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{route.title}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
{/* Mobile Auth Controls */}
|
||||
<div className='mt-auto p-4 border-t border-text/8'>
|
||||
{!showAuth ? (
|
||||
<div className='w-full h-10 rounded-lg bg-text/3 animate-pulse' />
|
||||
) : session ? (
|
||||
<div className='space-y-2'>
|
||||
{authRoutes.map((route) => (
|
||||
<Link
|
||||
key={route.href}
|
||||
href={route.href}
|
||||
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.title}
|
||||
</Link>
|
||||
))}
|
||||
{session.user.role === "admin" && (
|
||||
<Link
|
||||
href="/manage"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-primary/80 hover:text-primary hover:bg-primary/5 transition-colors cursor-pointer"
|
||||
>
|
||||
<ShieldIcon className="h-4 w-4" />
|
||||
Manage
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={async () => {
|
||||
setMobileMenuOpen(false)
|
||||
await authClient.signOut()
|
||||
}}
|
||||
className='w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg border border-white/10 text-sm text-text/70 hover:text-text hover:bg-text/5 transition-colors cursor-pointer'
|
||||
>
|
||||
<LogOut className='h-4 w-4' />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-2'>
|
||||
<Link
|
||||
href='/login'
|
||||
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
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.aside>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
CircleCheck,
|
||||
CircleDot,
|
||||
Wrench,
|
||||
CircleX,
|
||||
HelpCircle,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PlayabilityBadgeProps {
|
||||
status: "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown" | null;
|
||||
compact?: boolean;
|
||||
showLabel?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
great: {
|
||||
icon: CircleCheck,
|
||||
label: "Plays Great",
|
||||
color: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
||||
dotColor: "bg-emerald-400",
|
||||
},
|
||||
playable: {
|
||||
icon: CircleDot,
|
||||
label: "Playable",
|
||||
color: "bg-blue-500/15 text-blue-400 border-blue-500/30",
|
||||
dotColor: "bg-blue-400",
|
||||
},
|
||||
needs_tweaks: {
|
||||
icon: Wrench,
|
||||
label: "Needs Tweaks",
|
||||
color: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
||||
dotColor: "bg-amber-400",
|
||||
},
|
||||
unplayable: {
|
||||
icon: CircleX,
|
||||
label: "Unplayable",
|
||||
color: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
dotColor: "bg-red-400",
|
||||
},
|
||||
unknown: {
|
||||
icon: HelpCircle,
|
||||
label: "Unknown",
|
||||
color: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
|
||||
dotColor: "bg-zinc-400",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function PlayabilityBadge({
|
||||
status,
|
||||
compact = false,
|
||||
showLabel = true,
|
||||
className,
|
||||
}: PlayabilityBadgeProps) {
|
||||
if (!status) return null;
|
||||
|
||||
const config = statusConfig[status];
|
||||
const Icon = config.icon;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||
config.color,
|
||||
className
|
||||
)}
|
||||
title={config.label}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{showLabel && config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm font-medium",
|
||||
config.color,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{config.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { Cpu, Clock, TrendingUp, CheckCircle } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import type { ContributionEntry } from "@/types/api"
|
||||
|
||||
interface ContributionListProps {
|
||||
entries: ContributionEntry[]
|
||||
showViewAll?: boolean
|
||||
totalCount?: number
|
||||
}
|
||||
|
||||
export function ContributionList({ entries, showViewAll = false, totalCount }: ContributionListProps) {
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-text/40">
|
||||
<TrendingUp className="h-8 w-8 mx-auto mb-2" />
|
||||
<p>No contributions yet</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{showViewAll && totalCount && totalCount > entries.length && (
|
||||
<div className="flex justify-end">
|
||||
<span className="text-xs text-text/40">
|
||||
Showing {entries.length} of {totalCount}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{entries.map((entry, i) => (
|
||||
<motion.div
|
||||
key={entry.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
>
|
||||
<Link
|
||||
href={`/game/${entry.gameId}`}
|
||||
className="flex items-center gap-4 p-3 rounded-lg bg-text/5 border border-border hover:border-primary/30 transition-colors"
|
||||
>
|
||||
{entry.gameHeaderImage ? (
|
||||
<Image
|
||||
src={entry.gameHeaderImage}
|
||||
alt={entry.gameTitle}
|
||||
width={80}
|
||||
height={36}
|
||||
unoptimized
|
||||
className="rounded h-9 w-20 object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-9 w-20 rounded bg-text/10 shrink-0" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{entry.gameTitle}</p>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-text/50">
|
||||
<span className="flex items-center gap-1">
|
||||
<Cpu className="h-3 w-3" />
|
||||
{entry.hardwareName}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{new Date(entry.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-sm font-bold text-primary">
|
||||
{entry.fpsAvg.toFixed(0)} FPS
|
||||
</div>
|
||||
{entry.verifiedAt && (
|
||||
<div className="flex items-center gap-1 text-xs text-green-400 mt-0.5">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Verified
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
import { Shield, Crown, CheckCircle, Mail } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
interface ProfileHeaderProps {
|
||||
name: string
|
||||
email?: string
|
||||
role: string | null
|
||||
verified: boolean
|
||||
createdAt: string
|
||||
image?: string | null
|
||||
}
|
||||
|
||||
const roleConfig: Record<string, { label: string; color: string; icon: typeof Crown }> = {
|
||||
admin: { label: "Admin", color: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30", icon: Crown },
|
||||
contributor: { label: "Contributor", color: "bg-blue-500/20 text-blue-400 border-blue-500/30", icon: Shield },
|
||||
user: { label: "Member", color: "bg-text/10 text-text/60 border-text/20", icon: Shield },
|
||||
}
|
||||
|
||||
function isR2Avatar(url: string): boolean {
|
||||
return url.includes(".r2.dev")
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name.charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
export function ProfileHeader({ name, email, role, verified, createdAt, image }: ProfileHeaderProps) {
|
||||
const config = roleConfig[role || "user"] || roleConfig.user
|
||||
const RoleIcon = config.icon
|
||||
|
||||
const joinDate = new Date(createdAt).toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{image ? (
|
||||
<div className={`shrink-0 w-16 h-16 rounded-full overflow-hidden ${isR2Avatar(image) ? "ring-2 ring-primary/30 ring-offset-2 ring-offset-background" : ""}`}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={image} alt={`${name}'s profile photo`} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="shrink-0 w-16 h-16 rounded-full overflow-hidden flex items-center justify-center bg-primary/10 text-primary text-xl font-bold">
|
||||
{getInitials(name)}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-2xl font-bold">{name}</h1>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${config.color}`}>
|
||||
<RoleIcon className="h-3 w-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
{verified && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/20 text-green-400 border border-green-500/30">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Verified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-text/50">
|
||||
<span>Member since {joinDate}</span>
|
||||
{email && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Mail className="h-3 w-3" />
|
||||
{email}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { Camera, Trash2, Loader2, Upload } from "lucide-react"
|
||||
|
||||
interface ProfilePhotoUploadProps {
|
||||
currentImage: string | null
|
||||
userName: string
|
||||
userId: string
|
||||
onImageChange?: (url: string | null) => void
|
||||
}
|
||||
|
||||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"]
|
||||
const MAX_SIZE_MB = 5
|
||||
const MAX_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024
|
||||
|
||||
function isR2Avatar(url: string): boolean {
|
||||
return url.includes(".r2.dev")
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name.charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
export function ProfilePhotoUpload({ currentImage, userName, onImageChange }: ProfilePhotoUploadProps) {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(currentImage)
|
||||
const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success" | "error">("idle")
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const tempUrlRef = useRef<string | null>(null)
|
||||
|
||||
const cleanupTempUrl = () => {
|
||||
if (tempUrlRef.current) {
|
||||
URL.revokeObjectURL(tempUrlRef.current)
|
||||
tempUrlRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return cleanupTempUrl
|
||||
}, [])
|
||||
|
||||
// Sync previewUrl when parent updates currentImage externally
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPreviewUrl(currentImage)
|
||||
}, [currentImage])
|
||||
|
||||
const validateFile = (file: File): string | null => {
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return "Only JPEG, PNG, and WebP images are allowed."
|
||||
}
|
||||
if (file.size > MAX_SIZE_BYTES) {
|
||||
return "File must be under 5MB."
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const handleFile = useCallback(async (file: File) => {
|
||||
const validationError = validateFile(file)
|
||||
if (validationError) {
|
||||
setErrorMessage(validationError)
|
||||
setUploadState("error")
|
||||
return
|
||||
}
|
||||
|
||||
setErrorMessage(null)
|
||||
setUploadState("uploading")
|
||||
cleanupTempUrl()
|
||||
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
tempUrlRef.current = objectUrl
|
||||
setPreviewUrl(objectUrl)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("photo", file)
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/user/profile-photo", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.error || "Upload failed")
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
if (data.url) {
|
||||
setPreviewUrl(data.url)
|
||||
cleanupTempUrl()
|
||||
onImageChange?.(data.url)
|
||||
}
|
||||
setUploadState("success")
|
||||
} catch (err) {
|
||||
setErrorMessage(err instanceof Error ? err.message : "Upload failed")
|
||||
setUploadState("error")
|
||||
setPreviewUrl(currentImage)
|
||||
cleanupTempUrl()
|
||||
}
|
||||
}, [currentImage, onImageChange])
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFile(file)
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) handleFile(file)
|
||||
}, [handleFile])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!previewUrl || !isR2Avatar(previewUrl)) return
|
||||
|
||||
setUploadState("uploading")
|
||||
try {
|
||||
const res = await fetch("/api/user/profile-photo", {
|
||||
method: "DELETE",
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.error || "Delete failed")
|
||||
}
|
||||
|
||||
setPreviewUrl(null)
|
||||
setUploadState("idle")
|
||||
setErrorMessage(null)
|
||||
onImageChange?.(null)
|
||||
} catch (err) {
|
||||
setErrorMessage(err instanceof Error ? err.message : "Delete failed")
|
||||
setUploadState("error")
|
||||
}
|
||||
}
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const showDelete = previewUrl ? isR2Avatar(previewUrl) : false
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-text/[0.03] p-5">
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-text/60 mb-4">Profile Photo</h3>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center gap-5">
|
||||
{/* Avatar area */}
|
||||
<div
|
||||
className={`relative shrink-0 rounded-full overflow-hidden w-[128px] h-[128px] cursor-pointer transition-all ${
|
||||
isDragging ? "ring-2 ring-primary ring-offset-2 ring-offset-background" : ""
|
||||
}`}
|
||||
onClick={triggerFileInput}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Upload profile photo"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
triggerFileInput()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{previewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={`${userName}'s profile photo`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-primary/10 text-primary text-4xl font-bold select-none">
|
||||
{getInitials(userName)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload overlay */}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 hover:opacity-100 transition-opacity">
|
||||
<Camera className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
|
||||
{uploadState === "uploading" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<Loader2 className="h-8 w-8 text-white animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={triggerFileInput}
|
||||
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 shrink-0 cursor-pointer"
|
||||
disabled={uploadState === "uploading"}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload Photo
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showDelete && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
onClick={handleDelete}
|
||||
disabled={uploadState === "uploading"}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-lg border border-border bg-text/5 text-text/60 hover:text-red-400 hover:border-red-400/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
aria-label="Delete profile photo"
|
||||
title="Delete profile photo"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text/50">
|
||||
JPEG, PNG, or WebP. Max 5MB.
|
||||
</p>
|
||||
|
||||
<AnimatePresence>
|
||||
{errorMessage && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
className="text-sm text-red-400"
|
||||
>
|
||||
{errorMessage}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{uploadState === "success" && !errorMessage && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-sm text-green-400"
|
||||
>
|
||||
Photo updated successfully.
|
||||
</motion.p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={handleInputChange}
|
||||
className="hidden"
|
||||
aria-hidden="true"
|
||||
id="profile-photo-input"
|
||||
/>
|
||||
<label htmlFor="profile-photo-input" className="sr-only">
|
||||
Choose profile photo
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Loader2, Link as LinkIcon, Unlink, Shield } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { FaGoogle, FaDiscord } from "react-icons/fa"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
interface LinkedAccount {
|
||||
id: string
|
||||
providerId: string
|
||||
accountId: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
userId: string
|
||||
scopes: string[]
|
||||
}
|
||||
|
||||
interface AuthMethods {
|
||||
hasPassword: boolean
|
||||
passkeyCount: number
|
||||
oauthProviders: { providerId: string; id: string }[]
|
||||
totalAuthMethods: number
|
||||
}
|
||||
|
||||
const providerConfig: Record<string, { name: string; icon: React.ComponentType<{ className?: string }> | null; color: string; bgColor: string }> = {
|
||||
google: { name: "Google", icon: FaGoogle, color: "text-red-400", bgColor: "bg-red-500/10 border-red-500/20" },
|
||||
discord: { name: "Discord", icon: FaDiscord, color: "text-indigo-400", bgColor: "bg-indigo-500/10 border-indigo-500/20" },
|
||||
credential: { name: "Password", icon: null, color: "text-text/60", bgColor: "bg-text/5 border-border" },
|
||||
}
|
||||
|
||||
interface SettingsAccountsTabProps {
|
||||
authMethods: AuthMethods | null
|
||||
isLoadingAuthMethods: boolean
|
||||
onRefreshAuthMethods: () => Promise<void>
|
||||
}
|
||||
|
||||
export function SettingsAccountsTab({ authMethods, isLoadingAuthMethods, onRefreshAuthMethods }: SettingsAccountsTabProps) {
|
||||
const [accounts, setAccounts] = useState<LinkedAccount[]>([])
|
||||
const [isLoadingAccounts, setIsLoadingAccounts] = useState(true)
|
||||
const [accountsError, setAccountsError] = useState<string | null>(null)
|
||||
const [unlinking, setUnlinking] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(() => {
|
||||
// Check for OAuth callback success on initial render
|
||||
if (typeof window !== "undefined") {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.has("linked")) {
|
||||
window.history.replaceState({}, "", window.location.pathname)
|
||||
return { type: "success", text: "Account linked successfully!" }
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
}, [])
|
||||
|
||||
async function fetchAccounts() {
|
||||
try {
|
||||
const { data, error } = await authClient.listAccounts()
|
||||
if (error || !data) {
|
||||
setAccountsError("Failed to load linked accounts")
|
||||
setAccounts([])
|
||||
} else {
|
||||
setAccounts(Array.isArray(data) ? data : [])
|
||||
setAccountsError(null)
|
||||
}
|
||||
} catch {
|
||||
setAccountsError("Failed to load linked accounts. Please try again.")
|
||||
setAccounts([])
|
||||
} finally {
|
||||
setIsLoadingAccounts(false)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshAccounts = async () => {
|
||||
try {
|
||||
const { data } = await authClient.listAccounts()
|
||||
if (data) setAccounts(Array.isArray(data) ? data : accounts)
|
||||
} catch {
|
||||
// silently fail on refresh
|
||||
}
|
||||
}
|
||||
|
||||
const refreshData = async () => {
|
||||
await Promise.all([refreshAccounts(), onRefreshAuthMethods()])
|
||||
}
|
||||
|
||||
const handleLink = async (provider: "google" | "discord") => {
|
||||
setMessage(null)
|
||||
try {
|
||||
const { data, error } = await authClient.linkSocial({
|
||||
provider,
|
||||
callbackURL: window.location.origin + "/profile?linked=true",
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setMessage({ type: "error", text: error.message || "Failed to link account" })
|
||||
return
|
||||
}
|
||||
|
||||
if (data?.url) {
|
||||
window.location.assign(data.url)
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Failed to initiate account linking" })
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnlink = async (providerId: string) => {
|
||||
if (authMethods && authMethods.totalAuthMethods <= 1) return
|
||||
|
||||
setUnlinking(providerId)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const { error } = await authClient.unlinkAccount({
|
||||
providerId,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setMessage({ type: "error", text: error.message || "Failed to unlink account" })
|
||||
} else {
|
||||
setMessage({ type: "success", text: `${providerConfig[providerId]?.name || providerId} account unlinked` })
|
||||
await refreshData()
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Failed to unlink account" })
|
||||
} finally {
|
||||
setUnlinking(null)
|
||||
}
|
||||
}
|
||||
|
||||
const linkedProviders = new Set(accounts.map((a) => a.providerId))
|
||||
const availableProviders = ["google", "discord"].filter((p) => !linkedProviders.has(p))
|
||||
const isOnlyAuthMethod = authMethods ? authMethods.totalAuthMethods <= 1 : true
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Warning if only one auth method */}
|
||||
{isOnlyAuthMethod && !isLoadingAuthMethods && (
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl border border-yellow-500/30 bg-yellow-500/5">
|
||||
<Shield className="h-5 w-5 text-yellow-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-yellow-400 font-medium">Single authentication method</p>
|
||||
<p className="text-xs text-text/50 mt-1">
|
||||
You only have one way to sign in. Consider linking a social account or adding a passkey.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<p className={`text-sm ${message.type === "success" ? "text-green-400" : "text-red-400"}`}>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Linked Accounts */}
|
||||
<div className="rounded-xl border border-border bg-text/[0.03] p-5">
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-text/60 mb-4 flex items-center gap-2">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
Linked Accounts
|
||||
</h3>
|
||||
|
||||
{accountsError ? (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-red-400 mb-2">{accountsError}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setAccountsError(null)
|
||||
setIsLoadingAccounts(true)
|
||||
fetchAccounts()
|
||||
}}
|
||||
className="text-sm text-primary hover:underline cursor-pointer"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : isLoadingAccounts ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-text/40" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Password status */}
|
||||
{authMethods && (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-text/5 border border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-text/10 flex items-center justify-center text-text/40 text-sm font-bold">
|
||||
●
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Password</p>
|
||||
<p className="text-xs text-text/40">
|
||||
{authMethods.hasPassword ? "Configured" : "Not set"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${authMethods.hasPassword ? "bg-green-500/10 text-green-400 border border-green-500/20" : "bg-text/10 text-text/40 border border-border"}`}>
|
||||
{authMethods.hasPassword ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OAuth accounts */}
|
||||
{accounts.map((account) => {
|
||||
const config = providerConfig[account.providerId] || {
|
||||
name: account.providerId,
|
||||
icon: null,
|
||||
color: "text-text/60",
|
||||
bgColor: "bg-text/5 border-border",
|
||||
}
|
||||
const canUnlink = !isOnlyAuthMethod
|
||||
|
||||
return (
|
||||
<div key={account.id} className="flex items-center justify-between p-3 rounded-lg bg-text/5 border border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`h-8 w-8 rounded-lg flex items-center justify-center ${config.bgColor} border`}>
|
||||
{config.icon ? <config.icon className={`h-4 w-4 ${config.color}`} /> : <LinkIcon className={`h-4 w-4 ${config.color}`} />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{config.name}</p>
|
||||
<p className="text-xs text-text/40">Linked</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleUnlink(account.providerId)}
|
||||
disabled={!canUnlink || unlinking === account.providerId}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-red-500/20 text-xs font-medium text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{unlinking === account.providerId ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Unlink className="h-3 w-3" />
|
||||
)}
|
||||
Unlink
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Passkeys count */}
|
||||
{authMethods && authMethods.passkeyCount > 0 && (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-text/5 border border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-text/10 flex items-center justify-center text-text/40">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Passkeys</p>
|
||||
<p className="text-xs text-text/40">{authMethods.passkeyCount} registered</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-green-500/10 text-green-400 border border-green-500/20">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Link New Account */}
|
||||
{availableProviders.length > 0 && (
|
||||
<div className="rounded-xl border border-border bg-text/[0.03] p-5">
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-text/60 mb-4">Link a Social Account</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{availableProviders.map((provider) => {
|
||||
const config = providerConfig[provider]
|
||||
if (!config) return null
|
||||
return (
|
||||
<button
|
||||
key={provider}
|
||||
onClick={() => handleLink(provider as "google" | "discord")}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg border ${config.bgColor} ${config.color} text-sm font-medium hover:opacity-80 transition-opacity cursor-pointer`}
|
||||
>
|
||||
{config.icon && <config.icon className="h-4 w-4" />}
|
||||
Link {config.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import {
|
||||
Loader2,
|
||||
Key,
|
||||
Plus,
|
||||
Trash2,
|
||||
Copy,
|
||||
Check,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
} from "lucide-react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
|
||||
// Raw API key type from Better Auth (dates are Date objects from the API)
|
||||
interface RawApiKey {
|
||||
id: string
|
||||
name: string | null
|
||||
start: string | null
|
||||
prefix: string | null
|
||||
enabled: boolean
|
||||
expiresAt: Date | null
|
||||
lastRequest: Date | null
|
||||
remaining: number | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
referenceId: string
|
||||
metadata: Record<string, unknown> | null
|
||||
permissions: Record<string, string[]> | null
|
||||
configId: string
|
||||
refillInterval: number | null
|
||||
refillAmount: number | null
|
||||
lastRefillAt: Date | null
|
||||
rateLimitEnabled: boolean
|
||||
rateLimitTimeWindow: number | null
|
||||
rateLimitMax: number | null
|
||||
requestCount: number
|
||||
}
|
||||
|
||||
interface CreatedApiKey extends RawApiKey {
|
||||
key: string // Only returned on creation
|
||||
}
|
||||
|
||||
export function SettingsApiKeysTab() {
|
||||
const [apiKeys, setApiKeys] = useState<RawApiKey[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Create form state
|
||||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||
const [newKeyName, setNewKeyName] = useState("")
|
||||
const [newKeyExpiresIn, setNewKeyExpiresIn] = useState("")
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [createdKey, setCreatedKey] = useState<CreatedApiKey | null>(null)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
|
||||
// Delete state
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<{
|
||||
type: "success" | "error"
|
||||
text: string
|
||||
} | null>(null)
|
||||
|
||||
// Copy state
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [showKey, setShowKey] = useState(false)
|
||||
|
||||
const fetchApiKeys = useCallback(async () => {
|
||||
try {
|
||||
const { data, error } = await authClient.apiKey.list({})
|
||||
if (error) {
|
||||
setError(error.message || "Failed to load API keys")
|
||||
setApiKeys([])
|
||||
} else {
|
||||
setApiKeys((data?.apiKeys ?? []) as RawApiKey[])
|
||||
setError(null)
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load API keys. Please try again.")
|
||||
setApiKeys([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchApiKeys()
|
||||
}, [fetchApiKeys])
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setCreateError(null)
|
||||
|
||||
if (!newKeyName.trim()) {
|
||||
setCreateError("Name is required")
|
||||
return
|
||||
}
|
||||
|
||||
setIsCreating(true)
|
||||
try {
|
||||
const expiresIn = newKeyExpiresIn
|
||||
? parseInt(newKeyExpiresIn) * 24 * 60 * 60
|
||||
: undefined
|
||||
|
||||
const { data, error } = await authClient.apiKey.create({
|
||||
name: newKeyName.trim(),
|
||||
expiresIn,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setCreateError(error.message || "Failed to create API key")
|
||||
} else if (data) {
|
||||
setCreatedKey(data as unknown as CreatedApiKey)
|
||||
await fetchApiKeys()
|
||||
setNewKeyName("")
|
||||
setNewKeyExpiresIn("")
|
||||
setShowCreateForm(false)
|
||||
}
|
||||
} catch {
|
||||
setCreateError("An unexpected error occurred")
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (keyId: string) => {
|
||||
setDeletingId(keyId)
|
||||
setMessage(null)
|
||||
try {
|
||||
const { error } = await authClient.apiKey.delete({ keyId })
|
||||
if (error) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: error.message || "Failed to delete API key",
|
||||
})
|
||||
} else {
|
||||
setMessage({ type: "success", text: "API key deleted" })
|
||||
await fetchApiKeys()
|
||||
}
|
||||
} catch {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: "Failed to delete API key. Please try again.",
|
||||
})
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date: Date | string | null): string => {
|
||||
if (!date) return "Never"
|
||||
try {
|
||||
const d = date instanceof Date ? date : new Date(date)
|
||||
return d.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
} catch {
|
||||
return String(date)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyKey = async () => {
|
||||
if (createdKey?.key) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdKey.key)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const textarea = document.createElement("textarea")
|
||||
textarea.value = createdKey.key
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand("copy")
|
||||
document.body.removeChild(textarea)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Show the created key modal
|
||||
if (createdKey) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className='space-y-6'
|
||||
>
|
||||
<div className='rounded-xl border border-green-500/30 bg-green-500/10 p-6'>
|
||||
<div className='flex items-center gap-2 mb-4'>
|
||||
<Check className='h-5 w-5 text-green-400' />
|
||||
<h3 className='text-sm font-medium text-green-400'>
|
||||
API Key Created
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className='text-sm text-text/70 mb-3'>
|
||||
Copy your API key now. You won't be able to see it again.
|
||||
</p>
|
||||
|
||||
<div className='relative mb-4'>
|
||||
<div className='w-full px-4 py-3 rounded-lg bg-text/5 border border-border font-mono text-sm break-all pr-20'>
|
||||
{showKey
|
||||
? createdKey.key
|
||||
: `${createdKey.key.substring(0, 12)}${"•".repeat(Math.min(createdKey.key.length - 12, 20))}`}
|
||||
</div>
|
||||
<div className='absolute right-2 top-1/2 -translate-y-1/2 flex gap-1'>
|
||||
<button
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
className='p-1.5 rounded hover:bg-text/5 text-text/40 hover:text-text/80 transition-colors cursor-pointer'
|
||||
title={showKey ? "Hide key" : "Show key"}
|
||||
>
|
||||
{showKey ? (
|
||||
<EyeOff className='h-4 w-4' />
|
||||
) : (
|
||||
<Eye className='h-4 w-4' />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopyKey}
|
||||
className='p-1.5 rounded hover:bg-text/5 text-text/40 hover:text-text/80 transition-colors cursor-pointer'
|
||||
title='Copy to clipboard'
|
||||
>
|
||||
{copied ? (
|
||||
<Check className='h-4 w-4 text-green-400' />
|
||||
) : (
|
||||
<Copy className='h-4 w-4' />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createdKey.name && (
|
||||
<p className='text-xs text-text/50'>
|
||||
Name: <span className='text-text/70'>{createdKey.name}</span>
|
||||
</p>
|
||||
)}
|
||||
{createdKey.expiresAt && (
|
||||
<p className='text-xs text-text/50 mt-1'>
|
||||
Expires:{" "}
|
||||
<span className='text-text/70'>
|
||||
{formatDate(createdKey.expiresAt)}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setCreatedKey(null)}
|
||||
className='mt-4 px-4 py-2 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 transition-colors cursor-pointer'
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className='space-y-6'
|
||||
>
|
||||
{/* Header */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<h3 className='text-sm font-medium uppercase tracking-wider text-text/60 flex items-center gap-2'>
|
||||
<Key className='h-4 w-4' />
|
||||
API Keys
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateForm(!showCreateForm)
|
||||
setCreateError(null)
|
||||
}}
|
||||
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 cursor-pointer'
|
||||
>
|
||||
<Plus className='h-3.5 w-3.5' />
|
||||
{showCreateForm ? "Cancel" : "Create Key"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{message && (
|
||||
<div
|
||||
className={`text-sm px-4 py-2 rounded-lg ${
|
||||
message.type === "success"
|
||||
? "bg-green-500/10 text-green-400 border border-green-500/20"
|
||||
: "bg-red-500/10 text-red-400 border border-red-500/20"
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Form */}
|
||||
<AnimatePresence>
|
||||
{showCreateForm && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className='rounded-xl border border-border bg-text/3 p-5 overflow-hidden'
|
||||
>
|
||||
<form onSubmit={handleCreate} className='space-y-3'>
|
||||
<div>
|
||||
<label className='block text-xs text-text/50 mb-1'>
|
||||
Key Name <span className='text-red-400'>*</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(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='e.g. Decky Loader Plugin'
|
||||
maxLength={32}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-xs text-text/50 mb-1'>
|
||||
Expires In (days){" "}
|
||||
<span className='text-text/40'>(optional — leave empty for no expiry)</span>
|
||||
</label>
|
||||
<input
|
||||
type='number'
|
||||
value={newKeyExpiresIn}
|
||||
onChange={(e) => setNewKeyExpiresIn(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='Leave empty for no expiry'
|
||||
min={1}
|
||||
max={365}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{createError && (
|
||||
<p className='text-sm text-red-400 flex items-center gap-1'>
|
||||
<AlertCircle className='h-3.5 w-3.5' />
|
||||
{createError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type='submit'
|
||||
disabled={isCreating}
|
||||
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 w-full sm:w-auto'
|
||||
>
|
||||
{isCreating ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<Key className='h-4 w-4' />
|
||||
)}
|
||||
Create API Key
|
||||
</button>
|
||||
</form>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Key List */}
|
||||
{error ? (
|
||||
<div className='text-center py-8'>
|
||||
<p className='text-sm text-red-400 mb-2'>{error}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
setIsLoading(true)
|
||||
fetchApiKeys()
|
||||
}}
|
||||
className='text-sm text-primary hover:underline cursor-pointer'
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className='flex items-center gap-2 text-sm text-text/50 py-8'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
Loading API keys...
|
||||
</div>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<div className='text-center py-8'>
|
||||
<Key className='h-8 w-8 text-text/20 mx-auto mb-3' />
|
||||
<p className='text-sm text-text/50'>
|
||||
No API keys created yet.
|
||||
</p>
|
||||
<p className='text-xs text-text/30 mt-1'>
|
||||
Create an API key to use with the Decky Loader plugin or other external tools.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
<AnimatePresence>
|
||||
{apiKeys.map((ak) => (
|
||||
<motion.div
|
||||
key={ak.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className='flex items-center justify-between gap-3 rounded-lg border border-border bg-text/2 p-4'
|
||||
>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Key className='h-4 w-4 text-text/40 shrink-0' />
|
||||
<p className='text-sm font-medium text-text truncate'>
|
||||
{ak.name || "Unnamed key"}
|
||||
</p>
|
||||
{!ak.enabled && (
|
||||
<span className='text-xs px-1.5 py-0.5 rounded bg-yellow-500/10 text-yellow-400 border border-yellow-500/20'>
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-x-3 gap-y-1 mt-1'>
|
||||
{ak.start && (
|
||||
<span className='text-xs font-mono text-text/40'>
|
||||
{ak.start}...
|
||||
</span>
|
||||
)}
|
||||
{ak.expiresAt && (
|
||||
<span className='text-xs text-text/40 flex items-center gap-1'>
|
||||
<Clock className='h-3 w-3' />
|
||||
Expires {formatDate(ak.expiresAt)}
|
||||
</span>
|
||||
)}
|
||||
{ak.lastRequest && (
|
||||
<span className='text-xs text-text/40'>
|
||||
Last used {formatDate(ak.lastRequest)}
|
||||
</span>
|
||||
)}
|
||||
<span className='text-xs text-text/30'>
|
||||
Created {formatDate(ak.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(ak.id)}
|
||||
disabled={deletingId === ak.id}
|
||||
className='p-2 rounded hover:bg-red-500/10 text-text/40 hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shrink-0 cursor-pointer'
|
||||
title='Delete API key'
|
||||
>
|
||||
{deletingId === ak.id ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<Trash2 className='h-4 w-4' />
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className='rounded-xl border border-border bg-text/2 p-4'>
|
||||
<p className='text-xs text-text/40'>
|
||||
<strong className='text-text/60'>Using API keys:</strong> Pass your API key as the{" "}
|
||||
<code className='text-primary bg-text/5 px-1 rounded'>x-api-key</code> header when
|
||||
making requests to DeckyVault's API. You can use these keys with the Decky
|
||||
Loader plugin or any automation tool.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { User, Shield, Link as LinkIcon, Key } 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"
|
||||
import { SettingsApiKeysTab } from "@/components/profile/settings-api-keys-tab"
|
||||
|
||||
interface AuthMethods {
|
||||
hasPassword: boolean
|
||||
passkeyCount: number
|
||||
oauthProviders: Array<{ providerId: string; id: string }>
|
||||
totalAuthMethods: number
|
||||
}
|
||||
|
||||
type SettingsSubTab = "profile" | "security" | "accounts" | "api-keys"
|
||||
|
||||
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: "api-keys", label: "API Keys", icon: Key },
|
||||
]
|
||||
|
||||
interface SettingsContainerProps {
|
||||
name: string
|
||||
email: string
|
||||
role: string | null
|
||||
createdAt: string
|
||||
image?: string | null
|
||||
userId: string
|
||||
onImageChange?: (url: string | null) => void
|
||||
}
|
||||
|
||||
export function SettingsContainer({
|
||||
name,
|
||||
email,
|
||||
role,
|
||||
createdAt,
|
||||
image,
|
||||
userId,
|
||||
onImageChange,
|
||||
}: SettingsContainerProps) {
|
||||
const [activeSubTab, setActiveSubTab] = useState<SettingsSubTab>("profile")
|
||||
const [authMethods, setAuthMethods] = useState<AuthMethods | null>(null)
|
||||
const [isLoadingAuthMethods, setIsLoadingAuthMethods] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000)
|
||||
|
||||
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 () => {
|
||||
controller.abort()
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className='flex-1 min-w-0'>
|
||||
{activeSubTab === "profile" && (
|
||||
<SettingsProfileTab
|
||||
name={name}
|
||||
email={email}
|
||||
role={role}
|
||||
createdAt={createdAt}
|
||||
image={image}
|
||||
userId={userId}
|
||||
onImageChange={onImageChange}
|
||||
/>
|
||||
)}
|
||||
{activeSubTab === "security" && (
|
||||
<SettingsSecurityTab
|
||||
authMethods={authMethods}
|
||||
isLoadingAuthMethods={isLoadingAuthMethods}
|
||||
onRefreshAuthMethods={refreshAuthMethods}
|
||||
/>
|
||||
)}
|
||||
{activeSubTab === "accounts" && (
|
||||
<SettingsAccountsTab
|
||||
authMethods={authMethods}
|
||||
isLoadingAuthMethods={isLoadingAuthMethods}
|
||||
onRefreshAuthMethods={refreshAuthMethods}
|
||||
/>
|
||||
)}
|
||||
{activeSubTab === "api-keys" && (
|
||||
<SettingsApiKeysTab />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { Loader2, Save } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
import { ProfilePhotoUpload } from "@/components/profile/profile-photo-upload"
|
||||
|
||||
interface SettingsProfileTabProps {
|
||||
name: string
|
||||
email: string
|
||||
role: string | null
|
||||
createdAt: string
|
||||
image?: string | null
|
||||
userId: string
|
||||
onImageChange?: (url: string | null) => void
|
||||
}
|
||||
|
||||
export function SettingsProfileTab({ name, email, role, createdAt, image, userId, onImageChange }: SettingsProfileTabProps) {
|
||||
const [displayName, setDisplayName] = useState(name)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null)
|
||||
|
||||
const handleSaveName = async () => {
|
||||
if (!displayName.trim()) {
|
||||
setMessage({ type: "error", text: "Name cannot be empty" })
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
setMessage(null)
|
||||
|
||||
const { error } = await authClient.updateUser({
|
||||
name: displayName.trim(),
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setMessage({ type: "error", text: error.message || "Failed to update name" })
|
||||
} else {
|
||||
setMessage({ type: "success", text: "Name updated successfully" })
|
||||
}
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
const joinDate = new Date(createdAt).toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Profile Photo */}
|
||||
<ProfilePhotoUpload
|
||||
currentImage={image ?? null}
|
||||
userName={name}
|
||||
userId={userId}
|
||||
onImageChange={async (url) => {
|
||||
onImageChange?.(url)
|
||||
await authClient.updateUser({ image: url ?? "" })
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Display Name */}
|
||||
<div className="rounded-xl border border-border bg-text/[0.03] p-5">
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-text/60 mb-4">Display Name</h3>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(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="Your display name"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveName}
|
||||
disabled={isSaving || displayName === name}
|
||||
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 shrink-0 cursor-pointer"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
{message && (
|
||||
<p className={`mt-2 text-sm ${message.type === "success" ? "text-green-400" : "text-red-400"}`}>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email (read-only) */}
|
||||
<div className="rounded-xl border border-border bg-text/[0.03] p-5">
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-text/60 mb-4">Email</h3>
|
||||
<p className="text-sm text-text/80">{email}</p>
|
||||
<p className="text-xs text-text/40 mt-1">Email changes require verification. Contact support if needed.</p>
|
||||
</div>
|
||||
|
||||
{/* Account Info (read-only) */}
|
||||
<div className="rounded-xl border border-border bg-text/[0.03] p-5">
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-text/60 mb-4">Account Info</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-text/50">Role</span>
|
||||
<p className="text-text/80 capitalize">{role || "user"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text/50">Member since</span>
|
||||
<p className="text-text/80">{joinDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import {
|
||||
Loader2,
|
||||
Key,
|
||||
Fingerprint,
|
||||
Plus,
|
||||
Trash2,
|
||||
Pencil,
|
||||
Check,
|
||||
X,
|
||||
Shield,
|
||||
} from "lucide-react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
|
||||
interface Passkey {
|
||||
id: string
|
||||
name: string | null
|
||||
deviceType: string
|
||||
createdAt: string | null
|
||||
}
|
||||
|
||||
interface AuthMethods {
|
||||
hasPassword: boolean
|
||||
passkeyCount: number
|
||||
oauthProviders: Array<{ providerId: string; id: string }>
|
||||
totalAuthMethods: number
|
||||
}
|
||||
|
||||
interface SettingsSecurityTabProps {
|
||||
authMethods: AuthMethods | null
|
||||
isLoadingAuthMethods: boolean
|
||||
onRefreshAuthMethods: () => Promise<void>
|
||||
}
|
||||
|
||||
export function SettingsSecurityTab({
|
||||
authMethods,
|
||||
isLoadingAuthMethods,
|
||||
onRefreshAuthMethods,
|
||||
}: SettingsSecurityTabProps) {
|
||||
const [passkeys, setPasskeys] = useState<Passkey[]>([])
|
||||
const [isLoadingPasskeys, setIsLoadingPasskeys] = useState(true)
|
||||
const [passkeysError, setPasskeysError] = useState<string | null>(null)
|
||||
const [passwordMessage, setPasswordMessage] = useState<{
|
||||
type: "success" | "error"
|
||||
text: string
|
||||
} | null>(null)
|
||||
const [isPasswordSubmitting, setIsPasswordSubmitting] = useState(false)
|
||||
|
||||
// Password form state
|
||||
const [currentPassword, setCurrentPassword] = useState("")
|
||||
const [newPassword, setNewPassword] = useState("")
|
||||
const [confirmPassword, setConfirmPassword] = useState("")
|
||||
|
||||
// Passkey state
|
||||
const [editingPasskeyId, setEditingPasskeyId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [editingName, setEditingName] = useState("")
|
||||
const [isUpdatingPasskey, setIsUpdatingPasskey] = useState(false)
|
||||
const [isDeletingPasskey, setIsDeletingPasskey] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [isAddingPasskey, setIsAddingPasskey] = useState(false)
|
||||
const [passkeyMessage, setPasskeyMessage] = useState<{
|
||||
type: "success" | "error"
|
||||
text: string
|
||||
} | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchPasskeys()
|
||||
}, [])
|
||||
|
||||
async function fetchPasskeys() {
|
||||
try {
|
||||
const res = await fetch("/api/auth/passkey/list-user-passkeys", {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) {
|
||||
setPasskeysError("Failed to load passkeys")
|
||||
setPasskeys([])
|
||||
} else {
|
||||
const data = await res.json()
|
||||
setPasskeys(Array.isArray(data) ? data : [])
|
||||
setPasskeysError(null)
|
||||
}
|
||||
} catch {
|
||||
setPasskeysError("Failed to load passkeys. Please try again.")
|
||||
setPasskeys([])
|
||||
} finally {
|
||||
setIsLoadingPasskeys(false)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshPasskeys = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/passkey/list-user-passkeys", {
|
||||
credentials: "include",
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setPasskeys(Array.isArray(data) ? data : [])
|
||||
setPasskeysError(null)
|
||||
}
|
||||
} catch {
|
||||
// silently fail on refresh
|
||||
}
|
||||
}
|
||||
|
||||
const handlePasswordSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setPasswordMessage(null)
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordMessage({
|
||||
type: "error",
|
||||
text: "Passwords do not match",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword.length < 10) {
|
||||
setPasswordMessage({
|
||||
type: "error",
|
||||
text: "Password must be at least 10 characters",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsPasswordSubmitting(true)
|
||||
|
||||
try {
|
||||
if (authMethods?.hasPassword) {
|
||||
const { error } = await authClient.changePassword({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setPasswordMessage({
|
||||
type: "error",
|
||||
text: error.message || "Failed to change password",
|
||||
})
|
||||
} else {
|
||||
setPasswordMessage({
|
||||
type: "success",
|
||||
text: "Password changed successfully",
|
||||
})
|
||||
setCurrentPassword("")
|
||||
setNewPassword("")
|
||||
setConfirmPassword("")
|
||||
await onRefreshAuthMethods()
|
||||
}
|
||||
} else {
|
||||
const res = await fetch("/api/user/me/set-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ newPassword }),
|
||||
credentials: "include",
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res
|
||||
.json()
|
||||
.catch(() => ({ error: "Failed to set password" }))
|
||||
setPasswordMessage({
|
||||
type: "error",
|
||||
text: data.error || "Failed to set password",
|
||||
})
|
||||
} else {
|
||||
setPasswordMessage({
|
||||
type: "success",
|
||||
text: "Password set successfully",
|
||||
})
|
||||
setNewPassword("")
|
||||
setConfirmPassword("")
|
||||
await onRefreshAuthMethods()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setPasswordMessage({
|
||||
type: "error",
|
||||
text: "An unexpected error occurred",
|
||||
})
|
||||
} finally {
|
||||
setIsPasswordSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddPasskey = async () => {
|
||||
setIsAddingPasskey(true)
|
||||
setPasskeyMessage(null)
|
||||
try {
|
||||
const { error } = await authClient.passkey.addPasskey()
|
||||
if (error) {
|
||||
setPasskeyMessage({
|
||||
type: "error",
|
||||
text: error.message || "Failed to add passkey",
|
||||
})
|
||||
} else {
|
||||
setPasskeyMessage({
|
||||
type: "success",
|
||||
text: "Passkey added successfully",
|
||||
})
|
||||
await refreshPasskeys()
|
||||
await onRefreshAuthMethods()
|
||||
}
|
||||
} catch {
|
||||
setPasskeyMessage({
|
||||
type: "error",
|
||||
text: "Failed to add passkey. Please try again.",
|
||||
})
|
||||
} finally {
|
||||
setIsAddingPasskey(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeletePasskey = async (id: string) => {
|
||||
if (authMethods && authMethods.totalAuthMethods <= 1) return
|
||||
|
||||
setIsDeletingPasskey(id)
|
||||
setPasskeyMessage(null)
|
||||
try {
|
||||
const res = await fetch("/api/auth/passkey/delete-passkey", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res
|
||||
.json()
|
||||
.catch(() => ({ message: "Failed to delete passkey" }))
|
||||
setPasskeyMessage({
|
||||
type: "error",
|
||||
text: data.message || "Failed to delete passkey",
|
||||
})
|
||||
} else {
|
||||
setPasskeyMessage({ type: "success", text: "Passkey deleted" })
|
||||
await refreshPasskeys()
|
||||
await onRefreshAuthMethods()
|
||||
}
|
||||
} catch {
|
||||
setPasskeyMessage({
|
||||
type: "error",
|
||||
text: "Failed to delete passkey. Please try again.",
|
||||
})
|
||||
} finally {
|
||||
setIsDeletingPasskey(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRenamePasskey = async (id: string) => {
|
||||
setIsUpdatingPasskey(true)
|
||||
setPasskeyMessage(null)
|
||||
try {
|
||||
const res = await fetch("/api/auth/passkey/update-passkey", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ id, name: editingName }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res
|
||||
.json()
|
||||
.catch(() => ({ message: "Failed to rename passkey" }))
|
||||
setPasskeyMessage({
|
||||
type: "error",
|
||||
text: data.message || "Failed to rename passkey",
|
||||
})
|
||||
} else {
|
||||
setPasskeyMessage({ type: "success", text: "Passkey renamed" })
|
||||
await refreshPasskeys()
|
||||
setEditingPasskeyId(null)
|
||||
setEditingName("")
|
||||
}
|
||||
} catch {
|
||||
setPasskeyMessage({
|
||||
type: "error",
|
||||
text: "Failed to rename passkey. Please try again.",
|
||||
})
|
||||
} finally {
|
||||
setIsUpdatingPasskey(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startEditingPasskey = (passkey: Passkey) => {
|
||||
setEditingPasskeyId(passkey.id)
|
||||
setEditingName(passkey.name || "")
|
||||
}
|
||||
|
||||
const cancelEditingPasskey = () => {
|
||||
setEditingPasskeyId(null)
|
||||
setEditingName("")
|
||||
}
|
||||
|
||||
const isSingleAuthMethod = authMethods && authMethods.totalAuthMethods <= 1
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className='space-y-6'
|
||||
>
|
||||
{/* Warning for single auth method */}
|
||||
<AnimatePresence>
|
||||
{isSingleAuthMethod && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
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' />
|
||||
<p className='text-sm text-yellow-200/80'>
|
||||
Single authentication method — consider adding a
|
||||
passkey or linking a social account
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Password Section */}
|
||||
<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'>
|
||||
<Key className='h-4 w-4' />
|
||||
Password
|
||||
</h3>
|
||||
|
||||
{isLoadingAuthMethods ? (
|
||||
<div className='flex items-center gap-2 text-sm text-text/50'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
Loading...
|
||||
</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'
|
||||
>
|
||||
{authMethods?.hasPassword && (
|
||||
<div>
|
||||
<label className='block text-xs text-text/50 mb-1'>
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
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}
|
||||
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'
|
||||
placeholder={
|
||||
authMethods?.hasPassword
|
||||
? "Enter new password"
|
||||
: "Set a password"
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-xs text-text/50 mb-1'>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
value={confirmPassword}
|
||||
onChange={(e) =>
|
||||
setConfirmPassword(e.target.value)
|
||||
}
|
||||
className='w-full px-3 py-2 rounded-lg bg-text/5 border border-border text-sm text-text focus:outline-none focus:border-primary/60 focus:ring-1 focus:ring-primary/30 transition-colors'
|
||||
placeholder='Confirm password'
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center gap-3 pt-1'>
|
||||
<button
|
||||
type='submit'
|
||||
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'
|
||||
>
|
||||
{isPasswordSubmitting ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<Key className='h-4 w-4' />
|
||||
)}
|
||||
{authMethods?.hasPassword
|
||||
? "Change Password"
|
||||
: "Set Password"}
|
||||
</button>
|
||||
{passwordMessage && (
|
||||
<p
|
||||
className={`text-sm ${passwordMessage.type === "success" ? "text-green-400" : "text-red-400"}`}
|
||||
>
|
||||
{passwordMessage.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Passkey Section */}
|
||||
<div className='rounded-xl border border-border bg-text/3 p-5'>
|
||||
<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'>
|
||||
<Fingerprint className='h-4 w-4' />
|
||||
Passkeys
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleAddPasskey}
|
||||
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'
|
||||
>
|
||||
{isAddingPasskey ? (
|
||||
<Loader2 className='h-3.5 w-3.5 animate-spin' />
|
||||
) : (
|
||||
<Plus className='h-3.5 w-3.5' />
|
||||
)}
|
||||
Add Passkey
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{passkeyMessage && (
|
||||
<div
|
||||
className={`mb-3 text-sm ${passkeyMessage.type === "success" ? "text-green-400" : "text-red-400"}`}
|
||||
>
|
||||
{passkeyMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{passkeysError ? (
|
||||
<div className='text-center py-4'>
|
||||
<p className='text-sm text-red-400 mb-2'>
|
||||
{passkeysError}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setPasskeysError(null)
|
||||
setIsLoadingPasskeys(true)
|
||||
fetchPasskeys()
|
||||
}}
|
||||
className='text-sm text-primary hover:underline cursor-pointer'
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : isLoadingPasskeys ? (
|
||||
<div className='flex items-center gap-2 text-sm text-text/50'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
Loading passkeys...
|
||||
</div>
|
||||
) : passkeys.length === 0 ? (
|
||||
<p className='text-sm text-text/50'>
|
||||
No passkeys registered.
|
||||
</p>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
<AnimatePresence>
|
||||
{passkeys.map((pk) => (
|
||||
<motion.div
|
||||
key={pk.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
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'>
|
||||
<Fingerprint className='h-4 w-4 text-text/40 shrink-0' />
|
||||
<div className='min-w-0'>
|
||||
{editingPasskeyId === pk.id ? (
|
||||
<div className='flex items-center gap-2'>
|
||||
<input
|
||||
type='text'
|
||||
value={editingName}
|
||||
onChange={(e) =>
|
||||
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
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleRenamePasskey(
|
||||
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 ? (
|
||||
<Loader2 className='h-3.5 w-3.5 animate-spin' />
|
||||
) : (
|
||||
<Check className='h-3.5 w-3.5' />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={
|
||||
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' />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className='text-sm font-medium text-text truncate'>
|
||||
{pk.name ||
|
||||
"Unnamed passkey"}
|
||||
</p>
|
||||
<p className='text-xs text-text/50'>
|
||||
{pk.deviceType}
|
||||
{pk.createdAt && (
|
||||
<span className='ml-1'>
|
||||
· Added{" "}
|
||||
{new Date(
|
||||
pk.createdAt,
|
||||
).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{editingPasskeyId !== pk.id && (
|
||||
<div className='flex items-center gap-1 shrink-0'>
|
||||
<button
|
||||
onClick={() =>
|
||||
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' />
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleDeletePasskey(pk.id)
|
||||
}
|
||||
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 ? (
|
||||
<Loader2 className='h-3.5 w-3.5 animate-spin' />
|
||||
) : (
|
||||
<Trash2 className='h-3.5 w-3.5' />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import { TrendingUp, CheckCircle, Star } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
interface StatsRowProps {
|
||||
contributions: number
|
||||
verifiedEntries: number
|
||||
reputation: number
|
||||
}
|
||||
|
||||
export function StatsRow({ contributions, verifiedEntries, reputation }: StatsRowProps) {
|
||||
const stats = [
|
||||
{ label: "Contributions", value: contributions, icon: TrendingUp, color: "text-primary" },
|
||||
{ label: "Verified", value: verifiedEntries, icon: CheckCircle, color: "text-green-400" },
|
||||
{ label: "Reputation", value: reputation, icon: Star, color: "text-accent" },
|
||||
]
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="grid grid-cols-3 gap-4"
|
||||
>
|
||||
{stats.map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="flex flex-col items-center p-4 rounded-xl bg-text/5 border border-border"
|
||||
>
|
||||
<stat.icon className={`h-5 w-5 ${stat.color} mb-2`} />
|
||||
<span className="text-2xl font-bold">{stat.value}</span>
|
||||
<span className="text-xs text-text/50">{stat.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Bookmark, Plus, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SavedFilter {
|
||||
id: string;
|
||||
name: string;
|
||||
filters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SavedFiltersProps {
|
||||
currentFilters: Record<string, unknown>;
|
||||
onLoad: (filters: Record<string, unknown>) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SavedFilters({ currentFilters, onLoad, className }: SavedFiltersProps) {
|
||||
const [saved, setSaved] = useState<SavedFilter[]>([]);
|
||||
const [showSave, setShowSave] = useState(false);
|
||||
const [filterName, setFilterName] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchSaved = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/saved-filters");
|
||||
if (res.ok) setSaved(await res.json());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSaved();
|
||||
}, []);
|
||||
|
||||
const saveCurrent = async () => {
|
||||
if (!filterName.trim()) return;
|
||||
|
||||
const res = await fetch("/api/saved-filters", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: filterName.trim(), filters: currentFilters }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setFilterName("");
|
||||
setShowSave(false);
|
||||
fetchSaved();
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFilter = async (id: string) => {
|
||||
await fetch(`/api/saved-filters/${id}`, { method: "DELETE" });
|
||||
setSaved((prev) => prev.filter((f) => f.id !== id));
|
||||
};
|
||||
|
||||
const hasActiveFilters = Object.values(currentFilters).some(
|
||||
(v) => v !== undefined && v !== "" && v !== "any"
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-zinc-300">
|
||||
<Bookmark className="h-4 w-4" />
|
||||
Saved Filters
|
||||
</span>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={() => setShowSave(!showSave)}
|
||||
className="flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Save Current
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSave && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={filterName}
|
||||
onChange={(e) => setFilterName(e.target.value)}
|
||||
placeholder="Filter name..."
|
||||
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm"
|
||||
onKeyDown={(e) => e.key === "Enter" && saveCurrent()}
|
||||
/>
|
||||
<button
|
||||
onClick={saveCurrent}
|
||||
disabled={!filterName.trim()}
|
||||
className="rounded-md bg-blue-600 px-2 py-1 text-xs text-white hover:bg-blue-500 disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && saved.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{saved.map((filter) => (
|
||||
<div
|
||||
key={filter.id}
|
||||
className="group flex items-center justify-between rounded-md border border-zinc-800 px-2 py-1.5 hover:bg-zinc-800/50"
|
||||
>
|
||||
<button
|
||||
onClick={() => onLoad(filter.filters)}
|
||||
className="flex-1 text-left text-sm text-zinc-300 hover:text-zinc-100"
|
||||
>
|
||||
{filter.name}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteFilter(filter.id)}
|
||||
className="text-zinc-500 opacity-0 transition-opacity hover:text-red-400 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && saved.length === 0 && (
|
||||
<p className="text-xs text-zinc-500">No saved filters yet</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Bookmark, Loader2 } from "lucide-react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { useSession } from "@/lib/auth-client"
|
||||
|
||||
interface BookmarkButtonProps {
|
||||
gameId: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function BookmarkButton({ gameId, className = "" }: BookmarkButtonProps) {
|
||||
const { data: session } = useSession()
|
||||
const [isSaved, setIsSaved] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(() => !session)
|
||||
const [isToggling, setIsToggling] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return
|
||||
|
||||
async function checkSaved() {
|
||||
try {
|
||||
const res = await fetch(`/api/user/me/saved-games/check/${gameId}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setIsSaved(data.saved)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to check saved status:", err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkSaved()
|
||||
}, [session, gameId])
|
||||
|
||||
const toggleSave = async () => {
|
||||
if (!session || isToggling) return
|
||||
|
||||
setIsToggling(true)
|
||||
try {
|
||||
if (isSaved) {
|
||||
const res = await fetch(`/api/user/me/saved-games/${gameId}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
if (res.ok) {
|
||||
setIsSaved(false)
|
||||
}
|
||||
} else {
|
||||
const res = await fetch("/api/user/me/saved-games", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ gameId }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setIsSaved(true)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle saved game:", err)
|
||||
} finally {
|
||||
setIsToggling(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!session || isLoading) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={toggleSave}
|
||||
disabled={isToggling}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors cursor-pointer ${
|
||||
isSaved
|
||||
? "bg-primary/10 border-primary/30 text-primary"
|
||||
: "bg-text/5 border-border text-text/50 hover:text-text hover:border-primary/30"
|
||||
} ${className}`}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{isToggling ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<motion.div
|
||||
key={isSaved ? "saved" : "not-saved"}
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0.8 }}
|
||||
>
|
||||
<Bookmark
|
||||
className={`h-4 w-4 ${isSaved ? "fill-primary" : ""}`}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<span className="text-sm font-medium">
|
||||
{isSaved ? "Saved" : "Save"}
|
||||
</span>
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { Bookmark, Loader2, X, Gamepad2 } from "lucide-react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
|
||||
interface SavedGame {
|
||||
id: string
|
||||
gameId: string
|
||||
createdAt: string
|
||||
gameTitle: string
|
||||
gameHeaderImage: string | null
|
||||
gameCapsuleImage: string | null
|
||||
gameSteamAppId: number | null
|
||||
}
|
||||
|
||||
export function SavedGamesGrid() {
|
||||
const [games, setGames] = useState<SavedGame[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [removingId, setRemovingId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchSaved() {
|
||||
try {
|
||||
const res = await fetch("/api/user/me/saved-games")
|
||||
if (res.ok) {
|
||||
setGames(await res.json())
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch saved games:", err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchSaved()
|
||||
}, [])
|
||||
|
||||
const removeGame = async (id: string, gameId: string) => {
|
||||
setRemovingId(id)
|
||||
try {
|
||||
const res = await fetch(`/api/user/me/saved-games/${gameId}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
if (res.ok) {
|
||||
setGames((prev) => prev.filter((g) => g.id !== id))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to remove saved game:", err)
|
||||
} finally {
|
||||
setRemovingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (games.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-text/40">
|
||||
<Bookmark className="h-8 w-8 mx-auto mb-2" />
|
||||
<p>No saved games yet</p>
|
||||
<p className="text-xs mt-1">
|
||||
Save games from their detail pages to see them here
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<AnimatePresence>
|
||||
{games.map((game) => (
|
||||
<motion.div
|
||||
key={game.id}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="relative group"
|
||||
>
|
||||
<Link
|
||||
href={`/game/${game.gameId}`}
|
||||
className="block rounded-xl overflow-hidden border border-border hover:border-primary/30 transition-colors"
|
||||
>
|
||||
{game.gameHeaderImage ? (
|
||||
<Image
|
||||
src={game.gameHeaderImage}
|
||||
alt={game.gameTitle}
|
||||
width={300}
|
||||
height={140}
|
||||
unoptimized
|
||||
className="w-full h-32 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-32 bg-text/5 flex items-center justify-center">
|
||||
<Gamepad2 className="h-8 w-8 text-text/20" />
|
||||
</div>
|
||||
)}
|
||||
<div className="p-3">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{game.gameTitle}
|
||||
</p>
|
||||
<p className="text-xs text-text/40 mt-1">
|
||||
Saved {new Date(game.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => removeGame(game.id, game.gameId)}
|
||||
disabled={removingId === game.id}
|
||||
className="absolute top-2 right-2 p-1.5 rounded-full bg-black/50 text-text/70 hover:text-red-400 hover:bg-black/70 transition-colors opacity-0 group-hover:opacity-100 cursor-pointer disabled:cursor-not-allowed"
|
||||
>
|
||||
{removingId === game.id ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<X className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { ThumbsUp, ThumbsDown, ExternalLink, MessageSquare } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SteamReview {
|
||||
recommendationid: string;
|
||||
author: {
|
||||
steamid: string;
|
||||
playtime_forever: number;
|
||||
};
|
||||
language: string;
|
||||
review: string;
|
||||
timestamp_created: number;
|
||||
voted_up: boolean;
|
||||
votes_up: number;
|
||||
votes_funny: number;
|
||||
}
|
||||
|
||||
interface SteamReviewsData {
|
||||
success: number;
|
||||
query_summary: {
|
||||
num_reviews: number;
|
||||
review_score_desc: string;
|
||||
total_positive: number;
|
||||
total_negative: number;
|
||||
total_reviews: number;
|
||||
};
|
||||
reviews: SteamReview[];
|
||||
}
|
||||
|
||||
interface SteamReviewsProps {
|
||||
gameId: string;
|
||||
steamAppId: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SteamReviews({ gameId, steamAppId, className }: SteamReviewsProps) {
|
||||
const [data, setData] = useState<SteamReviewsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
const fetchReviews = useCallback(async (newOffset: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/steam-reviews/${gameId}?offset=${newOffset}&limit=5&language=english`
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => null);
|
||||
throw new Error(errData?.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
setData(result);
|
||||
setOffset(newOffset);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [gameId, setData, setLoading, setError, setOffset]);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
fetchReviews(0);
|
||||
}, [gameId, fetchReviews]);
|
||||
|
||||
// Loading skeleton
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<div className={cn("animate-pulse space-y-4", className)}>
|
||||
<div className="h-8 w-48 rounded bg-zinc-800" />
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-24 rounded-lg bg-zinc-800/50" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className={cn("rounded-lg border border-zinc-800 p-4", className)}>
|
||||
<div className="flex items-center gap-2 text-zinc-400">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
<p className="text-sm">Steam reviews unavailable</p>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-500">{error}</p>
|
||||
<a
|
||||
href={`https://store.steampowered.com/app/${steamAppId}#app_reviews_hash`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
View on Steam <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No data or no summary
|
||||
if (!data?.query_summary) {
|
||||
return (
|
||||
<div className={cn("rounded-lg border border-zinc-800 p-4", className)}>
|
||||
<div className="flex items-center gap-2 text-zinc-400">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
<p className="text-sm">No Steam reviews available</p>
|
||||
</div>
|
||||
<a
|
||||
href={`https://store.steampowered.com/app/${steamAppId}#app_reviews_hash`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
View on Steam <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { query_summary: summary } = data;
|
||||
const totalReviews = summary.total_reviews ?? 0;
|
||||
const totalPositive = summary.total_positive ?? 0;
|
||||
const positivePercent =
|
||||
totalReviews > 0
|
||||
? Math.round((totalPositive / totalReviews) * 100)
|
||||
: 0;
|
||||
|
||||
// No reviews case
|
||||
if (totalReviews === 0) {
|
||||
return (
|
||||
<div className={cn("rounded-lg border border-zinc-800 p-4", className)}>
|
||||
<div className="flex items-center gap-2 text-zinc-400">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
<p className="text-sm">No reviews yet</p>
|
||||
</div>
|
||||
<a
|
||||
href={`https://store.steampowered.com/app/${steamAppId}#app_reviews_hash`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Be the first to review on Steam <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* Summary header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Steam Reviews</h3>
|
||||
<p className="text-sm text-zinc-400">
|
||||
{summary.review_score_desc ?? "No reviews"} — {positivePercent}% positive ({totalReviews.toLocaleString()} reviews)
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={`https://store.steampowered.com/app/${steamAppId}#app_reviews_hash`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
View on Steam <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Review cards */}
|
||||
<div className="space-y-3">
|
||||
{(data.reviews ?? []).map((review) => (
|
||||
<div
|
||||
key={review.recommendationid}
|
||||
className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{review.voted_up ? (
|
||||
<ThumbsUp className="h-4 w-4 text-green-400" />
|
||||
) : (
|
||||
<ThumbsDown className="h-4 w-4 text-red-400" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-sm font-medium",
|
||||
review.voted_up ? "text-green-400" : "text-red-400"
|
||||
)}>
|
||||
{review.voted_up ? "Recommended" : "Not Recommended"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{Math.floor((review.author?.playtime_forever ?? 0) / 60)}h played
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-300 line-clamp-4">{review.review}</p>
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-zinc-500">
|
||||
<span>{review.votes_up} found helpful</span>
|
||||
{review.votes_funny > 0 && (
|
||||
<span>{review.votes_funny} funny</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalReviews > 5 && (
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={() => fetchReviews(Math.max(0, offset - 5))}
|
||||
disabled={offset === 0 || loading}
|
||||
className="rounded-md border border-zinc-700 px-3 py-1.5 text-sm text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-xs text-zinc-500 self-center">
|
||||
Showing {offset + 1}-{Math.min(offset + 5, totalReviews)} of {totalReviews}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => fetchReviews(offset + 5)}
|
||||
disabled={offset + 5 >= totalReviews || loading}
|
||||
className="rounded-md border border-zinc-700 px-3 py-1.5 text-sm text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import { useEditor, EditorContent } from "@tiptap/react"
|
||||
import StarterKit from "@tiptap/starter-kit"
|
||||
import Link from "@tiptap/extension-link"
|
||||
import Placeholder from "@tiptap/extension-placeholder"
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
List,
|
||||
ListOrdered,
|
||||
Link as LinkIcon,
|
||||
Unlink,
|
||||
} from "lucide-react"
|
||||
|
||||
interface TiptapEditorProps {
|
||||
content?: string
|
||||
onChange?: (json: Record<string, unknown>) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TiptapEditor({
|
||||
content,
|
||||
onChange,
|
||||
placeholder = "Write your notes here...",
|
||||
className = "",
|
||||
}: TiptapEditorProps) {
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: false,
|
||||
codeBlock: false,
|
||||
code: false,
|
||||
blockquote: false,
|
||||
horizontalRule: false,
|
||||
link: false,
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
class: "text-primary underline",
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
],
|
||||
content: content ? JSON.parse(content) : undefined,
|
||||
onUpdate: ({ editor }) => {
|
||||
onChange?.(editor.getJSON())
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"prose prose-invert prose-sm max-w-none min-h-[120px] px-4 py-3 outline-none",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!editor) {
|
||||
return null
|
||||
}
|
||||
|
||||
const toggleLink = () => {
|
||||
if (editor.isActive("link")) {
|
||||
editor.chain().focus().unsetLink().run()
|
||||
} else {
|
||||
const url = window.prompt("Enter URL:")
|
||||
if (url) {
|
||||
editor.chain().focus().setLink({ href: url }).run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border border-border bg-text/5 overflow-hidden ${className}`}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1 px-3 py-2 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
className={`p-1.5 rounded hover:bg-text/10 transition-colors cursor-pointer ${
|
||||
editor.isActive("bold") ? "bg-text/10 text-primary" : "text-text/50"
|
||||
}`}
|
||||
>
|
||||
<Bold className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
className={`p-1.5 rounded hover:bg-text/10 transition-colors cursor-pointer ${
|
||||
editor.isActive("italic") ? "bg-text/10 text-primary" : "text-text/50"
|
||||
}`}
|
||||
>
|
||||
<Italic className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
className={`p-1.5 rounded hover:bg-text/10 transition-colors cursor-pointer ${
|
||||
editor.isActive("bulletList") ? "bg-text/10 text-primary" : "text-text/50"
|
||||
}`}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
className={`p-1.5 rounded hover:bg-text/10 transition-colors cursor-pointer ${
|
||||
editor.isActive("orderedList") ? "bg-text/10 text-primary" : "text-text/50"
|
||||
}`}
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLink}
|
||||
className={`p-1.5 rounded hover:bg-text/10 transition-colors cursor-pointer ${
|
||||
editor.isActive("link") ? "bg-text/10 text-primary" : "text-text/50"
|
||||
}`}
|
||||
>
|
||||
{editor.isActive("link") ? (
|
||||
<Unlink className="h-4 w-4" />
|
||||
) : (
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
|
||||
interface TiptapNode {
|
||||
type: string
|
||||
content?: TiptapNode[]
|
||||
text?: string
|
||||
marks?: { type: string; attrs?: Record<string, string> }[]
|
||||
attrs?: Record<string, string>
|
||||
}
|
||||
|
||||
export function TiptapRenderer({ content }: { content: string }) {
|
||||
let parsed: TiptapNode
|
||||
try {
|
||||
parsed = JSON.parse(content)
|
||||
} catch {
|
||||
// If it's just plain text, wrap it in a paragraph
|
||||
return <p className="text-sm text-text/70 whitespace-pre-line leading-relaxed">{content}</p>
|
||||
}
|
||||
|
||||
return <div className="text-sm text-text/70 leading-relaxed">{renderNode(parsed)}</div>
|
||||
}
|
||||
|
||||
function renderNode(node: TiptapNode, key?: number): React.ReactNode {
|
||||
switch (node.type) {
|
||||
case "doc":
|
||||
return node.content?.map((child, i) => renderNode(child, i))
|
||||
case "paragraph":
|
||||
return <p key={key} className="mb-2 last:mb-0">{node.content ? node.content.map((child, i) => renderNode(child, i)) : <br />}</p>
|
||||
case "text": {
|
||||
let el: React.ReactNode = node.text ?? ""
|
||||
node.marks?.forEach((mark) => {
|
||||
if (mark.type === "bold") el = <strong key={`${key}-bold`}>{el}</strong>
|
||||
if (mark.type === "italic") el = <em key={`${key}-italic`}>{el}</em>
|
||||
})
|
||||
return <span key={key}>{el}</span>
|
||||
}
|
||||
case "bulletList":
|
||||
return <ul key={key} className="list-disc pl-4 mb-2">{node.content?.map((child, i) => renderNode(child, i))}</ul>
|
||||
case "orderedList":
|
||||
return <ol key={key} className="list-decimal pl-4 mb-2">{node.content?.map((child, i) => renderNode(child, i))}</ol>
|
||||
case "listItem":
|
||||
return <li key={key}>{node.content?.map((child, i) => renderNode(child, i))}</li>
|
||||
case "link":
|
||||
return <a key={key} href={node.attrs?.href} target="_blank" rel="noopener noreferrer" className="text-primary underline">{node.content?.map((child, i) => renderNode(child, i))}</a>
|
||||
case "hardBreak":
|
||||
return <br key={key} />
|
||||
default:
|
||||
return node.content?.map((child, i) => renderNode(child, i))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Modal } from "./modal"
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
title?: string
|
||||
message?: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
variant?: "default" | "destructive"
|
||||
loading?: boolean
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title = "Confirm",
|
||||
message,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
variant = "default",
|
||||
loading = false,
|
||||
children,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={open}
|
||||
onClose={loading ? () => {} : onClose}
|
||||
title={title}
|
||||
size="sm"
|
||||
showCloseButton={!loading}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{message && (
|
||||
<p className="text-sm text-text/80">{message}</p>
|
||||
)}
|
||||
{children}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
"inline-flex touch-target items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50",
|
||||
"bg-text/10 text-text hover:bg-text/20",
|
||||
loading && "opacity-70 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
"inline-flex touch-target items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50",
|
||||
variant === "destructive"
|
||||
? "bg-red-600 text-white hover:bg-red-700"
|
||||
: "bg-primary text-white hover:bg-primary/90",
|
||||
loading && "opacity-70 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{loading && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Modal } from "./modal"
|
||||
export { ConfirmDialog } from "./confirm-dialog"
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useLayoutEffect } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "max-w-sm",
|
||||
md: "max-w-md",
|
||||
lg: "max-w-lg",
|
||||
xl: "max-w-xl",
|
||||
full: "max-w-full w-full h-full m-0 rounded-none",
|
||||
}
|
||||
|
||||
type ModalSize = keyof typeof sizeClasses
|
||||
|
||||
export interface ModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
description?: string
|
||||
size?: ModalSize
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
}
|
||||
|
||||
// Use useLayoutEffect for DOM reads/writes to avoid hydration mismatches
|
||||
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect
|
||||
|
||||
export function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
size = "md",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
}: ModalProps) {
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
const previousActiveElement = useRef<HTMLElement | null>(null)
|
||||
|
||||
// Escape handler
|
||||
const handleEscape = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
document.addEventListener("keydown", handleEscape)
|
||||
return () => document.removeEventListener("keydown", handleEscape)
|
||||
}, [isOpen, handleEscape])
|
||||
|
||||
// Body scroll lock
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const originalOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = "hidden"
|
||||
return () => {
|
||||
document.body.style.overflow = originalOverflow
|
||||
}
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Focus management: store previously focused element and restore on close
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (isOpen) {
|
||||
previousActiveElement.current = document.activeElement as HTMLElement
|
||||
// Focus the modal header or the close button for accessibility
|
||||
const firstFocusable = modalRef.current?.querySelector<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
)
|
||||
firstFocusable?.focus()
|
||||
return () => {
|
||||
previousActiveElement.current?.focus()
|
||||
}
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Focus trap
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab" || !modalRef.current) return
|
||||
|
||||
const focusableElements = modalRef.current.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
)
|
||||
const first = focusableElements[0]
|
||||
const last = focusableElements[focusableElements.length - 1]
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last?.focus()
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first?.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleTab)
|
||||
return () => document.removeEventListener("keydown", handleTab)
|
||||
}, [isOpen])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50" aria-hidden={!isOpen}>
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Modal wrapper */}
|
||||
<div className="absolute inset-0 flex items-center justify-center overflow-y-auto p-4">
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? "modal-title" : undefined}
|
||||
aria-describedby={description ? "modal-description" : undefined}
|
||||
className={cn(
|
||||
"relative w-full rounded-xl border border-border bg-background text-text shadow-xl",
|
||||
size !== "full" && "my-auto max-h-[90vh] flex flex-col",
|
||||
size === "full" && "h-full flex flex-col",
|
||||
sizeClasses[size],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
{(title || showCloseButton) && (
|
||||
<div className="flex items-center justify-between gap-4 border-b border-border px-5 py-4 shrink-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
{title && (
|
||||
<h2
|
||||
id="modal-title"
|
||||
className="text-base font-semibold text-text truncate"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{description && (
|
||||
<p id="modal-description" className="mt-0.5 text-sm text-text/60">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="shrink-0 rounded-md p-1.5 text-text/50 transition-colors hover:bg-text/5 hover:text-text focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 cursor-pointer"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<div className={cn("flex-1 overflow-y-auto", !title && !showCloseButton && "p-5", title || showCloseButton ? "px-5 py-4" : "")}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// components/ui/screenshot-lightbox.tsx
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback, useEffect } from "react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { XIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
interface ScreenshotLightboxProps {
|
||||
screenshots: Array<{ id: string; url: string; width: number; height: number }>
|
||||
initialIndex: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ScreenshotLightbox({
|
||||
screenshots,
|
||||
initialIndex,
|
||||
onClose,
|
||||
}: ScreenshotLightboxProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex)
|
||||
const [zoomed, setZoomed] = useState(false)
|
||||
const hasMultiple = screenshots.length > 1
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setZoomed(false)
|
||||
setCurrentIndex((prev) => (prev < screenshots.length - 1 ? prev + 1 : prev))
|
||||
}, [screenshots.length])
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setZoomed(false)
|
||||
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : prev))
|
||||
}, [])
|
||||
|
||||
const toggleZoom = useCallback(() => {
|
||||
setZoomed((z) => !z)
|
||||
}, [])
|
||||
|
||||
// Keyboard handlers
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose()
|
||||
} else if (e.key === "ArrowRight" && hasMultiple) {
|
||||
goNext()
|
||||
} else if (e.key === "ArrowLeft" && hasMultiple) {
|
||||
goPrev()
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [onClose, goNext, goPrev, hasMultiple])
|
||||
|
||||
const current = screenshots[currentIndex]
|
||||
if (!current) return null
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key="lightbox-backdrop"
|
||||
className="fixed inset-0 z-[60] bg-black/90 flex items-center justify-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors z-10 cursor-pointer"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XIcon className="h-5 w-5 text-white" />
|
||||
</button>
|
||||
|
||||
{/* Previous arrow */}
|
||||
{hasMultiple && currentIndex > 0 && (
|
||||
<button
|
||||
onClick={goPrev}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors z-10 cursor-pointer"
|
||||
aria-label="Previous screenshot"
|
||||
>
|
||||
<ChevronLeftIcon className="h-6 w-6 text-white" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Next arrow */}
|
||||
{hasMultiple && currentIndex < screenshots.length - 1 && (
|
||||
<button
|
||||
onClick={goNext}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors z-10 cursor-pointer"
|
||||
aria-label="Next screenshot"
|
||||
>
|
||||
<ChevronRightIcon className="h-6 w-6 text-white" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Image container — scrollable for panning when zoomed */}
|
||||
<div
|
||||
className="flex items-center justify-center w-full h-full overflow-auto"
|
||||
style={{ touchAction: zoomed ? "pan-x pan-y" : "pinch-zoom" }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={current.url}
|
||||
alt={`Screenshot ${currentIndex + 1}`}
|
||||
className={`transition-transform duration-200 ${
|
||||
zoomed
|
||||
? "max-w-none cursor-zoom-out"
|
||||
: "max-w-[90vw] max-h-[90vh] object-contain cursor-zoom-in"
|
||||
}`}
|
||||
style={zoomed ? { width: current.width, height: current.height } : undefined}
|
||||
onClick={toggleZoom}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Counter */}
|
||||
{hasMultiple && (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-white/10 text-white text-xs font-medium">
|
||||
{currentIndex + 1} / {screenshots.length}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { ListIcon } from "lucide-react"
|
||||
import type { UpdateHeading } from "@/lib/updates"
|
||||
|
||||
export function ChapterNav({
|
||||
headings,
|
||||
}: {
|
||||
headings: UpdateHeading[]
|
||||
}) {
|
||||
const [activeId, setActiveId] = useState<string>(headings[0]?.id ?? "")
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const observers: IntersectionObserver[] = []
|
||||
|
||||
headings.forEach((heading) => {
|
||||
const element = document.getElementById(heading.id)
|
||||
if (!element) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveId(heading.id)
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
rootMargin: "-20% 0px -70% 0px",
|
||||
},
|
||||
)
|
||||
observer.observe(element)
|
||||
observers.push(observer)
|
||||
})
|
||||
|
||||
return () => {
|
||||
observers.forEach((observer) => observer.disconnect())
|
||||
}
|
||||
}, [headings])
|
||||
|
||||
if (headings.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile toggle button */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
className="fixed bottom-4 right-4 z-50 md:hidden flex items-center justify-center w-10 h-10 rounded-full bg-background border border-border shadow-lg hover:border-border-active transition-colors cursor-pointer"
|
||||
aria-label="Toggle chapter navigation"
|
||||
>
|
||||
<ListIcon className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Mobile dropdown */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-45 md:hidden"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<nav
|
||||
className={`fixed bottom-16 right-4 z-50 md:z-auto md:static md:block max-h-[50vh] md:max-h-none overflow-y-auto bg-background border border-border rounded-lg p-3 shadow-lg md:shadow-none md:rounded-none md:border-0 md:p-0 md:bg-transparent transition-all md:transition-none ${
|
||||
mobileOpen
|
||||
? "block opacity-100"
|
||||
: "hidden md:block opacity-0 md:opacity-100"
|
||||
}`}
|
||||
>
|
||||
<h4 className="text-xs uppercase text-text/40 font-semibold mb-2 hidden md:block">
|
||||
Chapters
|
||||
</h4>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{headings.map((heading) => (
|
||||
<li key={heading.id}>
|
||||
<a
|
||||
href={`#${heading.id}`}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={`block text-sm py-1 transition-colors ${
|
||||
heading.level === 3 ? "pl-3" : ""
|
||||
} ${
|
||||
activeId === heading.id
|
||||
? "text-primary font-medium"
|
||||
: "text-text/60 hover:text-text"
|
||||
}`}
|
||||
>
|
||||
{heading.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export function ReadingProgressBar() {
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const updateProgress = () => {
|
||||
const scrollTop = window.scrollY
|
||||
const scrollHeight = document.documentElement.scrollHeight
|
||||
const clientHeight = window.innerHeight
|
||||
const maxScroll = scrollHeight - clientHeight
|
||||
|
||||
if (maxScroll <= 0) {
|
||||
setProgress(0)
|
||||
return
|
||||
}
|
||||
|
||||
setProgress((scrollTop / maxScroll) * 100)
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", updateProgress, { passive: true })
|
||||
updateProgress()
|
||||
|
||||
return () => window.removeEventListener("scroll", updateProgress)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="fixed top-[3.6rem] left-0 right-0 z-40 h-0.5 bg-border">
|
||||
<div
|
||||
className="h-full bg-primary transition-[width] duration-150 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Link from "next/link"
|
||||
import type { UpdateMeta } from "@/lib/updates"
|
||||
|
||||
export function UpdateCard({ update }: { update: UpdateMeta }) {
|
||||
const formattedDate = new Date(update.date).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
|
||||
return (
|
||||
<Link href={`/updates/${update.slug}`}>
|
||||
<article className='group flex flex-col gap-2 p-4 border border-border rounded-md hover:bg-text/3 transition-colors'>
|
||||
<div className='flex flex-row items-center gap-2'>
|
||||
<span className='text-xs font-mono px-2 py-0.5 rounded bg-primary/10 text-primary border border-primary/20'>
|
||||
v{update.version}
|
||||
</span>
|
||||
<time className='text-xs text-text/60'>
|
||||
{formattedDate}
|
||||
</time>
|
||||
</div>
|
||||
<h3 className='text-lg font-semibold group-hover:text-primary transition-colors'>
|
||||
{update.title}
|
||||
</h3>
|
||||
<p className='text-sm text-text/60 line-clamp-2'>
|
||||
{update.summary}
|
||||
</p>
|
||||
</article>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import { ReadingProgressBar } from "./reading-progress-bar"
|
||||
import { ChapterNav } from "./chapter-nav"
|
||||
import type { UpdateContent } from "@/lib/updates"
|
||||
|
||||
export function UpdateViewer({
|
||||
update,
|
||||
}: {
|
||||
update: UpdateContent
|
||||
}) {
|
||||
const formattedDate = new Date(update.meta.date).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReadingProgressBar />
|
||||
<div className="w-full max-w-7xl mx-auto px-4 py-8 flex flex-row gap-8">
|
||||
{/* Chapter navigation sidebar (desktop) */}
|
||||
<aside className="hidden md:block w-48 shrink-0">
|
||||
<div className="sticky top-20">
|
||||
<ChapterNav headings={update.headings} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<article className="flex-1 min-w-0 prose prose-invert lg:proxe-xl">
|
||||
<header className="mb-8">
|
||||
<div className="flex flex-row items-center gap-2 mb-2">
|
||||
<span className="text-xs font-mono px-2 py-0.5 rounded bg-primary/10 text-primary border border-primary/20">
|
||||
v{update.meta.version}
|
||||
</span>
|
||||
<time className="text-sm text-text/60">{formattedDate}</time>
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold">{update.meta.title}</h1>
|
||||
</header>
|
||||
<div
|
||||
className="update-content max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: update.html }}
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{/* Mobile chapter nav (rendered inside viewer for context) */}
|
||||
<div className="md:hidden">
|
||||
<ChapterNav headings={update.headings} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { StepIndicator } from "@/components/wizard/step-indicator"
|
||||
import { SetupStep, type GameVersionInfo } from "@/components/wizard/steps/setup-step"
|
||||
import { type AntiCheatData } from "@/components/wizard/steps/anti-cheat-step"
|
||||
import { PerformanceStep, type PerformanceData } from "@/components/wizard/steps/performance-step"
|
||||
import { SettingsStep } from "@/components/wizard/steps/settings-step"
|
||||
import { EnvironmentStep, type EnvironmentData } from "@/components/wizard/steps/environment-step"
|
||||
import { ReviewStep, type ExistingScreenshot } from "@/components/wizard/steps/review-step"
|
||||
import type { SettingCategory } from "@/components/wizard/settings-editor"
|
||||
import { performanceEntries } from "@/lib/db/schema"
|
||||
|
||||
// Export GameVersionInfo so the server page can use it
|
||||
export type { GameVersionInfo }
|
||||
|
||||
const STEPS = [
|
||||
{ label: "Setup", tooltip: "Choose the hardware, game version, and anti-cheat status" },
|
||||
{ label: "Performance", tooltip: "Enter the performance metrics you observed. FPS Average is required." },
|
||||
{ label: "Settings", tooltip: "Configure the game settings you used. Add categories and settings to help others replicate your setup." },
|
||||
{ label: "Environment", tooltip: "Specify the software environment and any launch options used" },
|
||||
{ label: "Review", tooltip: "Review your entry before submitting. Add any additional notes." },
|
||||
]
|
||||
|
||||
interface PlatformSupportEntry {
|
||||
hardwareSlug: string
|
||||
antiCheatRelevant: boolean
|
||||
antiCheatName: string | null
|
||||
antiCheatStatus: "none" | "supported" | "unsupported" | "unknown"
|
||||
}
|
||||
|
||||
interface GameEntryWizardProps {
|
||||
gameId: string
|
||||
gameVersions: GameVersionInfo[]
|
||||
defaultVersionId: string
|
||||
editEntry?: typeof performanceEntries.$inferSelect | null
|
||||
platformSupport: PlatformSupportEntry[]
|
||||
}
|
||||
|
||||
export function GameEntryWizard({ gameId, gameVersions, defaultVersionId, editEntry, platformSupport }: GameEntryWizardProps) {
|
||||
const router = useRouter()
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [screenshotFiles, setScreenshotFiles] = useState<File[]>([])
|
||||
const [submitPhase, setSubmitPhase] = useState<"idle" | "uploading" | "saving" | "success" | "error">("idle")
|
||||
const [existingScreenshots, setExistingScreenshots] = useState<ExistingScreenshot[]>([])
|
||||
const [removedScreenshotIds, setRemovedScreenshotIds] = useState<string[]>([])
|
||||
|
||||
// Step 0: Setup — Hardware
|
||||
const [hardwareSlug, setHardwareSlug] = useState(editEntry?.hardwareSlug ?? "")
|
||||
const [hardwareName, setHardwareName] = useState("")
|
||||
const [hardwareWattHours, setHardwareWattHours] = useState<number | null>(null)
|
||||
const [hardwareDeviceType, setHardwareDeviceType] = useState<string | null>(null)
|
||||
|
||||
// Step 0: Setup — Game Version
|
||||
const [selectedVersionId, setSelectedVersionId] = useState(defaultVersionId)
|
||||
const [newVersionString, setNewVersionString] = useState("")
|
||||
const [isCreatingVersion, setIsCreatingVersion] = useState(false)
|
||||
|
||||
// Step 0: Setup — Anti-Cheat
|
||||
const [antiCheat, setAntiCheat] = useState<AntiCheatData>({
|
||||
antiCheatRelevant: false,
|
||||
antiCheatName: "",
|
||||
antiCheatStatus: "none",
|
||||
})
|
||||
|
||||
// Initialize anti-cheat from existing platformSupport when editing
|
||||
useEffect(() => {
|
||||
const entry = platformSupport.find(
|
||||
(p) => p.hardwareSlug === hardwareSlug && p.antiCheatRelevant
|
||||
) ?? platformSupport.find((p) => p.antiCheatRelevant)
|
||||
|
||||
if (entry) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAntiCheat({
|
||||
antiCheatRelevant: entry.antiCheatRelevant,
|
||||
antiCheatName: entry.antiCheatName ?? "",
|
||||
antiCheatStatus: entry.antiCheatStatus,
|
||||
})
|
||||
} else {
|
||||
setAntiCheat({
|
||||
antiCheatRelevant: false,
|
||||
antiCheatName: "",
|
||||
antiCheatStatus: "none",
|
||||
})
|
||||
}
|
||||
}, [hardwareSlug, platformSupport])
|
||||
|
||||
// Step 1: Performance
|
||||
const [performance, setPerformance] = useState<PerformanceData>(
|
||||
editEntry
|
||||
? {
|
||||
fpsAvg: editEntry.fpsAvg,
|
||||
fpsOnePercentLow: editEntry.fpsOnePercentLow ?? undefined,
|
||||
fpsLow: editEntry.fpsLow ?? undefined,
|
||||
fpsHigh: editEntry.fpsHigh ?? undefined,
|
||||
loadTimeSsd: editEntry.loadTimeSsd ?? undefined,
|
||||
loadTimeSd: editEntry.loadTimeSd ?? undefined,
|
||||
tdpWatts: editEntry.tdpWatts ?? undefined,
|
||||
}
|
||||
: {},
|
||||
)
|
||||
|
||||
// Step 2: Settings
|
||||
const [settingsJson, setSettingsJson] = useState<SettingCategory[]>(
|
||||
editEntry?.settingsJson ?? [],
|
||||
)
|
||||
|
||||
// Step 3: Environment
|
||||
const [environment, setEnvironment] = useState<EnvironmentData>(
|
||||
editEntry
|
||||
? {
|
||||
protonVersion: editEntry.protonVersion ?? undefined,
|
||||
osVersion: editEntry.osVersion ?? undefined,
|
||||
upscalerType: editEntry.upscalerType ?? "none",
|
||||
upscalerVersion: editEntry.upscalerVersion ?? undefined,
|
||||
frameGenMethod: editEntry.frameGenMethod ?? "none",
|
||||
launchOptions: editEntry.launchOptions ?? undefined,
|
||||
customSystem: editEntry.customSystem ?? false,
|
||||
youtubeVideoId: editEntry.youtubeVideoId ?? undefined,
|
||||
}
|
||||
: {
|
||||
upscalerType: "none",
|
||||
frameGenMethod: "none",
|
||||
},
|
||||
)
|
||||
|
||||
// Step 4: Notes
|
||||
const [userNotes, setUserNotes] = useState(editEntry?.userNotes ?? "")
|
||||
|
||||
// Auto-detected version suggestion (multi-strategy)
|
||||
const [steamdbVersion, setSteamdbVersion] = useState<{
|
||||
versionString: string | null
|
||||
buildId: string | null
|
||||
source?: string
|
||||
} | null>(null)
|
||||
const [steamdbLoading, setSteamdbLoading] = useState(false)
|
||||
const [steamdbError, setSteamdbError] = useState<string | null>(null)
|
||||
|
||||
// Fetch hardware name when slug changes
|
||||
const handleHardwareChange = useCallback(async (slug: string) => {
|
||||
setHardwareSlug(slug)
|
||||
if (!slug) {
|
||||
setHardwareName("")
|
||||
setHardwareWattHours(null)
|
||||
setHardwareDeviceType(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/api/performance/hardware")
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { data: Array<{ slug: string; name: string; deviceType: string; wattHours: number | null; tdpMax: number | null }> }
|
||||
const device = data.data.find((d) => d.slug === slug)
|
||||
if (device) {
|
||||
setHardwareName(device.name)
|
||||
setHardwareWattHours(device.wattHours ?? null)
|
||||
setHardwareDeviceType(device.deviceType ?? null)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fetch hardware name when in edit mode
|
||||
useEffect(() => {
|
||||
if (!editEntry || !hardwareSlug) return
|
||||
let cancelled = false
|
||||
async function fetchName() {
|
||||
try {
|
||||
const res = await fetch("/api/performance/hardware")
|
||||
if (res.ok && !cancelled) {
|
||||
const data = await res.json() as { data: Array<{ slug: string; name: string; deviceType: string; wattHours: number | null; tdpMax: number | null }> }
|
||||
const device = data.data.find((d) => d.slug === hardwareSlug)
|
||||
if (device && !cancelled) {
|
||||
setHardwareName(device.name)
|
||||
setHardwareWattHours(device.wattHours ?? null)
|
||||
setHardwareDeviceType(device.deviceType ?? null)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
fetchName()
|
||||
return () => { cancelled = true }
|
||||
}, [editEntry, hardwareSlug])
|
||||
|
||||
// Resolve the version label for display
|
||||
const getVersionLabel = useCallback(() => {
|
||||
if (selectedVersionId === "__new__") {
|
||||
return newVersionString || "New version"
|
||||
}
|
||||
if (selectedVersionId === "__steamdb__") {
|
||||
return steamdbVersion
|
||||
? steamdbVersion.versionString || `Build ${steamdbVersion.buildId}`
|
||||
: "SteamDB version"
|
||||
}
|
||||
const v = gameVersions.find((v) => v.id === selectedVersionId)
|
||||
if (!v) return "Unknown"
|
||||
return v.versionString || (v.buildId ? `Build ${v.buildId}` : "Unknown version")
|
||||
}, [selectedVersionId, newVersionString, gameVersions, steamdbVersion])
|
||||
|
||||
const handleRemoveExistingScreenshot = useCallback((id: string) => {
|
||||
setExistingScreenshots((prev) => prev.filter((ss) => ss.id !== id))
|
||||
setRemovedScreenshotIds((prev) => [...prev, id])
|
||||
}, [])
|
||||
|
||||
const fetchSteamDBVersion = useCallback(async () => {
|
||||
setSteamdbLoading(true)
|
||||
setSteamdbError(null)
|
||||
try {
|
||||
// Step 1: Try server-side strategies first
|
||||
const res = await fetch(`/api/games/${gameId}/steamdb-version`)
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
|
||||
// If server found something, use it
|
||||
if (data.versionString || data.buildId) {
|
||||
setSteamdbVersion({
|
||||
versionString: data.versionString,
|
||||
buildId: data.buildId,
|
||||
source: data.source,
|
||||
})
|
||||
}
|
||||
|
||||
// Step 2: If server suggests client-side fetch, run client strategies in browser
|
||||
if (data.needsClientFetch && data.clientStrategies?.length > 0) {
|
||||
await runClientStrategies(data.clientStrategies)
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — version detection is best-effort
|
||||
} finally {
|
||||
setSteamdbLoading(false)
|
||||
}
|
||||
}, [gameId])
|
||||
|
||||
// Run client-side strategies (uses browser IP to avoid server rate limits)
|
||||
const runClientStrategies = useCallback(async (strategyNames: string[]) => {
|
||||
// Dynamic import of client-side fetchers
|
||||
const { fetchStorePage } = await import("@/lib/version-fetchers/store-page")
|
||||
const { fetchCommunityHub } = await import("@/lib/version-fetchers/community-hub")
|
||||
const { fetchStoreApi } = await import("@/lib/version-fetchers/store-api")
|
||||
|
||||
const strategyMap: Record<string, (appId: number) => Promise<{ versionString: string | null; buildId: string | null; source: string; success: boolean }>> = {
|
||||
"Store Page Scrape": fetchStorePage,
|
||||
"Community Hub Scrape": fetchCommunityHub,
|
||||
"Store API Heuristic": fetchStoreApi,
|
||||
}
|
||||
|
||||
// We need the steamAppId — get it from a lightweight endpoint or from props
|
||||
const gameRes = await fetch(`/api/games/${gameId}`)
|
||||
if (!gameRes.ok) return
|
||||
const gameData = await gameRes.json()
|
||||
const steamAppId = gameData.steamAppId
|
||||
if (!steamAppId) return
|
||||
|
||||
// Run all requested client strategies in parallel
|
||||
const clientResults = await Promise.all(
|
||||
strategyNames.map(async (name) => {
|
||||
const fn = strategyMap[name]
|
||||
if (!fn) return null
|
||||
try {
|
||||
const result = await fn(steamAppId)
|
||||
return result
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const validResults = clientResults.filter(Boolean) as Array<{
|
||||
versionString: string | null
|
||||
buildId: string | null
|
||||
source: string
|
||||
success: boolean
|
||||
}>
|
||||
|
||||
// Merge with server result — send to server for final merge
|
||||
if (validResults.length > 0) {
|
||||
try {
|
||||
const mergeRes = await fetch(`/api/games/${gameId}/fetch-version-client`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clientResults: validResults }),
|
||||
})
|
||||
if (mergeRes.ok) {
|
||||
const merged = await mergeRes.json()
|
||||
if (merged.versionString || merged.buildId) {
|
||||
setSteamdbVersion({
|
||||
versionString: merged.versionString,
|
||||
buildId: merged.buildId,
|
||||
source: merged.source,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If merge fails, use best client result directly
|
||||
const bestClient = validResults.find((r) => r.versionString) ??
|
||||
validResults.find((r) => r.buildId)
|
||||
if (bestClient && (bestClient.versionString || bestClient.buildId)) {
|
||||
setSteamdbVersion({
|
||||
versionString: bestClient.versionString,
|
||||
buildId: bestClient.buildId,
|
||||
source: bestClient.source,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [gameId])
|
||||
|
||||
// Initialize existing screenshots when editing
|
||||
useEffect(() => {
|
||||
if (editEntry && (editEntry as any).screenshots && Array.isArray((editEntry as any).screenshots)) {
|
||||
setExistingScreenshots(
|
||||
(editEntry as any).screenshots.map((ss: any) => ({
|
||||
type: "existing" as const,
|
||||
id: ss.id,
|
||||
url: ss.url,
|
||||
width: ss.width,
|
||||
height: ss.height,
|
||||
orderIndex: ss.orderIndex,
|
||||
}))
|
||||
)
|
||||
}
|
||||
}, [editEntry])
|
||||
|
||||
// Fetch auto-detected version on mount (disabled by default — set NEXT_PUBLIC_VERSION_AUTO_FETCH=true to enable)
|
||||
useEffect(() => {
|
||||
if (process.env.NEXT_PUBLIC_VERSION_AUTO_FETCH === "true") {
|
||||
fetchSteamDBVersion()
|
||||
}
|
||||
}, [fetchSteamDBVersion])
|
||||
|
||||
const canProceed = () => {
|
||||
switch (currentStep) {
|
||||
case 0: // Setup
|
||||
if (hardwareSlug === "") return false
|
||||
// If new version selected, require version string
|
||||
if (selectedVersionId === "__new__" && !newVersionString.trim()) return false
|
||||
// SteamDB option is always valid (data comes from external source)
|
||||
if (selectedVersionId === "__steamdb__" && !steamdbVersion) return false
|
||||
return true
|
||||
case 1: // Performance
|
||||
return performance.fpsAvg !== undefined && performance.fpsAvg > 0
|
||||
case 2: // Settings
|
||||
return true
|
||||
case 3: // Environment
|
||||
return true
|
||||
case 4: // Review
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < STEPS.length - 1 && canProceed()) {
|
||||
setCurrentStep(currentStep + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(currentStep - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStepClick = (step: number) => {
|
||||
if (step <= currentStep) {
|
||||
setCurrentStep(step)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the final version ID — create a new version if needed
|
||||
const resolveVersionId = async (): Promise<string> => {
|
||||
if (selectedVersionId !== "__new__" && selectedVersionId !== "__steamdb__") {
|
||||
return selectedVersionId
|
||||
}
|
||||
|
||||
if (selectedVersionId === "__steamdb__" && steamdbVersion) {
|
||||
// Create version from SteamDB data
|
||||
const res = await fetch(`/api/games/${gameId}/versions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
versionString: steamdbVersion.versionString,
|
||||
buildId: steamdbVersion.buildId,
|
||||
isLatest: true,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || "Failed to create version from SteamDB")
|
||||
}
|
||||
const data = await res.json() as { id: string }
|
||||
return data.id
|
||||
}
|
||||
|
||||
// Create a new version via API
|
||||
setIsCreatingVersion(true)
|
||||
try {
|
||||
const res = await fetch(`/api/games/${gameId}/versions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
versionString: newVersionString.trim(),
|
||||
isLatest: false,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || "Failed to create game version")
|
||||
}
|
||||
|
||||
const data = await res.json() as { id: string }
|
||||
return data.id
|
||||
} finally {
|
||||
setIsCreatingVersion(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true)
|
||||
setSubmitPhase("uploading")
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const versionId = await resolveVersionId()
|
||||
|
||||
const payload = {
|
||||
versionId,
|
||||
hardwareSlug,
|
||||
fpsAvg: Number(performance.fpsAvg),
|
||||
fpsOnePercentLow: performance.fpsOnePercentLow !== undefined ? Number(performance.fpsOnePercentLow) : null,
|
||||
fpsLow: performance.fpsLow !== undefined ? Number(performance.fpsLow) : null,
|
||||
fpsHigh: performance.fpsHigh !== undefined ? Number(performance.fpsHigh) : null,
|
||||
loadTimeSsd: performance.loadTimeSsd !== undefined ? Number(performance.loadTimeSsd) : null,
|
||||
loadTimeSd: performance.loadTimeSd !== undefined ? Number(performance.loadTimeSd) : null,
|
||||
tdpWatts: performance.tdpWatts !== undefined ? Number(performance.tdpWatts) : null,
|
||||
youtubeVideoId: environment.youtubeVideoId || null,
|
||||
protonVersion: environment.protonVersion || null,
|
||||
osVersion: environment.osVersion || null,
|
||||
upscalerType: environment.upscalerType ?? "none",
|
||||
upscalerVersion: environment.upscalerVersion || null,
|
||||
frameGenMethod: environment.frameGenMethod ?? "none",
|
||||
launchOptions: environment.launchOptions || null,
|
||||
customSystem: environment.customSystem ?? false,
|
||||
removedScreenshotIds: removedScreenshotIds.length > 0 ? removedScreenshotIds : undefined,
|
||||
settingsJson: settingsJson.length > 0 ? settingsJson : null,
|
||||
userNotes: userNotes || null,
|
||||
antiCheatRelevant: antiCheat.antiCheatRelevant,
|
||||
antiCheatName: antiCheat.antiCheatName || null,
|
||||
antiCheatStatus: antiCheat.antiCheatStatus,
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("payload", JSON.stringify(payload))
|
||||
|
||||
for (const file of screenshotFiles) {
|
||||
formData.append("screenshots", file)
|
||||
}
|
||||
|
||||
setSubmitPhase("saving")
|
||||
|
||||
const url = editEntry
|
||||
? `/api/performance/${editEntry.id}/edit`
|
||||
: "/api/performance/submit"
|
||||
const method = editEntry ? "PATCH" : "POST"
|
||||
|
||||
const res = await fetch(url, { method, body: formData })
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || `Failed to ${editEntry ? "update" : "submit"} entry`)
|
||||
}
|
||||
|
||||
setSubmitPhase("success")
|
||||
setTimeout(() => {
|
||||
router.push(`/game/${gameId}`)
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
setSubmitPhase("error")
|
||||
setError(err instanceof Error ? err.message : "An error occurred")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (submitPhase === "success") {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center justify-center py-16 text-center"
|
||||
>
|
||||
<h2 className="text-xl font-bold mb-2">Entry Submitted!</h2>
|
||||
<p className="text-sm text-text/60">Redirecting to game page...</p>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Step Indicator */}
|
||||
<StepIndicator
|
||||
steps={STEPS}
|
||||
currentStep={currentStep}
|
||||
onStepClick={handleStepClick}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1 text-xs text-text/40">
|
||||
<span className="text-red-400">*</span> Required fields
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentStep}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="min-h-[300px]"
|
||||
>
|
||||
{currentStep === 0 && (
|
||||
<SetupStep
|
||||
gameId={gameId}
|
||||
gameVersions={gameVersions}
|
||||
hardwareSlug={hardwareSlug}
|
||||
onHardwareChange={handleHardwareChange}
|
||||
hardwareName={hardwareName}
|
||||
selectedVersionId={selectedVersionId}
|
||||
onVersionChange={setSelectedVersionId}
|
||||
newVersionString={newVersionString}
|
||||
onNewVersionStringChange={setNewVersionString}
|
||||
isCreatingVersion={isCreatingVersion}
|
||||
antiCheat={antiCheat}
|
||||
onAntiCheatChange={setAntiCheat}
|
||||
platformSupport={platformSupport}
|
||||
steamdbVersion={steamdbVersion}
|
||||
steamdbLoading={steamdbLoading}
|
||||
steamdbError={steamdbError}
|
||||
onRefreshSteamDB={fetchSteamDBVersion}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 1 && (
|
||||
<PerformanceStep value={performance} onChange={setPerformance} />
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<SettingsStep value={settingsJson} onChange={setSettingsJson} />
|
||||
)}
|
||||
{currentStep === 3 && (
|
||||
<EnvironmentStep value={environment} onChange={setEnvironment} />
|
||||
)}
|
||||
{currentStep === 4 && (
|
||||
<ReviewStep
|
||||
data={{
|
||||
hardwareSlug,
|
||||
hardwareName,
|
||||
hardwareWattHours,
|
||||
hardwareDeviceType,
|
||||
gameVersionLabel: getVersionLabel(),
|
||||
antiCheat,
|
||||
performance,
|
||||
settings: settingsJson,
|
||||
environment,
|
||||
}}
|
||||
userNotes={userNotes}
|
||||
onUserNotesChange={setUserNotes}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
error={error}
|
||||
screenshotFiles={screenshotFiles}
|
||||
onScreenshotFilesChange={setScreenshotFiles}
|
||||
submitPhase={submitPhase}
|
||||
existingScreenshots={existingScreenshots}
|
||||
onRemoveExistingScreenshot={handleRemoveExistingScreenshot}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex justify-between">
|
||||
{currentStep > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
className="px-6 py-2 rounded-lg border border-border text-sm font-medium text-text/70 hover:bg-text/5 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
{currentStep < 4 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed()}
|
||||
className="px-6 py-2 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed ml-auto"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { SettingsIcon, Gamepad2Icon } from "lucide-react"
|
||||
|
||||
interface HardwareDevice {
|
||||
slug: string
|
||||
name: string
|
||||
deviceType: string
|
||||
}
|
||||
|
||||
interface PlatformSupportEntry {
|
||||
hardwareSlug: string
|
||||
isSupported: boolean
|
||||
protonStatus: string
|
||||
}
|
||||
|
||||
interface GameData {
|
||||
id: string
|
||||
title: string | null
|
||||
developer: string | null
|
||||
publisher: string | null
|
||||
description: string | null
|
||||
source: string
|
||||
storeUrl: string | null
|
||||
headerImage: string | null
|
||||
capsuleImage: string | null
|
||||
genres: string[] | null
|
||||
releaseDate: string | null
|
||||
createdBy: string | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
game: GameData
|
||||
platformSupport: PlatformSupportEntry[]
|
||||
hardwareList: HardwareDevice[]
|
||||
isOwner: boolean
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
export function NonSteamEditForm({ game, platformSupport, hardwareList, isOwner, isAdmin }: Props) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [title, setTitle] = useState(game.title ?? "")
|
||||
const [developer, setDeveloper] = useState(game.developer ?? "")
|
||||
const [publisher, setPublisher] = useState(game.publisher ?? "")
|
||||
const [description, setDescription] = useState(game.description ?? "")
|
||||
const [storeUrl, setStoreUrl] = useState(game.storeUrl ?? "")
|
||||
const [headerImage, setHeaderImage] = useState(game.headerImage ?? "")
|
||||
const [capsuleImage, setCapsuleImage] = useState(game.capsuleImage ?? "")
|
||||
const [genresStr, setGenresStr] = useState(game.genres?.join(", ") ?? "")
|
||||
const [releaseDate, setReleaseDate] = useState(game.releaseDate ?? "")
|
||||
const [platforms, setPlatforms] = useState<PlatformSupportEntry[]>(platformSupport)
|
||||
|
||||
const canEdit = isOwner || isAdmin
|
||||
|
||||
const handleTogglePlatform = (slug: string) => {
|
||||
setPlatforms(prev => {
|
||||
const existing = prev.find(p => p.hardwareSlug === slug)
|
||||
if (existing) {
|
||||
return prev.filter(p => p.hardwareSlug !== slug)
|
||||
}
|
||||
return [...prev, { hardwareSlug: slug, isSupported: true, protonStatus: "unknown" }]
|
||||
})
|
||||
}
|
||||
|
||||
const handleProtonChange = (slug: string, protonStatus: string) => {
|
||||
setPlatforms(prev =>
|
||||
prev.map(p => p.hardwareSlug === slug ? { ...p, protonStatus } : p)
|
||||
)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/games/${game.id}/manual`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: title.trim(),
|
||||
developer: developer.trim() || null,
|
||||
publisher: publisher.trim() || null,
|
||||
description: description.trim() || null,
|
||||
storeUrl: storeUrl.trim() || null,
|
||||
headerImage: headerImage.trim() || null,
|
||||
capsuleImage: capsuleImage.trim() || null,
|
||||
genres: genresStr.split(",").map(g => g.trim()).filter(Boolean),
|
||||
releaseDate: releaseDate.trim() || null,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || "Failed to update game")
|
||||
}
|
||||
router.push(`/game/${game.id}`)
|
||||
router.refresh()
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to update game")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-4">
|
||||
<SettingsIcon className="h-10 w-10 text-text/20" />
|
||||
<p className="text-sm text-text/40">Only the creator or an admin can edit this game.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-text/60">Basic Info</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">Title *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">Developer</label>
|
||||
<input
|
||||
type="text"
|
||||
value={developer}
|
||||
onChange={e => setDeveloper(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">Publisher</label>
|
||||
<input
|
||||
type="text"
|
||||
value={publisher}
|
||||
onChange={e => setPublisher(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">Store URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={storeUrl}
|
||||
onChange={e => setStoreUrl(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={4}
|
||||
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 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">Genres (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={genresStr}
|
||||
onChange={e => setGenresStr(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">Release Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={releaseDate}
|
||||
onChange={e => setReleaseDate(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Images */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-text/60">Images</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">Header Image URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={headerImage}
|
||||
onChange={e => setHeaderImage(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">Capsule Image URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={capsuleImage}
|
||||
onChange={e => setCapsuleImage(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform Support */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-text/60">Platform Support</h2>
|
||||
<div className="flex flex-col gap-3">
|
||||
{hardwareList.map(device => {
|
||||
const active = platforms.find(p => p.hardwareSlug === device.slug)
|
||||
return (
|
||||
<div key={device.slug} className="flex items-center justify-between p-3 rounded-lg border border-border bg-text/3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Gamepad2Icon className="h-4 w-4 text-text/50" />
|
||||
<span className="text-sm font-medium">{device.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => handleTogglePlatform(device.slug)}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium border transition-colors cursor-pointer ${
|
||||
active
|
||||
? "bg-green-500/20 border-green-500/30 text-green-400"
|
||||
: "border-border text-text/40 hover:text-text/60"
|
||||
}`}
|
||||
>
|
||||
{active ? "Supported" : "Unsupported"}
|
||||
</button>
|
||||
{active && (
|
||||
<select
|
||||
value={active.protonStatus}
|
||||
onChange={e => handleProtonChange(device.slug, e.target.value)}
|
||||
className="text-xs bg-background border border-border rounded-md px-2 py-1.5 text-text/80 focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="unknown">Unknown</option>
|
||||
<option value="native">Native</option>
|
||||
<option value="proton">Proton</option>
|
||||
<option value="unsupported">Unsupported</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="px-4 py-2 rounded-lg border border-border text-sm text-text/70 hover:bg-text/5 transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !title.trim()}
|
||||
className="px-4 py-2 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors cursor-pointer"
|
||||
>
|
||||
{loading ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { StepIndicator } from "./step-indicator"
|
||||
import { NonSteamBasicInfoStep, BasicInfoData } from "./steps/non-steam-basic-info-step"
|
||||
import { NonSteamImageStep } from "./steps/non-steam-image-step"
|
||||
import { NonSteamPlatformStep, PlatformSupportItem } from "./steps/non-steam-platform-step"
|
||||
import { NonSteamReviewStep } from "./steps/non-steam-review-step"
|
||||
|
||||
const STEPS = [
|
||||
{ label: "Basic Info", tooltip: "Enter the game title, developer, publisher, and other details." },
|
||||
{ label: "Cover Art", tooltip: "Search SteamGridDB for cover art or enter an image URL." },
|
||||
{ label: "Platform Support", tooltip: "Select supported devices and Proton compatibility." },
|
||||
{ label: "Review", tooltip: "Review all details before submitting the game." },
|
||||
]
|
||||
|
||||
interface FormData {
|
||||
basicInfo: BasicInfoData
|
||||
headerImage: string
|
||||
capsuleImage: string
|
||||
platformSupport: PlatformSupportItem[]
|
||||
}
|
||||
|
||||
export function NonSteamWizard() {
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
basicInfo: {
|
||||
title: "",
|
||||
developer: "",
|
||||
publisher: "",
|
||||
description: "",
|
||||
source: "manual",
|
||||
storeUrl: "",
|
||||
genres: [],
|
||||
releaseDate: "",
|
||||
},
|
||||
headerImage: "",
|
||||
capsuleImage: "",
|
||||
platformSupport: [],
|
||||
})
|
||||
|
||||
const updateBasicInfo = useCallback(
|
||||
(value: BasicInfoData) => {
|
||||
setFormData((prev) => ({ ...prev, basicInfo: value }))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const updateImages = useCallback(
|
||||
(headerImage: string, capsuleImage: string) => {
|
||||
setFormData((prev) => ({ ...prev, headerImage, capsuleImage }))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const updatePlatformSupport = useCallback(
|
||||
(value: PlatformSupportItem[]) => {
|
||||
setFormData((prev) => ({ ...prev, platformSupport: value }))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const payload = {
|
||||
title: formData.basicInfo.title,
|
||||
developer: formData.basicInfo.developer || undefined,
|
||||
publisher: formData.basicInfo.publisher || undefined,
|
||||
description: formData.basicInfo.description || undefined,
|
||||
source: formData.basicInfo.source,
|
||||
storeUrl: formData.basicInfo.storeUrl || undefined,
|
||||
genres: formData.basicInfo.genres.length > 0 ? formData.basicInfo.genres : undefined,
|
||||
releaseDate: formData.basicInfo.releaseDate || undefined,
|
||||
headerImage: formData.headerImage || undefined,
|
||||
capsuleImage: formData.capsuleImage || undefined,
|
||||
platformSupport: formData.platformSupport.map((ps) => ({
|
||||
hardwareSlug: ps.hardwareSlug,
|
||||
isSupported: ps.isSupported,
|
||||
protonStatus: ps.protonStatus,
|
||||
})),
|
||||
}
|
||||
|
||||
const res = await fetch("/api/games/manual", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
if (res.status === 409 && data.existingGame) {
|
||||
setError(`Game already exists: "${data.existingGame.title}". Redirecting...`)
|
||||
setTimeout(() => router.push(`/game/${data.existingGame.id}`), 2000)
|
||||
return
|
||||
}
|
||||
throw new Error(data.error || "Failed to create game")
|
||||
}
|
||||
|
||||
setSuccess(true)
|
||||
setTimeout(() => router.push(`/game/${data.game.id}`), 1500)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create game")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canProceed = () => {
|
||||
switch (step) {
|
||||
case 0:
|
||||
return formData.basicInfo.title.trim().length > 0
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (step < STEPS.length - 1 && canProceed()) {
|
||||
setStep(step + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (step > 0) {
|
||||
setStep(step - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStepClick = (clickedStep: number) => {
|
||||
if (clickedStep <= step) {
|
||||
setStep(clickedStep)
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center justify-center py-16 text-center"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold mb-2">Game Created!</h2>
|
||||
<p className="text-sm text-text/60">Redirecting to game page...</p>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<StepIndicator steps={STEPS} currentStep={step} onStepClick={handleStepClick} />
|
||||
|
||||
<div className="flex items-center gap-1 text-xs text-text/40">
|
||||
<span className="text-red-400">*</span> Required fields
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={step}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="min-h-[300px]"
|
||||
>
|
||||
{step === 0 && (
|
||||
<NonSteamBasicInfoStep
|
||||
value={formData.basicInfo}
|
||||
onChange={updateBasicInfo}
|
||||
/>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<NonSteamImageStep
|
||||
headerImage={formData.headerImage}
|
||||
capsuleImage={formData.capsuleImage}
|
||||
onChange={updateImages}
|
||||
/>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<NonSteamPlatformStep
|
||||
value={formData.platformSupport}
|
||||
onChange={updatePlatformSupport}
|
||||
/>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<NonSteamReviewStep
|
||||
basicInfo={formData.basicInfo}
|
||||
headerImage={formData.headerImage}
|
||||
capsuleImage={formData.capsuleImage}
|
||||
platformSupport={formData.platformSupport}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={loading}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
{step < STEPS.length - 1 && (
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
disabled={step === 0}
|
||||
className="px-6 py-2 rounded-lg border border-border text-sm font-medium text-text/70 hover:bg-text/5 transition-colors disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed()}
|
||||
className="px-6 py-2 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "motion/react"
|
||||
import { Check, Info } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
interface Step {
|
||||
label: string
|
||||
tooltip: string
|
||||
}
|
||||
|
||||
interface StepIndicatorProps {
|
||||
steps: Step[]
|
||||
currentStep: number
|
||||
onStepClick?: (step: number) => void
|
||||
}
|
||||
|
||||
export function StepIndicator({
|
||||
steps,
|
||||
currentStep,
|
||||
onStepClick,
|
||||
}: StepIndicatorProps) {
|
||||
const [showTooltip, setShowTooltip] = useState<number | null>(null)
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* Desktop: horizontal steps */}
|
||||
<div className="hidden sm:flex items-center justify-between">
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = index < currentStep
|
||||
const isCurrent = index === currentStep
|
||||
const isClickable = index <= currentStep
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center flex-1">
|
||||
{/* Step circle with tooltip */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => isClickable && onStepClick?.(index)}
|
||||
onMouseEnter={() => setShowTooltip(index)}
|
||||
onMouseLeave={() => setShowTooltip(null)}
|
||||
disabled={!isClickable}
|
||||
className={`relative z-10 flex items-center justify-center w-8 h-8 rounded-full text-xs font-medium transition-all ${
|
||||
isCompleted
|
||||
? "bg-primary text-white"
|
||||
: isCurrent
|
||||
? "bg-primary/20 text-primary border-2 border-primary"
|
||||
: "bg-text/10 text-text/40"
|
||||
} ${isClickable ? "cursor-pointer" : "cursor-default"}`}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Tooltip */}
|
||||
{showTooltip === index && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="absolute top-full mt-2 left-1/2 -translate-x-1/2 z-20 px-3 py-2 rounded-lg bg-[#1a1020] border border-white/10 shadow-lg w-48"
|
||||
>
|
||||
<p className="text-xs font-medium text-text mb-1">
|
||||
{step.label}
|
||||
</p>
|
||||
<p className="text-xs text-text/60">{step.tooltip}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step label */}
|
||||
<span
|
||||
className={`ml-2 text-xs font-medium ${
|
||||
isCurrent ? "text-text" : "text-text/40"
|
||||
}`}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
|
||||
{/* Connector line */}
|
||||
{index < steps.length - 1 && (
|
||||
<div className="flex-1 mx-3">
|
||||
<div className="h-0.5 bg-text/10 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{
|
||||
width: isCompleted ? "100%" : "0%",
|
||||
}}
|
||||
className="h-full bg-primary rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Mobile: compact step indicator */}
|
||||
<div className="flex sm:hidden items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
Step {currentStep + 1} of {steps.length}
|
||||
</span>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={() => setShowTooltip(currentStep)}
|
||||
onMouseLeave={() => setShowTooltip(null)}
|
||||
className="text-text/40 hover:text-text transition-colors cursor-pointer"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</button>
|
||||
{showTooltip === currentStep && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="absolute top-full mt-2 right-0 z-20 px-3 py-2 rounded-lg bg-[#1a1020] border border-white/10 shadow-lg w-48"
|
||||
>
|
||||
<p className="text-xs font-medium text-text mb-1">
|
||||
{steps[currentStep].label}
|
||||
</p>
|
||||
<p className="text-xs text-text/60">
|
||||
{steps[currentStep].tooltip}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: step dots */}
|
||||
<div className="flex sm:hidden items-center gap-1.5 mt-3">
|
||||
{steps.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => index <= currentStep && onStepClick?.(index)}
|
||||
className={`h-1.5 rounded-full transition-all cursor-pointer ${
|
||||
index === currentStep
|
||||
? "w-6 bg-primary"
|
||||
: index < currentStep
|
||||
? "w-1.5 bg-primary/50"
|
||||
: "w-1.5 bg-text/20"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Shield, ShieldCheck, ShieldX, ShieldQuestion } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type AntiCheatData = {
|
||||
antiCheatRelevant: boolean
|
||||
antiCheatName: string
|
||||
antiCheatStatus: "none" | "supported" | "unsupported" | "unknown"
|
||||
}
|
||||
|
||||
interface PlatformSupportEntry {
|
||||
hardwareSlug: string
|
||||
antiCheatRelevant: boolean
|
||||
antiCheatStatus: "none" | "supported" | "unsupported" | "unknown"
|
||||
antiCheatName: string | null
|
||||
}
|
||||
|
||||
interface AntiCheatStepProps {
|
||||
hardwareSlug: string
|
||||
platformSupport: PlatformSupportEntry[]
|
||||
value: AntiCheatData
|
||||
onChange: (data: AntiCheatData) => void
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
supported: {
|
||||
icon: ShieldCheck,
|
||||
label: "Supported",
|
||||
color: "border-green-500/30 bg-green-500/10",
|
||||
textColor: "text-green-400",
|
||||
message: "Anti-cheat works on Linux/SteamOS. Multiplayer should work.",
|
||||
},
|
||||
unsupported: {
|
||||
icon: ShieldX,
|
||||
label: "Unsupported",
|
||||
color: "border-red-500/30 bg-red-500/10",
|
||||
textColor: "text-red-400",
|
||||
message: "Anti-cheat does not support Linux/SteamOS. Multiplayer may not work.",
|
||||
},
|
||||
unknown: {
|
||||
icon: ShieldQuestion,
|
||||
label: "Unknown",
|
||||
color: "border-yellow-500/30 bg-yellow-500/10",
|
||||
textColor: "text-yellow-400",
|
||||
message: "Compatibility is unknown. Multiplayer may or may not work.",
|
||||
},
|
||||
none: {
|
||||
icon: Shield,
|
||||
label: "None",
|
||||
color: "border-zinc-500/30 bg-zinc-500/10",
|
||||
textColor: "text-zinc-400",
|
||||
message: "No anti-cheat detected.",
|
||||
},
|
||||
} as const
|
||||
|
||||
export function AntiCheatStep({
|
||||
hardwareSlug,
|
||||
platformSupport,
|
||||
value,
|
||||
onChange,
|
||||
}: AntiCheatStepProps) {
|
||||
const [isEditing] = useState(false)
|
||||
|
||||
// Find the best existing entry to prefill:
|
||||
// Prefer the entry for the currently selected hardware,
|
||||
// otherwise fall back to any entry with anti-cheat data.
|
||||
const hardwareEntry = platformSupport.find(
|
||||
(p) => p.hardwareSlug === hardwareSlug && p.antiCheatRelevant
|
||||
)
|
||||
const anyEntry = platformSupport.find((p) => p.antiCheatRelevant)
|
||||
const existingEntry = hardwareEntry ?? anyEntry
|
||||
|
||||
// Prefill once when the component mounts if the current value is the default
|
||||
useEffect(() => {
|
||||
if (existingEntry && !isEditing) {
|
||||
onChange({
|
||||
antiCheatRelevant: existingEntry.antiCheatRelevant,
|
||||
antiCheatName: existingEntry.antiCheatName ?? "",
|
||||
antiCheatStatus: existingEntry.antiCheatStatus,
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hardwareSlug]) // re-prefill when hardware changes
|
||||
|
||||
const relevant = value.antiCheatRelevant
|
||||
const currentConfig = statusConfig[value.antiCheatStatus]
|
||||
const CurrentIcon = currentConfig.icon
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text">Anti-Cheat</h3>
|
||||
<p className="text-xs text-text/60 mt-1">
|
||||
Set the anti-cheat status for this game. This helps others know if multiplayer will work.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle: Has anti-cheat? */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...value,
|
||||
antiCheatRelevant: !relevant,
|
||||
antiCheatStatus: !relevant ? "unknown" : "none",
|
||||
antiCheatName: !relevant ? value.antiCheatName : "",
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
|
||||
relevant ? "bg-primary" : "bg-text/20"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
|
||||
relevant ? "translate-x-6" : "translate-x-1"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-text">Game uses anti-cheat</span>
|
||||
<span className="text-xs text-text/50">
|
||||
{relevant
|
||||
? "Yes — select the anti-cheat name and compatibility below"
|
||||
: "No anti-cheat software detected in this game"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{relevant && (
|
||||
<div className="space-y-4">
|
||||
{/* Anti-cheat name */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text/70 mb-1.5">
|
||||
Anti-Cheat Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value.antiCheatName}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, antiCheatName: e.target.value })
|
||||
}
|
||||
placeholder="e.g. Easy Anti-Cheat, BattlEye, Ricochet"
|
||||
className="w-full px-3 py-2 rounded-lg bg-background border border-border text-sm text-text placeholder:text-text/30 focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status radios */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text/70 mb-2">
|
||||
Compatibility on Linux / SteamOS
|
||||
</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{(
|
||||
[
|
||||
"supported",
|
||||
"unsupported",
|
||||
"unknown",
|
||||
] as const
|
||||
).map((status) => {
|
||||
const cfg = statusConfig[status]
|
||||
const Icon = cfg.icon
|
||||
const active = value.antiCheatStatus === status
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange({ ...value, antiCheatStatus: status })
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2.5 rounded-lg border text-left transition-colors",
|
||||
active
|
||||
? cfg.color
|
||||
: "border-border bg-text/[0.02] hover:bg-text/5"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
active ? cfg.textColor : "text-text/30"
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
active ? cfg.textColor : "text-text/60"
|
||||
)}
|
||||
>
|
||||
{cfg.label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview card */}
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border p-4",
|
||||
currentConfig.color
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<CurrentIcon className={cn("h-5 w-5", currentConfig.textColor)} />
|
||||
<h4 className="font-medium text-sm">
|
||||
{relevant && value.antiCheatName
|
||||
? value.antiCheatName
|
||||
: currentConfig.label}
|
||||
</h4>
|
||||
</div>
|
||||
<p className={cn("mt-2 text-sm", currentConfig.textColor)}>
|
||||
{currentConfig.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { Terminal, ChevronDown, Loader2, ToggleLeft, ToggleRight } from "lucide-react"
|
||||
import { api } from "@/lib/eden"
|
||||
|
||||
export const UPSCALER_TYPE_OPTIONS = [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "fsr", label: "AMD FSR" },
|
||||
{ value: "dlss", label: "NVIDIA DLSS" },
|
||||
{ value: "xess", label: "Intel XeSS" },
|
||||
{ value: "lsfg", label: "Lossless Scaling FG" },
|
||||
{ value: "other", label: "Other" },
|
||||
] as const
|
||||
|
||||
export const FRAME_GEN_OPTIONS = [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "fsr_fg", label: "FSR Frame Generation" },
|
||||
{ value: "dlss_fg", label: "DLSS Frame Generation" },
|
||||
{ value: "lsfg", label: "Lossless Scaling FG" },
|
||||
{ value: "other", label: "Other" },
|
||||
] as const
|
||||
|
||||
export interface EnvironmentData {
|
||||
protonVersion?: string
|
||||
osVersion?: string
|
||||
upscalerType?: string
|
||||
upscalerVersion?: string
|
||||
frameGenMethod?: string
|
||||
launchOptions?: string
|
||||
customSystem?: boolean
|
||||
youtubeVideoId?: string // NEW
|
||||
}
|
||||
|
||||
interface EnvironmentStepProps {
|
||||
value: EnvironmentData
|
||||
onChange: (value: EnvironmentData) => void
|
||||
}
|
||||
|
||||
function AutocompleteInput({
|
||||
label,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
field,
|
||||
}: {
|
||||
label: string
|
||||
placeholder: string
|
||||
value: string
|
||||
onChange: (val: string) => void
|
||||
field: "protonVersion" | "osVersion"
|
||||
}) {
|
||||
const [suggestions, setSuggestions] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [localValue, setLocalValue] = useState("")
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputValue = isFocused ? localValue : (value ?? "")
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const fetchSuggestions = useCallback(
|
||||
async (query: string) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await api.performance.autocomplete.get({ query: { field } })
|
||||
if (!res.error && res.data?.data) {
|
||||
const data = res.data.data
|
||||
const filtered = query
|
||||
? data.filter((s) => s.toLowerCase().includes(query.toLowerCase()))
|
||||
: data
|
||||
setSuggestions(filtered.slice(0, 8))
|
||||
}
|
||||
} catch {
|
||||
setSuggestions([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[field]
|
||||
)
|
||||
|
||||
const handleFocus = () => {
|
||||
setLocalValue(value ?? "")
|
||||
setIsFocused(true)
|
||||
setOpen(true)
|
||||
fetchSuggestions(value ?? "")
|
||||
}
|
||||
|
||||
const handleChange = (val: string) => {
|
||||
setLocalValue(val)
|
||||
onChange(val)
|
||||
fetchSuggestions(val)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const handleSelect = (val: string) => {
|
||||
setLocalValue(val)
|
||||
onChange(val)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5" ref={containerRef}>
|
||||
<label className="text-xs font-medium text-text/60">{label}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder={placeholder}
|
||||
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"
|
||||
/>
|
||||
{loading && (
|
||||
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-text/30" />
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{open && suggestions.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute z-10 mt-1 w-full max-h-48 overflow-y-auto rounded-lg border border-border bg-[#1a1020] shadow-lg"
|
||||
>
|
||||
{suggestions.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handleSelect(s)}
|
||||
className="w-full px-4 py-2 text-left text-sm text-text/80 hover:bg-primary/10 hover:text-text transition-colors cursor-pointer"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EnvironmentStep({ value, onChange }: EnvironmentStepProps) {
|
||||
const update = (field: keyof EnvironmentData, val: string) => {
|
||||
onChange({ ...value, [field]: val })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Terminal className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text">Environment</h3>
|
||||
<p className="text-xs text-text/60 mt-1">
|
||||
Describe the software environment used during testing.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<AutocompleteInput
|
||||
label="Proton Version"
|
||||
placeholder="e.g. Proton Experimental"
|
||||
value={value.protonVersion ?? ""}
|
||||
onChange={(val) => update("protonVersion", val)}
|
||||
field="protonVersion"
|
||||
/>
|
||||
|
||||
<AutocompleteInput
|
||||
label="OS Version"
|
||||
placeholder="e.g. SteamOS 3.5"
|
||||
value={value.osVersion ?? ""}
|
||||
onChange={(val) => update("osVersion", val)}
|
||||
field="osVersion"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">Upscaler Type</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={value.upscalerType ?? "none"}
|
||||
onChange={(e) => update("upscalerType", e.target.value)}
|
||||
className="w-full appearance-none px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors cursor-pointer"
|
||||
>
|
||||
{UPSCALER_TYPE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text/40 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{value.upscalerType && value.upscalerType !== "none" && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">Upscaler Version</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value.upscalerVersion ?? ""}
|
||||
onChange={(e) => update("upscalerVersion", e.target.value)}
|
||||
placeholder="e.g. 3.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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">Frame Gen Method</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={value.frameGenMethod ?? "none"}
|
||||
onChange={(e) => update("frameGenMethod", e.target.value)}
|
||||
className="w-full appearance-none px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors cursor-pointer"
|
||||
>
|
||||
{FRAME_GEN_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text/40 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">Launch Options</label>
|
||||
<textarea
|
||||
value={value.launchOptions ?? ""}
|
||||
onChange={(e) => update("launchOptions", e.target.value)}
|
||||
placeholder="e.g. PROTON_USE_WINED3D=1 %command%"
|
||||
rows={3}
|
||||
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 resize-none"
|
||||
/>
|
||||
<p className="text-xs text-text/40">
|
||||
Steam launch options or environment variables used.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">
|
||||
Custom / Modified System
|
||||
</label>
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ ...value, customSystem: !value.customSystem })}
|
||||
className={`flex items-center gap-2 text-sm cursor-pointer transition-colors ${
|
||||
value.customSystem ? "text-primary" : "text-text/40"
|
||||
}`}
|
||||
>
|
||||
{value.customSystem ? (
|
||||
<ToggleRight className="h-5 w-5" />
|
||||
) : (
|
||||
<ToggleLeft className="h-5 w-5" />
|
||||
)}
|
||||
{value.customSystem ? "Yes" : "No"}
|
||||
</button>
|
||||
<span className="text-xs text-text/40">
|
||||
Check if using custom firmware, OS, or mods that affect performance.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 pt-2">
|
||||
<label className="text-xs font-medium text-text/60">
|
||||
YouTube Video
|
||||
<span className="text-xs text-text/40 ml-1">Optional — link a gameplay video</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value.youtubeVideoId ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
// Accept full URLs or just the 11-char ID
|
||||
let videoId = val
|
||||
const ytMatch = val.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/)
|
||||
if (ytMatch) videoId = ytMatch[1]
|
||||
onChange({ ...value, youtubeVideoId: videoId || undefined })
|
||||
}}
|
||||
placeholder="YouTube video ID or URL"
|
||||
maxLength={200}
|
||||
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"
|
||||
/>
|
||||
{value.youtubeVideoId && !/^[a-zA-Z0-9_-]{11}$/.test(value.youtubeVideoId) && (
|
||||
<p className="text-xs text-red-400 mt-1">Invalid YouTube video ID (must be 11 characters)</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { motion } from "motion/react"
|
||||
import { Monitor, Gamepad2, Loader2 } from "lucide-react"
|
||||
import { api } from "@/lib/eden"
|
||||
|
||||
interface HardwareDevice {
|
||||
slug: string
|
||||
name: string
|
||||
deviceType: string
|
||||
}
|
||||
|
||||
interface HardwareStepProps {
|
||||
value: string
|
||||
onChange: (slug: string) => void
|
||||
}
|
||||
|
||||
export function HardwareStep({ value, onChange }: HardwareStepProps) {
|
||||
const [devices, setDevices] = useState<HardwareDevice[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function fetchHardware() {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const res = await api.performance.hardware.get()
|
||||
if (cancelled) return
|
||||
|
||||
if (res.error) {
|
||||
setError("Failed to load hardware devices")
|
||||
setDevices([])
|
||||
} else {
|
||||
setDevices(res.data?.data ?? [])
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setError("Failed to load hardware devices")
|
||||
setDevices([])
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchHardware()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="mt-4 text-sm text-text/60">Loading hardware devices...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 px-4 py-2 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-text/60">
|
||||
Select the hardware device you used to test this game.
|
||||
</p>
|
||||
<span className="text-red-400 text-xs">*</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{devices.map((device) => {
|
||||
const isSelected = device.slug === value
|
||||
const Icon = device.deviceType === "console" ? Gamepad2 : Monitor
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={device.slug}
|
||||
type="button"
|
||||
onClick={() => onChange(device.slug)}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className={`relative flex items-center gap-3 p-4 rounded-lg border transition-colors text-left cursor-pointer ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 ring-2 ring-primary/50"
|
||||
: "border-border bg-text/5 hover:bg-text/10 hover:border-border-active"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center justify-center w-10 h-10 rounded-lg ${
|
||||
isSelected ? "bg-primary/20 text-primary" : "bg-text/10 text-text/50"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text truncate">
|
||||
{device.name}
|
||||
</p>
|
||||
<p className="text-xs text-text/40 capitalize">
|
||||
{device.deviceType}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isSelected && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="w-4 h-4 rounded-full bg-primary flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<svg className="w-2.5 h-2.5 text-white" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{devices.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-10 border border-dashed border-border rounded-lg bg-text/5">
|
||||
<p className="text-sm text-text/50">No hardware devices available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { HardwareStep } from "./hardware-step"
|
||||
export { AntiCheatStep } from "./anti-cheat-step"
|
||||
export { PerformanceStep, type PerformanceData } from "./performance-step"
|
||||
export { SettingsStep } from "./settings-step"
|
||||
export { EnvironmentStep, type EnvironmentData } from "./environment-step"
|
||||
export { ReviewStep, type ReviewData } from "./review-step"
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback, useRef } from "react"
|
||||
import { Info, AlertTriangle, Loader2 } from "lucide-react"
|
||||
|
||||
export interface BasicInfoData {
|
||||
title: string
|
||||
developer: string
|
||||
publisher: string
|
||||
description: string
|
||||
source: "manual" | "gog" | "epic"
|
||||
storeUrl: string
|
||||
genres: string[]
|
||||
releaseDate: string
|
||||
}
|
||||
|
||||
interface NonSteamBasicInfoStepProps {
|
||||
value: BasicInfoData
|
||||
onChange: (value: BasicInfoData) => void
|
||||
}
|
||||
|
||||
interface DuplicateHint {
|
||||
id: string
|
||||
title: string
|
||||
source: string
|
||||
}
|
||||
|
||||
export function NonSteamBasicInfoStep({ value, onChange }: NonSteamBasicInfoStepProps) {
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [duplicates, setDuplicates] = useState<DuplicateHint[]>([])
|
||||
const [genreInput, setGenreInput] = useState(value.genres.join(", "))
|
||||
const lastCheckedTitle = useRef("")
|
||||
|
||||
const update = (field: keyof BasicInfoData, val: string | string[]) => {
|
||||
onChange({ ...value, [field]: val })
|
||||
}
|
||||
|
||||
const checkDuplicates = useCallback(async (title: string) => {
|
||||
if (!title.trim() || title.trim().length < 2) {
|
||||
setDuplicates([])
|
||||
return
|
||||
}
|
||||
if (lastCheckedTitle.current === title.trim()) return
|
||||
lastCheckedTitle.current = title.trim()
|
||||
|
||||
setChecking(true)
|
||||
try {
|
||||
const res = await fetch(`/api/search/unified?q=${encodeURIComponent(title)}`)
|
||||
if (!res.ok) {
|
||||
setDuplicates([])
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
const results = (data.results || []) as Array<{
|
||||
id?: string
|
||||
title: string
|
||||
source: string
|
||||
}>
|
||||
const matches = results
|
||||
.filter(
|
||||
(r) =>
|
||||
r.title.toLowerCase().includes(title.toLowerCase()) ||
|
||||
title.toLowerCase().includes(r.title.toLowerCase())
|
||||
)
|
||||
.slice(0, 3)
|
||||
.map((r) => ({
|
||||
id: r.id || String(r.title),
|
||||
title: r.title,
|
||||
source: r.source,
|
||||
}))
|
||||
setDuplicates(matches)
|
||||
} catch {
|
||||
setDuplicates([])
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleGenreBlur = () => {
|
||||
const parsed = genreInput
|
||||
.split(",")
|
||||
.map((g) => g.trim())
|
||||
.filter((g) => g.length > 0)
|
||||
onChange({ ...value, genres: parsed })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text">Basic Info</h3>
|
||||
<p className="text-xs text-text/60 mt-1">
|
||||
Enter the game details. Title is required.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">
|
||||
Title <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={value.title}
|
||||
onChange={(e) => update("title", e.target.value)}
|
||||
onBlur={(e) => checkDuplicates(e.target.value)}
|
||||
placeholder="e.g. Hollow Knight"
|
||||
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"
|
||||
/>
|
||||
{checking && (
|
||||
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-text/30" />
|
||||
)}
|
||||
</div>
|
||||
{duplicates.length > 0 && (
|
||||
<div className="flex items-start gap-2 mt-2">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-amber-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-xs text-amber-400">
|
||||
<span className="font-medium">Did you mean:</span>{" "}
|
||||
{duplicates.map((d, i) => (
|
||||
<span key={d.id}>
|
||||
<a
|
||||
href={`/game/${d.id}`}
|
||||
className="underline hover:text-amber-300"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{d.title}
|
||||
</a>
|
||||
{i < duplicates.length - 1 ? ", " : ""}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">Source</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={value.source}
|
||||
onChange={(e) =>
|
||||
update("source", e.target.value as "manual" | "gog" | "epic")
|
||||
}
|
||||
className="w-full appearance-none px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<option value="manual">Manual Entry</option>
|
||||
<option value="gog">GOG</option>
|
||||
<option value="epic">Epic Games Store</option>
|
||||
</select>
|
||||
<svg
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text/40 pointer-events-none"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Developer / Publisher */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">Developer</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value.developer}
|
||||
onChange={(e) => update("developer", e.target.value)}
|
||||
placeholder="e.g. Team Cherry"
|
||||
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 className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">Publisher</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value.publisher}
|
||||
onChange={(e) => update("publisher", e.target.value)}
|
||||
placeholder="e.g. Team Cherry"
|
||||
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>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">Description</label>
|
||||
<textarea
|
||||
value={value.description}
|
||||
onChange={(e) => update("description", e.target.value)}
|
||||
placeholder="Short game description..."
|
||||
rows={4}
|
||||
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 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Store URL */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">Store URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={value.storeUrl}
|
||||
onChange={(e) => update("storeUrl", e.target.value)}
|
||||
placeholder="https://..."
|
||||
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>
|
||||
|
||||
{/* Genres */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">Genres</label>
|
||||
<input
|
||||
type="text"
|
||||
value={genreInput}
|
||||
onChange={(e) => setGenreInput(e.target.value)}
|
||||
onBlur={handleGenreBlur}
|
||||
placeholder="Action, Adventure, Platformer"
|
||||
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"
|
||||
/>
|
||||
<p className="text-[10px] text-text/30">Comma-separated list</p>
|
||||
</div>
|
||||
|
||||
{/* Release Date */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">Release Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={value.releaseDate}
|
||||
onChange={(e) => update("releaseDate", e.target.value)}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { ImageIcon, Loader2, Search, ExternalLink } from "lucide-react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
|
||||
interface SteamGridResult {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface SteamGridImage {
|
||||
id: number
|
||||
url: string
|
||||
thumb: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
interface NonSteamImageStepProps {
|
||||
headerImage: string
|
||||
capsuleImage: string
|
||||
onChange: (headerImage: string, capsuleImage: string) => void
|
||||
}
|
||||
|
||||
export function NonSteamImageStep({
|
||||
headerImage,
|
||||
capsuleImage,
|
||||
onChange,
|
||||
}: NonSteamImageStepProps) {
|
||||
const [query, setQuery] = useState("")
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [loadingGrids, setLoadingGrids] = useState(false)
|
||||
const [results, setResults] = useState<SteamGridResult[]>([])
|
||||
const [grids, setGrids] = useState<SteamGridImage[]>([])
|
||||
const [selectedGameId, setSelectedGameId] = useState<number | null>(null)
|
||||
const [manualUrl, setManualUrl] = useState("")
|
||||
const [showManual, setShowManual] = useState(false)
|
||||
|
||||
const search = useCallback(async () => {
|
||||
if (!query.trim() || query.trim().length < 2) return
|
||||
setSearching(true)
|
||||
setResults([])
|
||||
setGrids([])
|
||||
setSelectedGameId(null)
|
||||
try {
|
||||
const res = await fetch(`/api/steamgrid/search?q=${encodeURIComponent(query)}`)
|
||||
if (!res.ok) throw new Error("Search failed")
|
||||
const data = await res.json()
|
||||
setResults((data.data || []).slice(0, 8))
|
||||
} catch {
|
||||
setResults([])
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}, [query])
|
||||
|
||||
const fetchGrids = useCallback(async (gameId: number) => {
|
||||
setLoadingGrids(true)
|
||||
setGrids([])
|
||||
setSelectedGameId(gameId)
|
||||
try {
|
||||
const res = await fetch(`/api/steamgrid/grids/${gameId}?styles=alternate`)
|
||||
if (!res.ok) throw new Error("Failed to fetch grids")
|
||||
const data = await res.json()
|
||||
const images: SteamGridImage[] = (data.data || [])
|
||||
.filter((g: SteamGridImage) => g.url)
|
||||
.sort((a: SteamGridImage, b: SteamGridImage) => {
|
||||
// Prefer 600x900
|
||||
const aScore = a.width === 600 && a.height === 900 ? 2 : a.width === 342 ? 1 : 0
|
||||
const bScore = b.width === 600 && b.height === 900 ? 2 : b.width === 342 ? 1 : 0
|
||||
return bScore - aScore
|
||||
})
|
||||
setGrids(images.slice(0, 12))
|
||||
} catch {
|
||||
setGrids([])
|
||||
} finally {
|
||||
setLoadingGrids(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSelectImage = (url: string) => {
|
||||
// Use the same image for both header and capsule
|
||||
onChange(url, url)
|
||||
}
|
||||
|
||||
const handleManualSubmit = () => {
|
||||
if (!manualUrl.trim()) return
|
||||
onChange(manualUrl.trim(), manualUrl.trim())
|
||||
}
|
||||
|
||||
const currentImage = capsuleImage || headerImage
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<ImageIcon className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text">Cover Art</h3>
|
||||
<p className="text-xs text-text/60 mt-1">
|
||||
Search SteamGridDB for cover art, or paste an image URL manually.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && search()}
|
||||
placeholder="Search SteamGridDB..."
|
||||
className="w-full px-4 py-3 pr-10 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"
|
||||
/>
|
||||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text/30" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={search}
|
||||
disabled={searching || query.trim().length < 2}
|
||||
className="px-4 py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{searching ? <Loader2 className="h-4 w-4 animate-spin" /> : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<AnimatePresence>
|
||||
{results.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 4 }}
|
||||
className="flex flex-wrap gap-2"
|
||||
>
|
||||
{results.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => fetchGrids(r.id)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-colors cursor-pointer ${
|
||||
selectedGameId === r.id
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border bg-text/5 text-text/70 hover:bg-text/10 hover:border-text/30"
|
||||
}`}
|
||||
>
|
||||
{r.name}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Grid images */}
|
||||
{loadingGrids && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{grids.length > 0 && !loadingGrids && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2"
|
||||
>
|
||||
{grids.map((g) => {
|
||||
const isSelected = capsuleImage === g.url
|
||||
return (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectImage(g.url)}
|
||||
className={`relative aspect-[2/3] rounded-lg overflow-hidden border transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? "border-primary ring-2 ring-primary/50"
|
||||
: "border-border hover:border-text/30"
|
||||
}`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={g.thumb || g.url}
|
||||
alt="Cover"
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{isSelected && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-primary/20">
|
||||
<div className="w-6 h-6 rounded-full bg-primary flex items-center justify-center">
|
||||
<svg className="w-3.5 h-3.5 text-white" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Manual URL */}
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowManual(!showManual)}
|
||||
className="text-xs text-text/50 hover:text-primary transition-colors cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{showManual ? "Hide manual URL input" : "Enter image URL manually"}
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showManual && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-2 overflow-hidden"
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
value={manualUrl}
|
||||
onChange={(e) => setManualUrl(e.target.value)}
|
||||
placeholder="https://example.com/cover.jpg"
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleManualSubmit}
|
||||
disabled={!manualUrl.trim()}
|
||||
className="px-4 py-2 rounded-lg border border-border text-xs font-medium text-text/70 hover:bg-text/5 transition-colors disabled:opacity-40 cursor-pointer"
|
||||
>
|
||||
Use this URL
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{currentImage && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-text/60">Selected Cover</p>
|
||||
<div className="w-32 aspect-[2/3] rounded-lg overflow-hidden border border-border bg-text/5">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={currentImage}
|
||||
alt="Selected cover"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { Monitor, Gamepad2, Loader2 } from "lucide-react"
|
||||
|
||||
interface HardwareDevice {
|
||||
slug: string
|
||||
name: string
|
||||
deviceType: string
|
||||
}
|
||||
|
||||
export interface PlatformSupportItem {
|
||||
hardwareSlug: string
|
||||
isSupported: boolean
|
||||
protonStatus: "native" | "proton" | "unsupported" | "unknown"
|
||||
}
|
||||
|
||||
interface NonSteamPlatformStepProps {
|
||||
value: PlatformSupportItem[]
|
||||
onChange: (value: PlatformSupportItem[]) => void
|
||||
}
|
||||
|
||||
const PROTON_OPTIONS: { value: PlatformSupportItem["protonStatus"]; label: string }[] = [
|
||||
{ value: "native", label: "Native" },
|
||||
{ value: "proton", label: "Proton" },
|
||||
{ value: "unsupported", label: "Unsupported" },
|
||||
{ value: "unknown", label: "Unknown" },
|
||||
]
|
||||
|
||||
export function NonSteamPlatformStep({ value, onChange }: NonSteamPlatformStepProps) {
|
||||
const [devices, setDevices] = useState<HardwareDevice[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function fetchHardware() {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const res = await fetch("/api/performance/hardware")
|
||||
if (!res.ok) throw new Error("Failed to load hardware")
|
||||
const data = await res.json()
|
||||
if (!cancelled) {
|
||||
setDevices(data.data || [])
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setError("Failed to load hardware devices")
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchHardware()
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const getItem = (slug: string): PlatformSupportItem => {
|
||||
return (
|
||||
value.find((v) => v.hardwareSlug === slug) || {
|
||||
hardwareSlug: slug,
|
||||
isSupported: false,
|
||||
protonStatus: "unknown",
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateItem = (slug: string, patch: Partial<PlatformSupportItem>) => {
|
||||
const existing = value.find((v) => v.hardwareSlug === slug)
|
||||
let next: PlatformSupportItem[]
|
||||
if (existing) {
|
||||
next = value.map((v) =>
|
||||
v.hardwareSlug === slug ? { ...v, ...patch } : v
|
||||
)
|
||||
} else {
|
||||
next = [
|
||||
...value,
|
||||
{
|
||||
hardwareSlug: slug,
|
||||
isSupported: patch.isSupported ?? false,
|
||||
protonStatus: patch.protonStatus ?? "unknown",
|
||||
},
|
||||
]
|
||||
}
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
const toggleSupported = (slug: string) => {
|
||||
const item = getItem(slug)
|
||||
updateItem(slug, { isSupported: !item.isSupported })
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="mt-4 text-sm text-text/60">Loading hardware devices...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Monitor className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text">Platform Support</h3>
|
||||
<p className="text-xs text-text/60 mt-1">
|
||||
Select devices this game supports and its Proton status.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{devices.map((device) => {
|
||||
const item = getItem(device.slug)
|
||||
const Icon = device.deviceType === "console" ? Gamepad2 : Monitor
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={device.slug}
|
||||
layout
|
||||
className={`rounded-lg border p-4 transition-colors ${
|
||||
item.isSupported
|
||||
? "border-primary/30 bg-primary/5"
|
||||
: "border-border bg-text/[0.02]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex items-center justify-center w-9 h-9 rounded-lg ${
|
||||
item.isSupported
|
||||
? "bg-primary/15 text-primary"
|
||||
: "bg-text/10 text-text/50"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text">{device.name}</p>
|
||||
<p className="text-[11px] text-text/40 capitalize">
|
||||
{device.deviceType}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSupported(device.slug)}
|
||||
className={`relative inline-flex h-6 w-10 shrink-0 cursor-pointer rounded-full transition-colors duration-200 ${
|
||||
item.isSupported ? "bg-primary" : "bg-text/20"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out mt-0.5 ${
|
||||
item.isSupported ? "translate-x-5" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{item.isSupported && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mt-3 pt-3 border-t border-border/50 overflow-hidden"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">
|
||||
Proton Status
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PROTON_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
updateItem(device.slug, { protonStatus: opt.value })
|
||||
}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium border transition-colors cursor-pointer ${
|
||||
item.protonStatus === opt.value
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border bg-text/5 text-text/60 hover:bg-text/10"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
|
||||
{devices.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-10 border border-dashed border-border rounded-lg bg-text/5">
|
||||
<p className="text-sm text-text/50">No hardware devices available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client"
|
||||
|
||||
import { ImageIcon, Monitor, Info, FileText, Link } from "lucide-react"
|
||||
import { BasicInfoData } from "./non-steam-basic-info-step"
|
||||
import { PlatformSupportItem } from "./non-steam-platform-step"
|
||||
|
||||
interface NonSteamReviewStepProps {
|
||||
basicInfo: BasicInfoData
|
||||
headerImage: string
|
||||
capsuleImage: string
|
||||
platformSupport: PlatformSupportItem[]
|
||||
onSubmit: () => void
|
||||
isSubmitting: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
function SectionHeader({ icon: Icon, label }: { icon: React.ElementType; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className="h-3.5 w-3.5 text-primary" />
|
||||
<span className="text-xs font-semibold text-text/80 uppercase tracking-wider">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50 last:border-b-0">
|
||||
<span className="text-xs text-text/50">{label}</span>
|
||||
<span className="text-xs text-text font-medium">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
manual: "Manual Entry",
|
||||
gog: "GOG",
|
||||
epic: "Epic Games Store",
|
||||
}
|
||||
|
||||
const PROTON_LABELS: Record<string, string> = {
|
||||
native: "Native",
|
||||
proton: "Proton",
|
||||
unsupported: "Unsupported",
|
||||
unknown: "Unknown",
|
||||
}
|
||||
|
||||
export function NonSteamReviewStep({
|
||||
basicInfo,
|
||||
headerImage,
|
||||
capsuleImage,
|
||||
platformSupport,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
error,
|
||||
}: NonSteamReviewStepProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text">Review & Submit</h3>
|
||||
<p className="text-xs text-text/60 mt-1">
|
||||
Review all details before submitting the game to DeckyVault.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Basic Info */}
|
||||
<div className="rounded-lg border border-border bg-text/5 p-4">
|
||||
<SectionHeader icon={FileText} label="Basic Info" />
|
||||
<SummaryRow label="Title" value={basicInfo.title || "—"} />
|
||||
<SummaryRow label="Source" value={SOURCE_LABELS[basicInfo.source] || basicInfo.source} />
|
||||
<SummaryRow label="Developer" value={basicInfo.developer || "—"} />
|
||||
<SummaryRow label="Publisher" value={basicInfo.publisher || "—"} />
|
||||
<SummaryRow
|
||||
label="Genres"
|
||||
value={
|
||||
basicInfo.genres.length > 0 ? basicInfo.genres.join(", ") : "—"
|
||||
}
|
||||
/>
|
||||
<SummaryRow label="Release Date" value={basicInfo.releaseDate || "—"} />
|
||||
{basicInfo.storeUrl && (
|
||||
<SummaryRow
|
||||
label="Store URL"
|
||||
value={
|
||||
<a
|
||||
href={basicInfo.storeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline truncate max-w-[200px] block"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Link className="h-3 w-3" />
|
||||
{basicInfo.storeUrl}
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{basicInfo.description && (
|
||||
<div className="mt-2">
|
||||
<p className="text-[10px] text-text/40 mb-1">Description</p>
|
||||
<p className="text-[11px] text-text/70 leading-relaxed">{basicInfo.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cover Art */}
|
||||
<div className="rounded-lg border border-border bg-text/5 p-4">
|
||||
<SectionHeader icon={ImageIcon} label="Cover Art" />
|
||||
{(headerImage || capsuleImage) ? (
|
||||
<div className="w-28 aspect-[2/3] rounded-lg overflow-hidden border border-border bg-text/5">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={capsuleImage || headerImage}
|
||||
alt="Cover preview"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-text/40">No cover art selected</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Platform Support */}
|
||||
<div className="rounded-lg border border-border bg-text/5 p-4 lg:col-span-2">
|
||||
<SectionHeader icon={Monitor} label="Platform Support" />
|
||||
{platformSupport.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{platformSupport.map((ps) => (
|
||||
<span
|
||||
key={ps.hardwareSlug}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs border ${
|
||||
ps.isSupported
|
||||
? "border-primary/30 bg-primary/10 text-primary"
|
||||
: "border-border bg-text/5 text-text/40"
|
||||
}`}
|
||||
>
|
||||
{ps.isSupported ? (
|
||||
<>
|
||||
{ps.hardwareSlug}
|
||||
<span className="text-text/40">·</span>
|
||||
{PROTON_LABELS[ps.protonStatus] || ps.protonStatus}
|
||||
</>
|
||||
) : (
|
||||
<>{ps.hardwareSlug} — Unsupported</>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-text/40">No platform support configured</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3">
|
||||
<span className="text-xs text-red-400">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="w-full flex items-center justify-center gap-2 px-6 py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
"Submit Game"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { Gauge, Timer, Zap } from "lucide-react"
|
||||
|
||||
export interface PerformanceData {
|
||||
fpsAvg?: number
|
||||
fpsOnePercentLow?: number
|
||||
fpsLow?: number
|
||||
fpsHigh?: number
|
||||
loadTimeSsd?: number
|
||||
loadTimeSd?: number
|
||||
tdpWatts?: number
|
||||
}
|
||||
|
||||
interface PerformanceStepProps {
|
||||
value: PerformanceData
|
||||
onChange: (value: PerformanceData) => void
|
||||
}
|
||||
|
||||
export function PerformanceStep({ value, onChange }: PerformanceStepProps) {
|
||||
const error = useMemo(() => {
|
||||
if (value.fpsAvg !== undefined && value.fpsAvg !== null && (isNaN(value.fpsAvg) || value.fpsAvg <= 0)) {
|
||||
return "FPS Average must be greater than 0"
|
||||
}
|
||||
return null
|
||||
}, [value.fpsAvg])
|
||||
|
||||
const update = (field: keyof PerformanceData, val: string) => {
|
||||
const isDecimalField = field === "loadTimeSsd" || field === "loadTimeSd"
|
||||
const cleaned = isDecimalField
|
||||
? val.replace(/[^0-9.]/g, "").replace(/(\..*)\./g, "$1")
|
||||
: val.replace(/[^0-9]/g, "")
|
||||
const num = cleaned === "" || cleaned === "." ? undefined : Number(cleaned)
|
||||
onChange({ ...value, [field]: num })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* FPS Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gauge className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold text-text">Frame Rate</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">
|
||||
FPS Average <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={value.fpsAvg ?? ""}
|
||||
onChange={(e) => update("fpsAvg", e.target.value)}
|
||||
placeholder="e.g. 45"
|
||||
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"
|
||||
/>
|
||||
<p className="text-[10px] text-text/30">Required — average framerate during gameplay</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">1% Low FPS</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={value.fpsOnePercentLow ?? ""}
|
||||
onChange={(e) => update("fpsOnePercentLow", e.target.value)}
|
||||
placeholder="e.g. 32"
|
||||
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"
|
||||
/>
|
||||
<p className="text-[10px] text-text/30">FPS at the 1st percentile — represents worst 1% of frametimes</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">FPS Low</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={value.fpsLow ?? ""}
|
||||
onChange={(e) => update("fpsLow", e.target.value)}
|
||||
placeholder="e.g. 30"
|
||||
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 className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">FPS High</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={value.fpsHigh ?? ""}
|
||||
onChange={(e) => update("fpsHigh", e.target.value)}
|
||||
placeholder="e.g. 60"
|
||||
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>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Load Time Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Timer className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold text-text">Load Times</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">Load Time SSD (seconds)</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={value.loadTimeSsd ?? ""}
|
||||
onChange={(e) => update("loadTimeSsd", e.target.value)}
|
||||
placeholder="e.g. 12.5"
|
||||
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 className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">Load Time SD Card (seconds)</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={value.loadTimeSd ?? ""}
|
||||
onChange={(e) => update("loadTimeSd", e.target.value)}
|
||||
placeholder="e.g. 35.0"
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Power Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold text-text">Power</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">
|
||||
TDP (Watts)
|
||||
<span className="text-xs text-text/40 ml-1">Optional — thermal design power cap during benchmark</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={value.tdpWatts ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value.replace(/[^0-9.]/g, "").replace(/(\..*)\./g, "$1")
|
||||
const num = val === "" || val === "." ? undefined : Number(val)
|
||||
onChange({ ...value, tdpWatts: num })
|
||||
}}
|
||||
placeholder="e.g. 10"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,724 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { motion, Reorder, useDragControls } from "motion/react"
|
||||
import {
|
||||
Send,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Monitor,
|
||||
Gauge,
|
||||
SlidersHorizontal,
|
||||
Terminal,
|
||||
FileText,
|
||||
ImagePlus,
|
||||
X,
|
||||
GripVertical,
|
||||
Upload,
|
||||
ImageIcon,
|
||||
} from "lucide-react"
|
||||
import { TiptapEditor } from "@/components/tiptap-editor"
|
||||
import type { SettingCategory } from "@/components/wizard/settings-editor"
|
||||
import type { PerformanceData } from "./performance-step"
|
||||
import type { EnvironmentData } from "./environment-step"
|
||||
import { UPSCALER_TYPE_OPTIONS, FRAME_GEN_OPTIONS } from "./environment-step"
|
||||
|
||||
export interface ExistingScreenshot {
|
||||
type: "existing"
|
||||
id: string
|
||||
url: string
|
||||
width: number
|
||||
height: number
|
||||
orderIndex: number
|
||||
}
|
||||
|
||||
export interface ReviewData {
|
||||
hardwareSlug: string
|
||||
hardwareName: string
|
||||
hardwareWattHours: number | null
|
||||
hardwareDeviceType: string | null
|
||||
gameVersionLabel: string
|
||||
antiCheat: {
|
||||
antiCheatRelevant: boolean
|
||||
antiCheatName: string
|
||||
antiCheatStatus: "none" | "supported" | "unsupported" | "unknown"
|
||||
}
|
||||
performance: PerformanceData
|
||||
settings: SettingCategory[]
|
||||
environment: EnvironmentData
|
||||
}
|
||||
|
||||
interface ReviewStepProps {
|
||||
data: ReviewData
|
||||
userNotes: string
|
||||
onUserNotesChange: (notes: string) => void
|
||||
onSubmit: () => void
|
||||
isSubmitting: boolean
|
||||
error: string | null
|
||||
screenshotFiles: File[]
|
||||
onScreenshotFilesChange: (files: File[]) => void
|
||||
submitPhase: "idle" | "uploading" | "saving" | "success" | "error"
|
||||
existingScreenshots?: ExistingScreenshot[]
|
||||
onRemoveExistingScreenshot?: (id: string) => void
|
||||
}
|
||||
|
||||
function SectionHeader({
|
||||
icon: Icon,
|
||||
label,
|
||||
}: {
|
||||
icon: React.ElementType
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<div className='flex items-center gap-2 mb-2'>
|
||||
<Icon className='h-3.5 w-3.5 text-primary' />
|
||||
<span className='text-xs font-semibold text-text/80 uppercase tracking-wider'>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryRow({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className='flex items-center justify-between py-1.5 border-b border-border/50 last:border-b-0'>
|
||||
<span className='text-xs text-text/50'>{label}</span>
|
||||
<span className='text-xs text-text font-medium'>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatNumber(val: number | undefined): string {
|
||||
if (val === undefined || val === null) return "Not set"
|
||||
return String(val)
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function ScreenshotCard({
|
||||
file,
|
||||
url,
|
||||
index,
|
||||
onRemove,
|
||||
}: {
|
||||
file: File
|
||||
url: string
|
||||
index: number
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const dragControls = useDragControls()
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
value={file}
|
||||
dragListener={false}
|
||||
dragControls={dragControls}
|
||||
as='div'
|
||||
className='group relative rounded-xl border border-border bg-text/3 overflow-hidden shadow-sm hover:shadow-md hover:border-primary/30 transition-shadow transition-colors transition-[border-color]'
|
||||
whileDrag={{
|
||||
scale: 1.02,
|
||||
boxShadow: "0 12px 40px rgba(0,0,0,0.3)",
|
||||
zIndex: 20,
|
||||
}}
|
||||
>
|
||||
{/* Image */}
|
||||
<div className='relative aspect-video'>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={url}
|
||||
alt={file.name}
|
||||
className='w-full h-full object-cover'
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* Overlay gradient */}
|
||||
<div className='absolute inset-0 bg-linear-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity' />
|
||||
|
||||
{/* Drag handle (top-left) */}
|
||||
<div
|
||||
className='absolute top-2 left-2 p-2 rounded-lg bg-black/50 text-white/80 hover:text-white hover:bg-black/70 backdrop-blur-sm cursor-grab active:cursor-grabbing transition-colors opacity-0 group-hover:opacity-100 select-none touch-none'
|
||||
onPointerDown={(e) => { e.preventDefault(); dragControls.start(e) }}
|
||||
>
|
||||
<GripVertical className='h-4 w-4' />
|
||||
</div>
|
||||
|
||||
{/* Remove button (top-right) */}
|
||||
<button
|
||||
type='button'
|
||||
onClick={onRemove}
|
||||
className='absolute top-2 right-2 p-1.5 rounded-lg bg-black/50 text-white/80 hover:text-white hover:bg-red-500/80 backdrop-blur-sm transition-colors opacity-0 group-hover:opacity-100 cursor-pointer'
|
||||
title='Remove screenshot'
|
||||
>
|
||||
<X className='h-3.5 w-3.5' />
|
||||
</button>
|
||||
|
||||
{/* Index badge */}
|
||||
<div className='absolute bottom-2 left-2 px-2 py-0.5 rounded-md bg-black/50 backdrop-blur-sm text-[10px] font-medium text-white/90 opacity-0 group-hover:opacity-100 transition-opacity'>
|
||||
#{index + 1}
|
||||
</div>
|
||||
|
||||
{/* File info (bottom-right) */}
|
||||
<div className='absolute bottom-2 right-2 px-2 py-0.5 rounded-md bg-black/50 backdrop-blur-sm text-[10px] text-white/70 opacity-0 group-hover:opacity-100 transition-opacity truncate max-w-30'>
|
||||
{formatFileSize(file.size)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filename bar */}
|
||||
<div className='px-3 py-2 border-t border-border/50'>
|
||||
<p
|
||||
className='text-[11px] text-text/60 truncate'
|
||||
title={file.name}
|
||||
>
|
||||
{file.name}
|
||||
</p>
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReviewStep({
|
||||
data,
|
||||
userNotes,
|
||||
onUserNotesChange,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
error,
|
||||
screenshotFiles,
|
||||
onScreenshotFilesChange,
|
||||
submitPhase,
|
||||
existingScreenshots,
|
||||
onRemoveExistingScreenshot,
|
||||
}: ReviewStepProps) {
|
||||
const {
|
||||
hardwareName,
|
||||
gameVersionLabel,
|
||||
antiCheat,
|
||||
performance,
|
||||
environment,
|
||||
settings,
|
||||
} = data
|
||||
|
||||
// Stable URL mapping so reordering doesn't flicker
|
||||
const urlMapRef = useRef(new Map<File, string>())
|
||||
const [previewUrls, setPreviewUrls] = useState<string[]>([])
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const map = urlMapRef.current
|
||||
|
||||
// Create URLs for new files
|
||||
for (const file of screenshotFiles) {
|
||||
if (!map.has(file)) {
|
||||
map.set(file, URL.createObjectURL(file))
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke URLs for removed files
|
||||
for (const [file, url] of Array.from(map.entries())) {
|
||||
if (!screenshotFiles.includes(file)) {
|
||||
URL.revokeObjectURL(url)
|
||||
map.delete(file)
|
||||
}
|
||||
}
|
||||
|
||||
setPreviewUrls(screenshotFiles.map((f) => map.get(f)!))
|
||||
}, [screenshotFiles])
|
||||
|
||||
useEffect(() => {
|
||||
const map = urlMapRef.current
|
||||
return () => {
|
||||
for (const url of map.values()) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
map.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleFiles = (files: FileList | null) => {
|
||||
if (!files) return
|
||||
const incoming = Array.from(files).filter((f) =>
|
||||
/image\/(jpeg|png|webp)/.test(f.type),
|
||||
)
|
||||
const current = screenshotFiles || []
|
||||
const combined = [...current, ...incoming].slice(0, 2)
|
||||
onScreenshotFilesChange?.(combined)
|
||||
}
|
||||
|
||||
const removeFile = (file: File) => {
|
||||
const newFiles = (screenshotFiles || []).filter((f) => f !== file)
|
||||
onScreenshotFilesChange?.(newFiles)
|
||||
}
|
||||
|
||||
const handleReorder = (newFiles: File[]) => {
|
||||
onScreenshotFilesChange(newFiles)
|
||||
}
|
||||
|
||||
const upscalerLabel = (() => {
|
||||
if (
|
||||
!data.environment.upscalerType ||
|
||||
data.environment.upscalerType === "none"
|
||||
)
|
||||
return "None"
|
||||
const opt = UPSCALER_TYPE_OPTIONS.find(
|
||||
(o) => o.value === data.environment.upscalerType,
|
||||
)
|
||||
const base = opt?.label ?? data.environment.upscalerType
|
||||
return data.environment.upscalerVersion
|
||||
? `${base} ${data.environment.upscalerVersion}`
|
||||
: base
|
||||
})()
|
||||
|
||||
const frameGenLabel = (() => {
|
||||
if (
|
||||
!environment.frameGenMethod ||
|
||||
environment.frameGenMethod === "none"
|
||||
)
|
||||
return "None"
|
||||
const opt = FRAME_GEN_OPTIONS.find(
|
||||
(o) => o.value === environment.frameGenMethod,
|
||||
)
|
||||
return opt?.label ?? environment.frameGenMethod
|
||||
})()
|
||||
|
||||
const totalScreenshots = (existingScreenshots?.length ?? 0) + (screenshotFiles?.length ?? 0)
|
||||
const canUploadMore =
|
||||
totalScreenshots < 2 &&
|
||||
submitPhase !== "uploading" &&
|
||||
submitPhase !== "saving"
|
||||
const showExistingRemove = totalScreenshots >= 2
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<FileText className='h-4 w-4 text-primary mt-0.5 shrink-0' />
|
||||
<div>
|
||||
<h3 className='text-sm font-semibold text-text'>
|
||||
Review & Submit
|
||||
</h3>
|
||||
<p className='text-xs text-text/60 mt-1'>
|
||||
Review your submission details below. Add any additional
|
||||
notes and click Submit when ready.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className='grid grid-cols-1 lg:grid-cols-2 gap-4'>
|
||||
{/* Setup: Hardware + Version + Anti-Cheat */}
|
||||
<div className='rounded-lg border border-border bg-text/5 p-4'>
|
||||
<SectionHeader
|
||||
icon={Monitor}
|
||||
label='Setup'
|
||||
/>
|
||||
<SummaryRow
|
||||
label='Device'
|
||||
value={
|
||||
hardwareName || data.hardwareSlug || "Not selected"
|
||||
}
|
||||
/>
|
||||
<SummaryRow
|
||||
label='Game Version'
|
||||
value={gameVersionLabel}
|
||||
/>
|
||||
{antiCheat.antiCheatRelevant && (
|
||||
<>
|
||||
<SummaryRow
|
||||
label='Anti-Cheat'
|
||||
value={antiCheat.antiCheatName || "Unknown"}
|
||||
/>
|
||||
<SummaryRow
|
||||
label='Anti-Cheat Status'
|
||||
value={
|
||||
antiCheat.antiCheatStatus === "supported"
|
||||
? "Supported"
|
||||
: antiCheat.antiCheatStatus ===
|
||||
"unsupported"
|
||||
? "Unsupported"
|
||||
: antiCheat.antiCheatStatus ===
|
||||
"unknown"
|
||||
? "Unknown"
|
||||
: "None"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!antiCheat.antiCheatRelevant && (
|
||||
<SummaryRow
|
||||
label='Anti-Cheat'
|
||||
value='None'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Performance */}
|
||||
<div className='rounded-lg border border-border bg-text/5 p-4'>
|
||||
<SectionHeader
|
||||
icon={Gauge}
|
||||
label='Performance'
|
||||
/>
|
||||
<SummaryRow
|
||||
label='FPS Average'
|
||||
value={formatNumber(performance.fpsAvg)}
|
||||
/>
|
||||
<SummaryRow
|
||||
label='1% Low FPS'
|
||||
value={formatNumber(performance.fpsOnePercentLow)}
|
||||
/>
|
||||
<SummaryRow
|
||||
label='FPS Low'
|
||||
value={formatNumber(performance.fpsLow)}
|
||||
/>
|
||||
<SummaryRow
|
||||
label='FPS High'
|
||||
value={formatNumber(performance.fpsHigh)}
|
||||
/>
|
||||
<SummaryRow
|
||||
label='Load Time SSD'
|
||||
value={formatNumber(performance.loadTimeSsd)}
|
||||
/>
|
||||
<SummaryRow
|
||||
label='Load Time SD'
|
||||
value={formatNumber(performance.loadTimeSd)}
|
||||
/>
|
||||
<SummaryRow
|
||||
label='TDP (Watts)'
|
||||
value={formatNumber(performance.tdpWatts)}
|
||||
/>
|
||||
<SummaryRow
|
||||
label='Est. Battery'
|
||||
value={(() => {
|
||||
const wh = data.hardwareWattHours
|
||||
const tdp = performance.tdpWatts
|
||||
if (
|
||||
wh &&
|
||||
tdp &&
|
||||
tdp > 0 &&
|
||||
data.hardwareDeviceType === "handheld"
|
||||
) {
|
||||
const hours = wh / tdp
|
||||
const mins = Math.round(hours * 60)
|
||||
return `~${hours.toFixed(1)}h (${mins} min)`
|
||||
}
|
||||
return "Not available — requires TDP and a handheld device"
|
||||
})()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Environment */}
|
||||
<div className='rounded-lg border border-border bg-text/5 p-4'>
|
||||
<SectionHeader
|
||||
icon={Terminal}
|
||||
label='Environment'
|
||||
/>
|
||||
<SummaryRow
|
||||
label='Proton Version'
|
||||
value={environment.protonVersion || "Not set"}
|
||||
/>
|
||||
<SummaryRow
|
||||
label='OS Version'
|
||||
value={environment.osVersion || "Not set"}
|
||||
/>
|
||||
<SummaryRow
|
||||
label='Upscaler'
|
||||
value={upscalerLabel}
|
||||
/>
|
||||
<SummaryRow
|
||||
label='Frame Gen'
|
||||
value={frameGenLabel}
|
||||
/>
|
||||
<SummaryRow
|
||||
label='Custom System'
|
||||
value={environment.customSystem ? "Yes" : "No"}
|
||||
/>
|
||||
{environment.launchOptions && (
|
||||
<SummaryRow
|
||||
label='Launch Options'
|
||||
value={
|
||||
<span
|
||||
className='font-mono text-[10px] truncate max-w-40 block'
|
||||
title={environment.launchOptions}
|
||||
>
|
||||
{environment.launchOptions}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{environment.youtubeVideoId &&
|
||||
/^[a-zA-Z0-9_-]{11}$/.test(environment.youtubeVideoId) ? (
|
||||
<div className='mt-2'>
|
||||
<span className='text-xs text-text/50'>
|
||||
YouTube Video
|
||||
</span>
|
||||
<div
|
||||
className='mt-1 relative'
|
||||
style={{ paddingBottom: "56.25%" }}
|
||||
>
|
||||
<iframe
|
||||
src={`https://www.youtube-nocookie.com/embed/${environment.youtubeVideoId}`}
|
||||
className='absolute inset-0 w-full h-full rounded-md'
|
||||
allow='accelerometer; autoplay; encrypted-media; picture-in-picture'
|
||||
sandbox='allow-scripts allow-same-origin allow-presentation'
|
||||
allowFullScreen
|
||||
loading='lazy'
|
||||
title='Review: Gameplay Video'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<SummaryRow
|
||||
label='YouTube Video'
|
||||
value='Not provided'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<div className='rounded-lg border border-border bg-text/5 p-4'>
|
||||
<SectionHeader
|
||||
icon={SlidersHorizontal}
|
||||
label='Settings'
|
||||
/>
|
||||
{settings.length === 0 ? (
|
||||
<p className='text-xs text-text/40 py-1'>
|
||||
No settings configured
|
||||
</p>
|
||||
) : (
|
||||
<div className='space-y-2 max-h-40 overflow-y-auto'>
|
||||
{settings.map((cat) => (
|
||||
<div key={cat.category}>
|
||||
<p className='text-xs font-medium text-text/70'>
|
||||
{cat.category}
|
||||
</p>
|
||||
<div className='flex flex-wrap gap-1 mt-0.5'>
|
||||
{cat.settings.map((s) => (
|
||||
<span
|
||||
key={s.title}
|
||||
className='inline-flex items-center px-1.5 py-0.5 rounded text-[10px] bg-text/10 text-text/60'
|
||||
>
|
||||
{s.title}: {String(s.value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Screenshots */}
|
||||
<div className='space-y-3 overflow-clip'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='text-xs font-medium text-text/60 flex items-center gap-1.5'>
|
||||
<ImageIcon className='h-3.5 w-3.5 text-text/40' />
|
||||
Screenshots
|
||||
<span className='text-[10px] text-text/30 font-normal'>
|
||||
({screenshotFiles?.length ?? 0}/2)
|
||||
</span>
|
||||
</label>
|
||||
{(submitPhase === "uploading" ||
|
||||
submitPhase === "saving") && (
|
||||
<div className='flex items-center gap-2 text-xs text-text/60'>
|
||||
<Loader2 className='h-3.5 w-3.5 animate-spin' />
|
||||
<span>Uploading...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data.settings.length === 0 ? (
|
||||
<div className='rounded-lg border border-border bg-text/5 p-4'>
|
||||
<p className='text-xs text-text/50'>
|
||||
Add game settings to enable screenshot upload
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{/* Existing screenshots (from edit mode) */}
|
||||
{existingScreenshots && existingScreenshots.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] text-text/30 uppercase tracking-wider">Existing screenshots</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{existingScreenshots.map((ss) => (
|
||||
<div
|
||||
key={ss.id}
|
||||
className="group relative rounded-xl border border-border bg-text/3 overflow-hidden"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={ss.url}
|
||||
alt={`Screenshot ${ss.orderIndex + 1}`}
|
||||
className="w-full aspect-video object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-linear-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
{showExistingRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveExistingScreenshot?.(ss.id)}
|
||||
className="absolute top-2 right-2 p-1.5 rounded-lg bg-black/50 text-white/80 hover:text-white hover:bg-red-500/80 backdrop-blur-sm transition-colors opacity-0 group-hover:opacity-100 cursor-pointer"
|
||||
title="Remove screenshot"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="absolute bottom-2 left-2 px-2 py-0.5 rounded-md bg-black/50 backdrop-blur-sm text-[10px] font-medium text-white/90 opacity-0 group-hover:opacity-100">
|
||||
Existing · #{ss.orderIndex + 1}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Screenshot grid with drag-to-reorder */}
|
||||
{screenshotFiles && screenshotFiles.length > 0 && (
|
||||
<Reorder.Group
|
||||
axis='x'
|
||||
values={screenshotFiles}
|
||||
onReorder={handleReorder}
|
||||
as='div'
|
||||
className='grid grid-cols-2 gap-3'
|
||||
>
|
||||
{screenshotFiles.map((file, idx) => (
|
||||
<ScreenshotCard
|
||||
key={
|
||||
file.name +
|
||||
file.size +
|
||||
file.lastModified
|
||||
}
|
||||
file={file}
|
||||
url={previewUrls[idx] || ""}
|
||||
index={idx}
|
||||
onRemove={() => removeFile(file)}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
)}
|
||||
|
||||
{/* Upload area */}
|
||||
{canUploadMore ? (
|
||||
<label
|
||||
className={`relative flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 transition-all cursor-pointer ${
|
||||
isDragOver
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-text/2 hover:bg-text/5 hover:border-text/20"
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setIsDragOver(true)
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setIsDragOver(false)
|
||||
handleFiles(e.dataTransfer.files)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`p-2.5 rounded-full transition-colors ${
|
||||
isDragOver
|
||||
? "bg-primary/15 text-primary"
|
||||
: "bg-text/5 text-text/30"
|
||||
}`}
|
||||
>
|
||||
<Upload
|
||||
className={`h-5 w-5 transition-transform ${
|
||||
isDragOver ? "scale-110" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className='text-center space-y-0.5'>
|
||||
<p className='text-xs text-text/60 font-medium'>
|
||||
{isDragOver
|
||||
? "Drop screenshots here"
|
||||
: screenshotFiles &&
|
||||
screenshotFiles.length > 0
|
||||
? "Add another screenshot"
|
||||
: "Add screenshots"}
|
||||
</p>
|
||||
<p className='text-[10px] text-text/30'>
|
||||
JPG, PNG, WebP · Max 2 files · Drag to
|
||||
upload
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type='file'
|
||||
accept='image/jpeg,image/png,image/webp'
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
handleFiles(e.target.files)
|
||||
e.target.value = ""
|
||||
}}
|
||||
className='sr-only'
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<div className='flex items-center justify-center gap-2 rounded-xl border border-border bg-text/2 p-4 text-xs text-text/40'>
|
||||
<ImagePlus className='h-4 w-4 text-text/20' />
|
||||
Maximum 2 screenshots reached
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Notes */}
|
||||
<div className='space-y-2'>
|
||||
<label className='text-xs font-medium text-text/60'>
|
||||
Additional Notes
|
||||
</label>
|
||||
<TiptapEditor
|
||||
content={userNotes}
|
||||
onChange={(json) => onUserNotesChange(JSON.stringify(json))}
|
||||
placeholder='Add any extra details about your experience...'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className='flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3'
|
||||
>
|
||||
<AlertCircle className='h-4 w-4 text-red-400 shrink-0' />
|
||||
<p className='text-xs text-red-400'>{error}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type='button'
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting}
|
||||
className='w-full flex items-center justify-center gap-2 px-6 py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer'
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
{submitPhase === "uploading"
|
||||
? "Uploading screenshots..."
|
||||
: submitPhase === "saving"
|
||||
? "Saving entry..."
|
||||
: "Processing..."}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className='h-4 w-4' />
|
||||
Submit Entry
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { SettingsEditor, type SettingCategory } from "@/components/wizard/settings-editor"
|
||||
import { SlidersHorizontal } from "lucide-react"
|
||||
|
||||
interface SettingsStepProps {
|
||||
value: SettingCategory[]
|
||||
onChange: (value: SettingCategory[]) => void
|
||||
}
|
||||
|
||||
export function SettingsStep({ value, onChange }: SettingsStepProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<SlidersHorizontal className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text">Game Settings</h3>
|
||||
<p className="text-xs text-text/60 mt-1">
|
||||
Configure the in-game settings you used during testing. Add categories and settings as needed, or load defaults to get started.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsEditor value={value} onChange={onChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client"
|
||||
|
||||
import { GitBranch, DatabaseIcon, RefreshCwIcon } from "lucide-react"
|
||||
import { HardwareStep } from "./hardware-step"
|
||||
import { AntiCheatStep, type AntiCheatData } from "./anti-cheat-step"
|
||||
|
||||
export interface GameVersionInfo {
|
||||
id: string
|
||||
versionString: string | null
|
||||
buildId: string | null
|
||||
isLatest: boolean
|
||||
}
|
||||
|
||||
export interface SteamDBVersion {
|
||||
versionString: string | null
|
||||
buildId: string | null
|
||||
source?: string
|
||||
}
|
||||
|
||||
interface SetupStepProps {
|
||||
gameId: string
|
||||
gameVersions: GameVersionInfo[]
|
||||
hardwareSlug: string
|
||||
onHardwareChange: (slug: string) => void
|
||||
hardwareName: string
|
||||
selectedVersionId: string
|
||||
onVersionChange: (versionId: string) => void
|
||||
newVersionString: string
|
||||
onNewVersionStringChange: (value: string) => void
|
||||
isCreatingVersion: boolean
|
||||
antiCheat: AntiCheatData
|
||||
onAntiCheatChange: (data: AntiCheatData) => void
|
||||
platformSupport: {
|
||||
hardwareSlug: string
|
||||
antiCheatRelevant: boolean
|
||||
antiCheatName: string | null
|
||||
antiCheatStatus: "none" | "supported" | "unsupported" | "unknown"
|
||||
}[]
|
||||
steamdbVersion: SteamDBVersion | null
|
||||
steamdbLoading: boolean
|
||||
steamdbError: string | null
|
||||
onRefreshSteamDB: () => void
|
||||
}
|
||||
|
||||
export function SetupStep({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
gameId: _gameId,
|
||||
gameVersions,
|
||||
hardwareSlug,
|
||||
onHardwareChange,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
hardwareName: _hardwareName,
|
||||
selectedVersionId,
|
||||
onVersionChange,
|
||||
newVersionString,
|
||||
onNewVersionStringChange,
|
||||
isCreatingVersion,
|
||||
antiCheat,
|
||||
onAntiCheatChange,
|
||||
platformSupport,
|
||||
steamdbVersion,
|
||||
steamdbLoading,
|
||||
steamdbError,
|
||||
onRefreshSteamDB,
|
||||
}: SetupStepProps) {
|
||||
const isNewVersion = selectedVersionId === "__new__"
|
||||
const isSteamDBVersion = selectedVersionId === "__steamdb__"
|
||||
const autoFetchEnabled = process.env.NEXT_PUBLIC_VERSION_AUTO_FETCH === "true"
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Hardware Section */}
|
||||
<section>
|
||||
<HardwareStep value={hardwareSlug} onChange={onHardwareChange} />
|
||||
</section>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Game Version Section */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<GitBranch className="h-4 w-4 text-primary" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text">Game Version</h3>
|
||||
<p className="text-xs text-text/60">
|
||||
Which version of the game did you test? This helps others know if benchmarks match their version.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">Version</label>
|
||||
<select
|
||||
value={selectedVersionId}
|
||||
onChange={(e) => onVersionChange(e.target.value)}
|
||||
className="w-full appearance-none px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors cursor-pointer"
|
||||
>
|
||||
{/* Auto-detected version suggestion — only when feature is enabled */}
|
||||
{autoFetchEnabled && steamdbVersion && (steamdbVersion.versionString || steamdbVersion.buildId) && (
|
||||
<option value="__steamdb__" className="bg-primary/10 text-primary">
|
||||
⬇ Latest ({steamdbVersion.source ?? "auto-detected"}): {steamdbVersion.versionString || `Build ${steamdbVersion.buildId}`} — recommended
|
||||
</option>
|
||||
)}
|
||||
{autoFetchEnabled && steamdbLoading && (
|
||||
<option disabled className="text-text/40">
|
||||
Fetching latest version from SteamDB...
|
||||
</option>
|
||||
)}
|
||||
<option disabled className="text-text/30 text-xs">
|
||||
── Existing versions ──
|
||||
</option>
|
||||
{gameVersions.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.versionString
|
||||
? v.versionString
|
||||
: v.buildId
|
||||
? `Build ${v.buildId}`
|
||||
: "Unknown version"}
|
||||
{v.isLatest ? " (latest)" : ""}
|
||||
</option>
|
||||
))}
|
||||
<option value="__new__">
|
||||
+ New version...
|
||||
</option>
|
||||
</select>
|
||||
|
||||
{/* Refresh button for auto-detection (only shown when feature enabled) */}
|
||||
{autoFetchEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefreshSteamDB}
|
||||
disabled={steamdbLoading}
|
||||
className="flex items-center gap-1 text-xs text-text/40 hover:text-primary transition-colors cursor-pointer mt-1 disabled:opacity-30"
|
||||
>
|
||||
<RefreshCwIcon className={`h-3 w-3 ${steamdbLoading ? "animate-spin" : ""}`} />
|
||||
Refresh auto-detected version
|
||||
</button>
|
||||
)}
|
||||
{steamdbError && (
|
||||
<p className="text-xs text-red-400 mt-1">{steamdbError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isNewVersion && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-text/60">
|
||||
Version String <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newVersionString}
|
||||
onChange={(e) => onNewVersionStringChange(e.target.value)}
|
||||
placeholder="e.g. 1.2.3, Patch 4.0, Hotfix Jan 2025"
|
||||
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"
|
||||
disabled={isCreatingVersion}
|
||||
/>
|
||||
<p className="text-xs text-text/40">
|
||||
Enter the game version you tested. This will create a new version entry.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{autoFetchEnabled && isSteamDBVersion && steamdbVersion && (
|
||||
<div className="space-y-3 p-3 rounded-lg border border-primary/30 bg-primary/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<DatabaseIcon className="h-4 w-4 text-primary" />
|
||||
<p className="text-xs font-medium text-primary">
|
||||
Auto-Detected Version ({steamdbVersion.source ?? "unknown source"})
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-text/40">Version</p>
|
||||
<p className="text-sm text-text">{steamdbVersion.versionString || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text/40">Build ID</p>
|
||||
<p className="text-sm text-text font-mono">{steamdbVersion.buildId || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-text/40">
|
||||
This version will be created when you submit your benchmark.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Anti-Cheat Section */}
|
||||
<section>
|
||||
<AntiCheatStep
|
||||
hardwareSlug={hardwareSlug}
|
||||
platformSupport={platformSupport}
|
||||
value={antiCheat}
|
||||
onChange={onAntiCheatChange}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user