From 4fc4b070dc71a766403ef8db59e0ea840ac07cdf Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 26 Apr 2026 01:08:10 +0800 Subject: [PATCH] feat: update search page and improve details --- .../26716-1777131188/content/auth-layout.html | 103 +++ .../26716-1777131188/content/login-flow.html | 124 +++ .../content/password-reset.html | 127 +++ .../content/signup-wizard.html | 192 +++++ .../26716-1777131188/content/waiting.html | 3 + .../26716-1777131188/state/server-stopped | 1 + .../26716-1777131188/state/server.log | 7 + .../26716-1777131188/state/server.pid | 1 + app/components/PlatformIcons.tsx | 13 + app/game/[id]/game-page-client.tsx | 9 +- app/game/[id]/page.tsx | 8 +- app/search/page.tsx | 747 +++++++++++++----- bun.lock | 3 + components/navbar.tsx | 6 +- lib/api/game-stub.ts | 11 +- lib/api/search-unified.ts | 99 ++- lib/api/steam-search.ts | 29 +- lib/steam/sync.ts | 3 +- package.json | 1 + 19 files changed, 1265 insertions(+), 222 deletions(-) create mode 100644 .superpowers/brainstorm/26716-1777131188/content/auth-layout.html create mode 100644 .superpowers/brainstorm/26716-1777131188/content/login-flow.html create mode 100644 .superpowers/brainstorm/26716-1777131188/content/password-reset.html create mode 100644 .superpowers/brainstorm/26716-1777131188/content/signup-wizard.html create mode 100644 .superpowers/brainstorm/26716-1777131188/content/waiting.html create mode 100644 .superpowers/brainstorm/26716-1777131188/state/server-stopped create mode 100644 .superpowers/brainstorm/26716-1777131188/state/server.log create mode 100644 .superpowers/brainstorm/26716-1777131188/state/server.pid create mode 100644 app/components/PlatformIcons.tsx diff --git a/.superpowers/brainstorm/26716-1777131188/content/auth-layout.html b/.superpowers/brainstorm/26716-1777131188/content/auth-layout.html new file mode 100644 index 0000000..a84572e --- /dev/null +++ b/.superpowers/brainstorm/26716-1777131188/content/auth-layout.html @@ -0,0 +1,103 @@ +

Auth Page Layout

+

Centered card layout used across all auth pages (login, signup, forgot-password, reset-password)

+ +
+
+
Desktop Layout
+
+ +
+
+ + +
+ +
+
๐Ÿ” DeckyVault
+
Sign in to your account
+
+ + +
+ + +
+ + +
+
+ or continue with email +
+
+ + +
+ + +
+ + +
+ + +
+ + + + + +
+ Forgot password? + โ€ข + Create account +
+
+
+
+ +
+
Mobile Layout
+
+ +
+
+
๐Ÿ” DeckyVault
+
Sign in to your account
+
+ +
+ + +
+ +
+
+ or email +
+
+ + + + + + +
+ Forgot password? + โ€ข + Create account +
+
+
+
+
+ +
+

Layout Specs

+ +
\ No newline at end of file diff --git a/.superpowers/brainstorm/26716-1777131188/content/login-flow.html b/.superpowers/brainstorm/26716-1777131188/content/login-flow.html new file mode 100644 index 0000000..48f65ba --- /dev/null +++ b/.superpowers/brainstorm/26716-1777131188/content/login-flow.html @@ -0,0 +1,124 @@ +

Login Flow โ€” Email-First with Conditional Passkey

+

Two-phase login: email check โ†’ password/passkey

+ +
+

Phase 1: Email Entry

+
+
Login โ€” Initial Screen
+
+
+
+
Welcome back
+
Sign in to DeckyVault
+
+ + +
+ + +
+ +
+
+ or continue with email +
+
+ + + + + + + +
+ Don't have an account? + Create one +
+
+
+
+
+

+ Key: Only email field is shown initially. The autocomplete="username webauthn" attribute enables passkey conditional UI โ€” browsers with passkey support will offer to autofill passkey credentials. +

+
+
+ +
+

Phase 2a: Email Exists โ†’ Password Entry

+
+
Login โ€” Password Step (email exists)
+
+
+
+
Welcome back
+
Signing in as user@example.com change
+
+ + + + + + + + + + +
+
๐Ÿ”‘ Your browser may offer to sign in with a passkey
+
+
+
+
+
+

+ Conditional UI: The password field has autocomplete="current-password webauthn". On mount, we call authClient.signIn.passkey({ autoFill: true }) to preload passkeys. Browsers that support Conditional UI (Chrome, Safari, Edge) will show a passkey autofill dropdown when the user interacts with the password field. +

+
+
+ +
+

Phase 2b: Email NOT Found โ†’ Error + Signup Link

+
+
Login โ€” Email Not Found
+
+
+
+
Welcome back
+
Sign in to DeckyVault
+
+ + + + + + +
+ โš  + No account found with this email. + Create one โ†’ +
+ + +
+
+
+
+ +
+

How Email Check Works

+
+
    +
  1. User enters email and clicks "Continue"
  2. +
  3. Client calls authClient.signIn.email() with email + empty password
  4. +
  5. Better-auth returns an error if email doesn't exist
  6. +
  7. If error: show "No account found" with signup link
  8. +
  9. If email exists: transition to password field with email displayed
  10. +
+

+ Note: We use signIn.email() as the check mechanism rather than a separate endpoint. This avoids leaking whether an email exists via a dedicated check API. +

+
+
\ No newline at end of file diff --git a/.superpowers/brainstorm/26716-1777131188/content/password-reset.html b/.superpowers/brainstorm/26716-1777131188/content/password-reset.html new file mode 100644 index 0000000..55aff6f --- /dev/null +++ b/.superpowers/brainstorm/26716-1777131188/content/password-reset.html @@ -0,0 +1,127 @@ +

Password Reset Flow โ€” Email OTP

+

Two-page flow: request reset โ†’ verify OTP + set new password

+ +
+

Page 1: Forgot Password โ€” Request OTP

+
+
/forgot-password
+
+
+
+
๐Ÿ”’
+
Forgot your password?
+
Enter your email and we'll send you a verification code to reset your password.
+
+ + + + + + + +
+
+
+
+

+ API: Calls authClient.emailOtp.requestPasswordReset({ email }). This triggers the server to send a "forget-password" OTP via the existing sendVerificationOTP handler. +

+
+
+ +
+

Page 2: Reset Password โ€” OTP + New Password

+
+
/reset-password?email=user@example.com
+
+
+
+
๐Ÿ“ง
+
Check your email
+
We sent a 6-digit code to user@example.com
+
+ + + +
+
_
+
_
+
_
+
_
+
_
+
_
+
+ +
+ Resend code in + 4:23 +
+ + +
+
+ then set new password +
+
+ + + + + + +
+
+
+
+
+
+
+
+
+ + + + + + + + +
+
+
+
+ +
+

Success State

+
+
After successful reset
+
+
+
โœ…
+
Password reset successful
+
Your password has been updated. You can now sign in with your new password.
+ +
+
+
+
+ +
+

API Flow

+
+
    +
  1. User enters email on /forgot-password
  2. +
  3. Client calls authClient.emailOtp.requestPasswordReset({ email })
  4. +
  5. Server sends OTP with type "forget-password" via sendVerificationOTP
  6. +
  7. User is redirected to /reset-password?email=user@example.com
  8. +
  9. User enters OTP + new password + confirm password
  10. +
  11. Client calls authClient.emailOtp.checkVerificationOtp({ email, otp, type: "forget-password" }) (optional validation)
  12. +
  13. Client calls authClient.resetPassword({ newPassword, otp })
  14. +
  15. On success: show success screen, then redirect to /login
  16. +
+
+
\ No newline at end of file diff --git a/.superpowers/brainstorm/26716-1777131188/content/signup-wizard.html b/.superpowers/brainstorm/26716-1777131188/content/signup-wizard.html new file mode 100644 index 0000000..bfd1a22 --- /dev/null +++ b/.superpowers/brainstorm/26716-1777131188/content/signup-wizard.html @@ -0,0 +1,192 @@ +

Signup Wizard โ€” Step-by-Step Flow

+

3 steps for email signup, 2 steps for social signup

+ +
+

Step 1: Account Creation

+
+
Email Signup โ€” Step 1 of 3
+
+
+ +
+
+
+
+
+ +
+
Create your account
+
Choose how you'd like to sign up
+
+ + +
+ + +
+ +
+
+ or use email +
+
+ + + + + + + + + +
+
+
+
+
+
+
+
+
Weak โ€” add uppercase, number, special char
+
+ + + +
+ Already have an account? + Sign in +
+
+
+
+
+ +
+

Step 2: Email Verification (OTP)

+
+
Email Signup โ€” Step 2 of 3
+
+
+ +
+
+
+
+
+ +
+
Verify your email
+
We sent a 6-digit code to user@example.com
+
+ + +
+
4
+
7
+
_
+
_
+
_
+
_
+
+ + +
+ Resend code in + 4:23 +
+ + + + +
+
+
+
+ +
+

Step 3: Passkey Setup (Optional)

+
+
Email Signup โ€” Step 3 of 3 (Optional)
+
+
+ +
+
+
+
+
+ +
+
๐Ÿ”‘
+
Set up a passkey
+
Sign in faster with biometrics or your device's security key. No password needed.
+
+ + +
+
+ โœ“ Faster sign-in with fingerprint or face +
+
+ โœ“ More secure than passwords +
+
+ โœ“ Works across your devices +
+
+ + + +
+
+
+
+ +
+

Social Signup Flow (2 steps)

+
+

+ Step 1: User clicks Google/Discord โ†’ OAuth redirect โ†’ authenticated
+ Step 2: Passkey setup (optional, same as Step 3 above) โ†’ Done

+ OTP step is skipped โ€” email is already verified by the OAuth provider. +

+
+
+ +
+

Password Strength Meter โ€” States

+
+
+
+
+
+
+
+
+
+
Weak โ€” add 8+ chars, uppercase, number, special char
+
+
+
+
+
+
+
+
+
+
Fair โ€” add a special character and more length
+
+
+
+
+
+
+
+
+
+
Strong โ€” great password!
+
+
+
\ No newline at end of file diff --git a/.superpowers/brainstorm/26716-1777131188/content/waiting.html b/.superpowers/brainstorm/26716-1777131188/content/waiting.html new file mode 100644 index 0000000..f92c257 --- /dev/null +++ b/.superpowers/brainstorm/26716-1777131188/content/waiting.html @@ -0,0 +1,3 @@ +
+

Continuing in terminal...

+
\ No newline at end of file diff --git a/.superpowers/brainstorm/26716-1777131188/state/server-stopped b/.superpowers/brainstorm/26716-1777131188/state/server-stopped new file mode 100644 index 0000000..65871d1 --- /dev/null +++ b/.superpowers/brainstorm/26716-1777131188/state/server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1777134909078} diff --git a/.superpowers/brainstorm/26716-1777131188/state/server.log b/.superpowers/brainstorm/26716-1777131188/state/server.log new file mode 100644 index 0000000..abb8131 --- /dev/null +++ b/.superpowers/brainstorm/26716-1777131188/state/server.log @@ -0,0 +1,7 @@ +{"type":"server-started","port":54158,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:54158","screen_dir":"/Users/adrianbonpin/Documents/Code/personal/deckyvault/.superpowers/brainstorm/26716-1777131188/content","state_dir":"/Users/adrianbonpin/Documents/Code/personal/deckyvault/.superpowers/brainstorm/26716-1777131188/state"} +{"type":"screen-added","file":"/Users/adrianbonpin/Documents/Code/personal/deckyvault/.superpowers/brainstorm/26716-1777131188/content/auth-layout.html"} +{"type":"screen-added","file":"/Users/adrianbonpin/Documents/Code/personal/deckyvault/.superpowers/brainstorm/26716-1777131188/content/signup-wizard.html"} +{"type":"screen-added","file":"/Users/adrianbonpin/Documents/Code/personal/deckyvault/.superpowers/brainstorm/26716-1777131188/content/login-flow.html"} +{"type":"screen-added","file":"/Users/adrianbonpin/Documents/Code/personal/deckyvault/.superpowers/brainstorm/26716-1777131188/content/password-reset.html"} +{"type":"screen-added","file":"/Users/adrianbonpin/Documents/Code/personal/deckyvault/.superpowers/brainstorm/26716-1777131188/content/waiting.html"} +{"type":"server-stopped","reason":"idle timeout"} diff --git a/.superpowers/brainstorm/26716-1777131188/state/server.pid b/.superpowers/brainstorm/26716-1777131188/state/server.pid new file mode 100644 index 0000000..35e769c --- /dev/null +++ b/.superpowers/brainstorm/26716-1777131188/state/server.pid @@ -0,0 +1 @@ +26725 diff --git a/app/components/PlatformIcons.tsx b/app/components/PlatformIcons.tsx new file mode 100644 index 0000000..bf35058 --- /dev/null +++ b/app/components/PlatformIcons.tsx @@ -0,0 +1,13 @@ +import { FaWindows, FaApple, FaLinux } from "react-icons/fa" + +export function WindowsIcon({ className = "h-3.5 w-3.5" }: { className?: string }) { + return +} + +export function MacIcon({ className = "h-3.5 w-3.5" }: { className?: string }) { + return +} + +export function LinuxIcon({ className = "h-3.5 w-3.5" }: { className?: string }) { + return +} diff --git a/app/game/[id]/game-page-client.tsx b/app/game/[id]/game-page-client.tsx index 7609aaf..51ea5b4 100644 --- a/app/game/[id]/game-page-client.tsx +++ b/app/game/[id]/game-page-client.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useCallback, useState } from "react" import Image from "next/image" import { Gamepad2Icon, @@ -61,20 +61,23 @@ export function GamePageClient({ platformSupport: PlatformSupport[] }) { const [activeTab, setActiveTab] = useState("overview") + const [imgError, setImgError] = useState(false) + const handleImgError = useCallback(() => setImgError(true), []) - const headerImage = game.headerImage || game.capsuleImage + const headerImage = game.capsuleImage || game.headerImage return (
{/* Hero */}
- {headerImage ? ( + {headerImage && !imgError ? ( {game.title} ) : (
diff --git a/app/game/[id]/page.tsx b/app/game/[id]/page.tsx index 09b178b..2cd1395 100644 --- a/app/game/[id]/page.tsx +++ b/app/game/[id]/page.tsx @@ -39,23 +39,27 @@ async function createGameStub(steamAppId: number) { const data = (await res.json()) as Record< string, { success: boolean; data: { + type?: string name: string developers?: string[] publishers?: string[] genres?: { description: string }[] header_image?: string - capsule_imagev5?: string short_description?: string } } > const entry = data[String(steamAppId)] if (entry?.success && entry.data) { + // Reject non-games (DLCs, soundtracks, demos, etc.) + if (entry.data.type && entry.data.type !== "game") { + notFound() + } title = entry.data.name developer = entry.data.developers?.[0] ?? null publisher = entry.data.publishers?.[0] ?? null genres = entry.data.genres?.map((g) => g.description) ?? [] headerImage = entry.data.header_image ?? null - capsuleImage = entry.data.capsule_imagev5 ?? entry.data.header_image ?? null + capsuleImage = `https://cdn.akamai.steamstatic.com/steam/apps/${steamAppId}/library_600x900.jpg` description = entry.data.short_description ?? null } } diff --git a/app/search/page.tsx b/app/search/page.tsx index 92176dd..e23d01e 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -1,239 +1,578 @@ "use client" -import { Suspense, useEffect, useState } from "react" +import { Suspense, useState, useEffect } from "react" import { useRouter, useSearchParams } from "next/navigation" -import { ExternalLinkIcon, Gamepad2Icon, MessageSquareIcon, SettingsIcon, TrendingUpIcon } from "lucide-react" +import { motion, AnimatePresence } from "motion/react" +import { + ExternalLinkIcon, + Gamepad2Icon, + MessageSquareIcon, + MonitorIcon, + SettingsIcon, + TrendingUpIcon, + DatabaseIcon, + SparklesIcon, +} from "lucide-react" +import { FaSteam } from "react-icons/fa" import Image from "next/image" +import { WindowsIcon, MacIcon, LinuxIcon } from "@/app/components/PlatformIcons" interface UnifiedResult { - kind: "local" | "steam" - id?: string - appId: number | null - title: string - image: string | null - developer: string | null - publisher: string | null - source: string - counts: { benchmarks: number; presets: number; comments: number } | null + kind: "local" | "steam" + id?: string + appId: number | null + title: string + image: string | null + developer: string | null + publisher: string | null + description: string | null + genres: string[] | null + source: string + counts: { benchmarks: number; presets: number; comments: number } | null + platformSupport: { + isSupported: boolean + protonStatus: string + antiCheatRelevant: boolean + antiCheatName: string | null + antiCheatStatus: string + } | null + metascore?: string | null + price?: { currency: string; initial: number; final: number } | null + platforms?: { windows: boolean; mac: boolean; linux: boolean } | null + controllerSupport?: string | null } function SearchContent() { - const searchParams = useSearchParams() - const router = useRouter() - const query = searchParams.get("q") || "" + const searchParams = useSearchParams() + const router = useRouter() + const query = searchParams.get("q") || "" - const [results, setResults] = useState([]) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - - useEffect(() => { - if (!query) { - setResults([]) - return - } - - let cancelled = false - - async function fetchResults() { - setLoading(true) - setError(null) - - try { - const res = await fetch( - `/api/search/unified?q=${encodeURIComponent(query)}`, - ) - if (!res.ok) throw new Error(await res.text()) - const data = await res.json() - if (!cancelled) setResults(data.results || []) - } catch (err) { - if (!cancelled) { - setError("Failed to fetch search results") - console.error(err) - } - } finally { - if (!cancelled) setLoading(false) - } - } - - fetchResults() - return () => { cancelled = true } - }, [query]) - - function handleClick(result: UnifiedResult) { - const path = result.appId - ? `/game/${result.appId}` - : `/game/${result.id}` - router.push(path) + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + // Handle direct navigation / browser back-forward + useEffect(() => { + if (!query || query.length < 2) { + setResults([]) + setError(null) + return } - return ( -
-
-

- {query ? `results for "${query}"` : "search using game name or appid"} -

-

- {query - ? `${results.length} result${results.length !== 1 ? "s" : ""} found` - : "Enter a game name or AppID to find benchmarks, settings, and reviews."} -

+ let cancelled = false - {!query && ( -
- -

- Start typing to search for games -

-
- )} + async function fetchResults() { + setLoading(true) + setError(null) - {query && loading && ( -
-
-

- Searching for "{query}"... -

-
- )} + try { + const res = await fetch( + `/api/search/unified?q=${encodeURIComponent(query)}`, + ) + if (!res.ok) throw new Error(await res.text()) + const data = await res.json() + if (!cancelled) setResults(data.results || []) + } catch (err) { + if (!cancelled) { + setError("Failed to fetch search results") + console.error(err) + } + } finally { + if (!cancelled) setLoading(false) + } + } - {query && !loading && error && ( -
-

{error}

-
- )} + fetchResults() + return () => { + cancelled = true + } + }, [query]) - {query && !loading && !error && results.length === 0 && ( -
- -

- No results found for "{query}" -

-
- )} + function handleClick(result: UnifiedResult) { + const path = result.appId + ? `/game/${result.appId}` + : `/game/${result.id}` + router.push(path) + } - {query && !loading && !error && results.length > 0 && ( -
- {results.map((result, idx) => ( - - ))} -
- )} -
-
- ) + return ( +
+
+ + {query ? `Results for "${query}"` : "Search using game name or AppID"} + + + {query + ? `${results.length} result${results.length !== 1 ? "s" : ""} found` + : "Enter a game name or AppID to find benchmarks, settings, and reviews."} + + + {!query && ( + + +

+ Start typing to search for games +

+
+ )} + + {query && loading && ( +
+ +

+ Searching for "{query}"... +

+
+ )} + + {query && !loading && error && ( + +

{error}

+
+ )} + + {query && !loading && !error && results.length === 0 && ( + + +

+ No results found for "{query}" +

+
+ )} + + {query && !loading && !error && results.length > 0 && ( + + + {results.map((result, idx) => ( + + ))} + + + )} +
+
+ ) } function SearchResultCard({ - result, - onClick, + result, + onClick, + index, }: { - result: UnifiedResult - onClick: (r: UnifiedResult) => void + result: UnifiedResult + onClick: (r: UnifiedResult) => void + index: number }) { - const isLocal = result.kind === "local" - const hasData = isLocal && result.counts && ( - result.counts.benchmarks > 0 || - result.counts.presets > 0 || - result.counts.comments > 0 - ) - const hasDeveloperInfo = result.developer || result.publisher + const isLocal = result.kind === "local" + const counts = result.counts + const hasData = + isLocal && + counts && + (counts.benchmarks > 0 || counts.presets > 0 || counts.comments > 0) - return ( -
onClick(result)} + return ( + + {/* Full-card click target */} +
onClick(result)} + /> + +
+ {/* Cover */} + - {/* Image */} -
- {result.image ? ( - {result.title} - ) : ( -
- -
- )} - {/* Source badge */} -
- {isLocal && ( - - In Database - - )} - {!isLocal && result.appId && ( - e.stopPropagation()} - > - Steam - - - )} -
-
+ +
- {/* Info */} -
-

- {result.title} + {/* Main Content */} +

+ {/* Row 1: Title + metascore/price row */} +
+
+

+ {result.title} +

+ {(result.developer || result.publisher) && ( +

+ {result.developer} + {result.developer && result.publisher ? " ยท " : ""} + {result.publisher}

- {hasDeveloperInfo ? ( -

- {result.developer || result.publisher} -

- ) : !isLocal && result.appId ? ( -

- AppID: {result.appId} -

- ) : null} + )}
- {/* Stats (local only) */} - {isLocal && result.counts && ( -
- {result.counts.benchmarks > 0 && ( - - - {result.counts.benchmarks} - - )} - {result.counts.presets > 0 && ( - - - {result.counts.presets} - - )} - {result.counts.comments > 0 && ( - - - {result.counts.comments} - - )} - {!hasData && ( - No data yet - )} -
+ {/* Metascore + Price row */} +
+ {result.metascore ? ( + + + {result.metascore} + + ) : null} + +
+
+ + {/* Row 2: Description */} + {result.description && ( +

+ {result.description} +

+ )} + + {/* Row 3: Genre tags + Platform icons + Controller */} +
+ {result.genres && result.genres.length > 0 && ( +
+ {result.genres.slice(0, 3).map((genre) => ( + + {genre} + + ))} + {result.genres.length > 3 && ( + + +{result.genres.length - 3} + + )} +
)} + + {/* Platform icons โ€” always show all 3, color if present */} +
+ + + + + + + + + +
+ + {result.controllerSupport && ( + + + Controller + + )} +
+ + {/* Row 4: Stats row */} +
+ {hasData ? ( + <> + {counts!.benchmarks > 0 && ( + + )} + {counts!.presets > 0 && ( + + )} + {counts!.comments > 0 && ( + + )} + + ) : isLocal ? ( + + No data yet โ€” be the first to contribute + + ) : ( + + )} +
+ + {/* Row 5: Anti-cheat info */} + {result.platformSupport?.antiCheatRelevant && ( +
+ + Anti-cheat: {result.platformSupport.antiCheatName || "Unknown"} + + {" "}โ€” {result.platformSupport.antiCheatStatus} + + +
+ )}
+ + {/* Right Panel โ€” Desktop Only */} +
+ {/* Deck Status */} + + + {/* Avg FPS */} + + + {/* Version */} + +
+
+ + ) +} + +function DataField({ + label, + value, + bar, + color, +}: { + label: string + value: string + bar?: boolean + color?: string +}) { + return ( +
+ + {label} + +
+ {bar && ( +
+
+
+ )} + + {value} + +
+
+ ) +} + +function protonLabel(status: string): string { + const map: Record = { + native: "Native", + proton: "Proton", + unsupported: "Unsupported", + unknown: "Unknown", + } + return map[status] || status +} + +function protonColor(status: string): string { + const map: Record = { + native: "text-green-400", + proton: "text-blue-400", + unsupported: "text-red-400", + unknown: "text-text/25", + } + return map[status] || "text-text/25" +} + +function GameCover({ image, title }: { image: string | null; title: string }) { + const [error, setError] = useState(false) + + if (image && !error) { + return ( + {title} setError(true)} + /> ) + } + + return ( +
+ +
+ ) +} + +function PriceTag({ + price, +}: { + price?: { currency: string; initial: number; final: number } | null +}) { + if (!price || price.final === 0) { + return ( + + Free + + ) + } + + const isDiscounted = price.final < price.initial + const fmt = new Intl.NumberFormat("en-US", { + style: "currency", + currency: price.currency, + }) + + return ( + + {isDiscounted && ( + + {fmt.format(price.initial / 100)} + + )} + + {fmt.format(price.final / 100)} + + + ) +} + +function StatBadge({ + icon: Icon, + count, + label, +}: { + icon: React.ElementType + count: number + label: string +}) { + return ( + + + {count} + {label} + + ) } export default function SearchPage() { - return ( - - - - ) + return ( + + + + ) } diff --git a/bun.lock b/bun.lock index 36c634d..ac46656 100644 --- a/bun.lock +++ b/bun.lock @@ -23,6 +23,7 @@ "pg": "^8.20.0", "react": "19.2.4", "react-dom": "19.2.4", + "react-icons": "^5.6.0", "resend": "^6.12.2", "web-haptics": "^0.0.6", }, @@ -1304,6 +1305,8 @@ "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "react-icons": ["react-icons@5.6.0", "", { "peerDependencies": { "react": "*" } }, "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA=="], + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], diff --git a/components/navbar.tsx b/components/navbar.tsx index fc475d0..7e2dbbb 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -17,7 +17,7 @@ export default function Navbar() { const isLanding = pathname === "/" - const [searchQuery, setSearchQuery] = useState("") + const [searchQuery, setSearchQuery] = useState(() => searchParams.get("q") || "") const debouncedQuery = useDebounce(searchQuery, 300) const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [isFocused, setIsFocused] = useState(false) @@ -35,6 +35,8 @@ export default function Navbar() { 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) { @@ -43,7 +45,7 @@ export default function Navbar() { params.delete("q") } router.replace(`/search?${params.toString()}`, { scroll: false }) - }, [debouncedQuery, isLanding, router, searchParams]) + }, [debouncedQuery, isLanding, router, searchParams, searchQuery]) // Maintain focus & styles when flying from landing page search useEffect(() => { diff --git a/lib/api/game-stub.ts b/lib/api/game-stub.ts index d97a569..dc8520d 100644 --- a/lib/api/game-stub.ts +++ b/lib/api/game-stub.ts @@ -4,12 +4,12 @@ import { games } from "@/lib/db/schema" import { eq } from "drizzle-orm" interface SteamAppDetails { + type?: string steam_appid: number name: string developers?: string[] publishers?: string[] header_image?: string - capsule_imagev5?: string genres?: { id: string; description: string }[] website?: string } @@ -50,6 +50,13 @@ export const gameStubRoutes = new Elysia({ prefix: "/games" }).post( details = entry.data } } + + // Reject non-games (DLCs, soundtracks, demos, etc.) + if (details?.type && details.type !== "game") { + set.status = 400 + return { error: `Not a game (type: ${details.type})` } + + } } catch (err) { console.error("Failed to fetch Steam appdetails:", err) } @@ -60,7 +67,7 @@ export const gameStubRoutes = new Elysia({ prefix: "/games" }).post( const genres = details?.genres?.map((g) => g.description) || [] const headerImage = details?.header_image || null const capsuleImage = - details?.capsule_imagev5 || details?.header_image || null + `https://cdn.akamai.steamstatic.com/steam/apps/${body.steamAppId}/library_600x900.jpg` const [game] = await db .insert(games) diff --git a/lib/api/search-unified.ts b/lib/api/search-unified.ts index c2c0564..8b12f0b 100644 --- a/lib/api/search-unified.ts +++ b/lib/api/search-unified.ts @@ -1,6 +1,12 @@ import { Elysia, t } from "elysia" import { db } from "@/lib/db/index" -import { games, gameVersions, performanceEntries, communityPresets, gameComments } from "@/lib/db/schema" +import { + games, + gameVersions, + performanceEntries, + communityPresets, + gameComments, +} from "@/lib/db/schema" import { ilike, or, sql, eq, inArray } from "drizzle-orm" interface SteamSearchItem { @@ -8,7 +14,9 @@ interface SteamSearchItem { name: string tiny_image: string metascore: string + price?: { currency: string; initial: number; final: number } platforms: { windows: boolean; mac: boolean; linux: boolean } + controller_support?: string } interface SteamSearchResponse { @@ -44,6 +52,42 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get( localGames.map((g) => g.steamAppId).filter(Boolean), ) + // Fetch platform support + anti-cheat for local games + let platformSupportMap = new Map< + string, + { + isSupported: boolean + protonStatus: string + antiCheatRelevant: boolean + antiCheatName: string | null + antiCheatStatus: string + } + >() + if (localGameIds.length > 0) { + const { gamePlatformSupport } = await import("@/lib/db/schema") + const supportRows = await db + .select({ + gameId: gamePlatformSupport.gameId, + isSupported: gamePlatformSupport.isSupported, + protonStatus: gamePlatformSupport.protonStatus, + antiCheatRelevant: gamePlatformSupport.antiCheatRelevant, + antiCheatName: gamePlatformSupport.antiCheatName, + antiCheatStatus: gamePlatformSupport.antiCheatStatus, + }) + .from(gamePlatformSupport) + .where(inArray(gamePlatformSupport.gameId, localGameIds)) + + for (const row of supportRows) { + platformSupportMap.set(row.gameId, { + isSupported: row.isSupported, + protonStatus: row.protonStatus, + antiCheatRelevant: row.antiCheatRelevant, + antiCheatName: row.antiCheatName, + antiCheatStatus: row.antiCheatStatus, + }) + } + } + // โ”€โ”€ 2. Count related data for local games โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let benchmarkCounts: { gameId: string; count: number }[] = [] let presetCounts: { gameId: string; count: number }[] = [] @@ -115,19 +159,40 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get( }) if (res.ok) { const data = (await res.json()) as SteamSearchResponse - steamItems = data.items || [] + steamItems = (data.items || []).filter((item) => { + const name = item.name.toLowerCase() + const exclude = [ + "soundtrack", + " original soundtrack", + " ost", + " - ost", + "dlc", + "expansion", + "season pass", + " deluxe edition", + " ultimate edition", + " premium edition", + " demo", + " trial", + " playtest", + " beta", + " artbook", + " soundtrack bundle", + ] + return !exclude.some((kw) => name.includes(kw)) + }) } } catch { // Steam search failure is non-fatal } // โ”€โ”€ 4. Build unified results โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - // Local games first (they have data), then Steam-only results const results = [] // Add local games for (const g of localGames) { const counts = countMap.get(g.id)! + const platform = platformSupportMap.get(g.id) results.push({ kind: "local" as const, id: g.id, @@ -136,23 +201,47 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get( image: g.capsuleImage || g.headerImage, developer: g.developer, publisher: g.publisher, + description: g.description, + genres: g.genres, source: g.source, counts, + platformSupport: platform + ? { + isSupported: platform.isSupported, + protonStatus: platform.protonStatus, + antiCheatRelevant: platform.antiCheatRelevant, + antiCheatName: platform.antiCheatName, + antiCheatStatus: platform.antiCheatStatus, + } + : null, }) } - // Add Steam-only games (deduplicated against local steamAppIds) + // Add Steam-only games for (const item of steamItems) { if (localSteamAppIds.has(item.id)) continue results.push({ kind: "steam" as const, appId: item.id, title: item.name, - image: item.tiny_image, + image: `https://cdn.akamai.steamstatic.com/steam/apps/${item.id}/library_600x900.jpg`, developer: null, publisher: null, + description: null, + genres: null, source: "steam" as const, counts: null, + platformSupport: null, + metascore: item.metascore || null, + price: item.price + ? { + currency: item.price.currency, + initial: item.price.initial, + final: item.price.final, + } + : null, + platforms: item.platforms, + controllerSupport: item.controller_support || null, }) } diff --git a/lib/api/steam-search.ts b/lib/api/steam-search.ts index 35339f7..aa5edb0 100644 --- a/lib/api/steam-search.ts +++ b/lib/api/steam-search.ts @@ -38,11 +38,36 @@ export const steamSearchRoutes = new Elysia({ prefix: "/search" }) const data = await res.json() + const items: SteamSearchItem[] = (data.items || []).filter( + (item: SteamSearchItem) => { + const name = item.name.toLowerCase() + const exclude = [ + "soundtrack", + " original soundtrack", + " ost", + " - ost", + "dlc", + "expansion", + "season pass", + " deluxe edition", + " ultimate edition", + " premium edition", + " demo", + " trial", + " playtest", + " beta", + " artbook", + " soundtrack bundle", + ] + return !exclude.some((kw) => name.includes(kw)) + }, + ) + return { - items: (data.items || []).map((item: SteamSearchItem) => ({ + items: items.map((item) => ({ appId: item.id, title: item.name, - image: item.tiny_image, + image: `https://cdn.akamai.steamstatic.com/steam/apps/${item.id}/library_600x900.jpg`, platforms: item.platforms, metascore: item.metascore, })), diff --git a/lib/steam/sync.ts b/lib/steam/sync.ts index ee16bc1..ffb2914 100644 --- a/lib/steam/sync.ts +++ b/lib/steam/sync.ts @@ -8,7 +8,6 @@ interface SteamAppDetails { developers?: string[] publishers?: string[] header_image?: string - capsule_imagev5?: string genres?: { id: string; description: string }[] website?: string short_description?: string @@ -59,7 +58,7 @@ export async function syncSteamGame(steamAppId: number): Promise { description: d.short_description || null, genres: d.genres?.map((g) => g.description) || [], headerImage: d.header_image || null, - capsuleImage: d.capsule_imagev5 || d.header_image || null, + capsuleImage: `https://cdn.akamai.steamstatic.com/steam/apps/${steamAppId}/library_600x900.jpg`, storeUrl: `https://store.steampowered.com/app/${steamAppId}`, lastSync: new Date(), syncStatus: "synced", diff --git a/package.json b/package.json index 0078fc5..ccb4d65 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "pg": "^8.20.0", "react": "19.2.4", "react-dom": "19.2.4", + "react-icons": "^5.6.0", "resend": "^6.12.2", "web-haptics": "^0.0.6" },