feat: update search page and improve details
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
<h2>Auth Page Layout</h2>
|
||||
<p class="subtitle">Centered card layout used across all auth pages (login, signup, forgot-password, reset-password)</p>
|
||||
|
||||
<div class="split">
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">Desktop Layout</div>
|
||||
<div class="mockup-body" style="background: #100b14; padding: 0; min-height: 420px; display: flex; flex-direction: column; align-items: center; justify-content: center;">
|
||||
<!-- Background decorative elements -->
|
||||
<div style="position: absolute; top: 40px; left: 40px; width: 120px; height: 120px; border-radius: 50%; background: radial-gradient(circle, rgba(235,55,121,0.15), transparent); filter: blur(30px);"></div>
|
||||
<div style="position: absolute; bottom: 60px; right: 60px; width: 100px; height: 100px; border-radius: 50%; background: radial-gradient(circle, rgba(87,27,139,0.2), transparent); filter: blur(25px);"></div>
|
||||
|
||||
<!-- Auth Card -->
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 32px; width: 100%; max-width: 400px; margin: 20px; backdrop-filter: blur(10px);">
|
||||
<!-- Logo -->
|
||||
<div style="text-align: center; margin-bottom: 24px;">
|
||||
<div style="font-size: 20px; font-weight: bold; color: #ebe4f1;">🔐 DeckyVault</div>
|
||||
<div style="font-size: 13px; color: rgba(235,228,241,0.5); margin-top: 4px;">Sign in to your account</div>
|
||||
</div>
|
||||
|
||||
<!-- Social Buttons -->
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
|
||||
<button style="flex: 1; padding: 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); color: #ebe4f1; font-size: 13px; cursor: pointer;">🔵 Google</button>
|
||||
<button style="flex: 1; padding: 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); color: #ebe4f1; font-size: 13px; cursor: pointer;">🟣 Discord</button>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin: 16px 0;">
|
||||
<div style="flex: 1; height: 1px; background: rgba(255,255,255,0.08);"></div>
|
||||
<span style="font-size: 11px; color: rgba(235,228,241,0.4); text-transform: uppercase;">or continue with email</span>
|
||||
<div style="flex: 1; height: 1px; background: rgba(255,255,255,0.08);"></div>
|
||||
</div>
|
||||
|
||||
<!-- Email Input -->
|
||||
<div style="margin-bottom: 12px;">
|
||||
<label style="font-size: 12px; color: rgba(235,228,241,0.6); display: block; margin-bottom: 4px;">Email</label>
|
||||
<input style="width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); color: #ebe4f1; font-size: 14px; box-sizing: border-box;" placeholder="you@example.com" autocomplete="username webauthn" />
|
||||
</div>
|
||||
|
||||
<!-- Password Input (conditional) -->
|
||||
<div style="margin-bottom: 16px;">
|
||||
<label style="font-size: 12px; color: rgba(235,228,241,0.6); display: block; margin-bottom: 4px;">Password</label>
|
||||
<input style="width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); color: #ebe4f1; font-size: 14px; box-sizing: border-box;" type="password" placeholder="••••••••" autocomplete="current-password webauthn" />
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<button style="width: 100%; padding: 10px; border-radius: 8px; border: none; background: #eb3779; color: white; font-size: 14px; font-weight: 600; cursor: pointer;">Continue</button>
|
||||
|
||||
<!-- Links -->
|
||||
<div style="text-align: center; margin-top: 16px; font-size: 12px;">
|
||||
<a style="color: rgba(235,228,241,0.5); text-decoration: none;">Forgot password?</a>
|
||||
<span style="color: rgba(235,228,241,0.3); margin: 0 8px;">•</span>
|
||||
<a style="color: #eb3779; text-decoration: none;">Create account</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">Mobile Layout</div>
|
||||
<div class="mockup-body" style="background: #100b14; padding: 0; min-height: 420px; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; padding-top: 24px;">
|
||||
<!-- Same card but full-width on mobile -->
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 24px 20px; width: calc(100% - 32px); max-width: none; margin: 0 16px;">
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<div style="font-size: 18px; font-weight: bold; color: #ebe4f1;">🔐 DeckyVault</div>
|
||||
<div style="font-size: 12px; color: rgba(235,228,241,0.5); margin-top: 4px;">Sign in to your account</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
|
||||
<button style="flex: 1; padding: 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); color: #ebe4f1; font-size: 13px;">🔵 Google</button>
|
||||
<button style="flex: 1; padding: 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); color: #ebe4f1; font-size: 13px;">🟣 Discord</button>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin: 16px 0;">
|
||||
<div style="flex: 1; height: 1px; background: rgba(255,255,255,0.08);"></div>
|
||||
<span style="font-size: 11px; color: rgba(235,228,241,0.4);">or email</span>
|
||||
<div style="flex: 1; height: 1px; background: rgba(255,255,255,0.08);"></div>
|
||||
</div>
|
||||
|
||||
<input style="width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); color: #ebe4f1; font-size: 14px; margin-bottom: 10px; box-sizing: border-box;" placeholder="you@example.com" />
|
||||
<input style="width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); color: #ebe4f1; font-size: 14px; margin-bottom: 16px; box-sizing: border-box;" type="password" placeholder="••••••••" />
|
||||
|
||||
<button style="width: 100%; padding: 10px; border-radius: 8px; border: none; background: #eb3779; color: white; font-size: 14px; font-weight: 600;">Continue</button>
|
||||
|
||||
<div style="text-align: center; margin-top: 14px; font-size: 12px;">
|
||||
<a style="color: rgba(235,228,241,0.5);">Forgot password?</a>
|
||||
<span style="color: rgba(235,228,241,0.3); margin: 0 8px;">•</span>
|
||||
<a style="color: #eb3779;">Create account</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" style="margin-top: 20px;">
|
||||
<h3>Layout Specs</h3>
|
||||
<ul>
|
||||
<li><strong>Container:</strong> Full viewport height minus navbar, centered content</li>
|
||||
<li><strong>Card:</strong> Max-width 400px, glass-morphism style (subtle border, blur backdrop)</li>
|
||||
<li><strong>Background:</strong> Subtle radial gradient orbs for depth (pink + purple)</li>
|
||||
<li><strong>Responsive:</strong> Card goes full-width on mobile with reduced padding</li>
|
||||
<li><strong>Animations:</strong> Card fades in with Motion (consistent with existing navbar pattern)</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,124 @@
|
||||
<h2>Login Flow — Email-First with Conditional Passkey</h2>
|
||||
<p class="subtitle">Two-phase login: email check → password/passkey</p>
|
||||
|
||||
<div class="section">
|
||||
<h3>Phase 1: Email Entry</h3>
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">Login — Initial Screen</div>
|
||||
<div class="mockup-body" style="background: #100b14; padding: 20px; min-height: 380px;">
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 28px; max-width: 380px; margin: 0 auto;">
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<div style="font-size: 18px; font-weight: bold; color: #ebe4f1;">Welcome back</div>
|
||||
<div style="font-size: 12px; color: rgba(235,228,241,0.5); margin-top: 4px;">Sign in to DeckyVault</div>
|
||||
</div>
|
||||
|
||||
<!-- Social buttons -->
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
|
||||
<button style="flex: 1; padding: 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); color: #ebe4f1; font-size: 13px;">🔵 Google</button>
|
||||
<button style="flex: 1; padding: 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); color: #ebe4f1; font-size: 13px;">🟣 Discord</button>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin: 16px 0;">
|
||||
<div style="flex: 1; height: 1px; background: rgba(255,255,255,0.08);"></div>
|
||||
<span style="font-size: 11px; color: rgba(235,228,241,0.4);">or continue with email</span>
|
||||
<div style="flex: 1; height: 1px; background: rgba(255,255,255,0.08);"></div>
|
||||
</div>
|
||||
|
||||
<!-- Email only — NO password field yet -->
|
||||
<label style="font-size: 12px; color: rgba(235,228,241,0.6); display: block; margin-bottom: 4px;">Email</label>
|
||||
<input style="width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); color: #ebe4f1; font-size: 14px; margin-bottom: 16px; box-sizing: border-box;" placeholder="you@example.com" autocomplete="username webauthn" />
|
||||
|
||||
<button style="width: 100%; padding: 10px; border-radius: 8px; border: none; background: #eb3779; color: white; font-size: 14px; font-weight: 600;">Continue</button>
|
||||
|
||||
<div style="text-align: center; margin-top: 14px; font-size: 12px;">
|
||||
<span style="color: rgba(235,228,241,0.5);">Don't have an account? </span>
|
||||
<a style="color: #eb3779;">Create one</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background: rgba(34,197,94,0.05); border: 1px solid rgba(34,197,94,0.15); border-radius: 8px; padding: 12px; margin-top: 12px;">
|
||||
<p style="color: rgba(235,228,241,0.7); font-size: 12px; margin: 0;">
|
||||
<strong>Key:</strong> Only email field is shown initially. The <code>autocomplete="username webauthn"</code> attribute enables passkey conditional UI — browsers with passkey support will offer to autofill passkey credentials.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Phase 2a: Email Exists → Password Entry</h3>
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">Login — Password Step (email exists)</div>
|
||||
<div class="mockup-body" style="background: #100b14; padding: 20px; min-height: 380px;">
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 28px; max-width: 380px; margin: 0 auto;">
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<div style="font-size: 18px; font-weight: bold; color: #ebe4f1;">Welcome back</div>
|
||||
<div style="font-size: 12px; color: rgba(235,228,241,0.5); margin-top: 4px;">Signing in as <strong style="color: #ebe4f1;">user@example.com</strong> <a style="color: #eb3779; font-size: 11px; cursor: pointer;">change</a></div>
|
||||
</div>
|
||||
|
||||
<!-- Password field with autocomplete for passkey conditional UI -->
|
||||
<label style="font-size: 12px; color: rgba(235,228,241,0.6); display: block; margin-bottom: 4px;">Password</label>
|
||||
<input style="width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); color: #ebe4f1; font-size: 14px; margin-bottom: 8px; box-sizing: border-box;" type="password" placeholder="Enter your password" autocomplete="current-password webauthn" />
|
||||
|
||||
<div style="text-align: right; margin-bottom: 16px;">
|
||||
<a style="font-size: 12px; color: rgba(235,228,241,0.5);">Forgot password?</a>
|
||||
</div>
|
||||
|
||||
<button style="width: 100%; padding: 10px; border-radius: 8px; border: none; background: #eb3779; color: white; font-size: 14px; font-weight: 600;">Sign in</button>
|
||||
|
||||
<!-- Passkey autofill hint -->
|
||||
<div style="margin-top: 12px; padding: 10px; border-radius: 8px; background: rgba(235,55,121,0.05); border: 1px solid rgba(235,55,121,0.1); text-align: center;">
|
||||
<div style="font-size: 12px; color: rgba(235,228,241,0.6);">🔑 Your browser may offer to sign in with a passkey</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background: rgba(34,197,94,0.05); border: 1px solid rgba(34,197,94,0.15); border-radius: 8px; padding: 12px; margin-top: 12px;">
|
||||
<p style="color: rgba(235,228,241,0.7); font-size: 12px; margin: 0;">
|
||||
<strong>Conditional UI:</strong> The password field has <code>autocomplete="current-password webauthn"</code>. On mount, we call <code>authClient.signIn.passkey({ autoFill: true })</code> 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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Phase 2b: Email NOT Found → Error + Signup Link</h3>
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">Login — Email Not Found</div>
|
||||
<div class="mockup-body" style="background: #100b14; padding: 20px; min-height: 380px;">
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 28px; max-width: 380px; margin: 0 auto;">
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<div style="font-size: 18px; font-weight: bold; color: #ebe4f1;">Welcome back</div>
|
||||
<div style="font-size: 12px; color: rgba(235,228,241,0.5); margin-top: 4px;">Sign in to DeckyVault</div>
|
||||
</div>
|
||||
|
||||
<!-- Email with error state -->
|
||||
<label style="font-size: 12px; color: rgba(235,228,241,0.6); display: block; margin-bottom: 4px;">Email</label>
|
||||
<input style="width: 100%; padding: 10px 12px; border-radius: 8px; border: 2px solid #ef4444; background: rgba(239,68,68,0.05); color: #ebe4f1; font-size: 14px; margin-bottom: 8px; box-sizing: border-box;" value="unknown@example.com" />
|
||||
|
||||
<!-- Error message -->
|
||||
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 16px;">
|
||||
<span style="color: #ef4444; font-size: 14px;">⚠</span>
|
||||
<span style="font-size: 12px; color: #ef4444;">No account found with this email.</span>
|
||||
<a style="font-size: 12px; color: #eb3779; white-space: nowrap; font-weight: 600;">Create one →</a>
|
||||
</div>
|
||||
|
||||
<button style="width: 100%; padding: 10px; border-radius: 8px; border: none; background: #eb3779; color: white; font-size: 14px; font-weight: 600;">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>How Email Check Works</h3>
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 16px;">
|
||||
<ol style="color: rgba(235,228,241,0.7); font-size: 13px; margin: 0; padding-left: 20px; line-height: 1.8;">
|
||||
<li>User enters email and clicks "Continue"</li>
|
||||
<li>Client calls <code>authClient.signIn.email()</code> with email + empty password</li>
|
||||
<li>Better-auth returns an error if email doesn't exist</li>
|
||||
<li>If error: show "No account found" with signup link</li>
|
||||
<li>If email exists: transition to password field with email displayed</li>
|
||||
</ol>
|
||||
<p style="color: rgba(235,228,241,0.5); font-size: 11px; margin: 12px 0 0 0;">
|
||||
<strong>Note:</strong> We use <code>signIn.email()</code> as the check mechanism rather than a separate endpoint. This avoids leaking whether an email exists via a dedicated check API.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,127 @@
|
||||
<h2>Password Reset Flow — Email OTP</h2>
|
||||
<p class="subtitle">Two-page flow: request reset → verify OTP + set new password</p>
|
||||
|
||||
<div class="section">
|
||||
<h3>Page 1: Forgot Password — Request OTP</h3>
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">/forgot-password</div>
|
||||
<div class="mockup-body" style="background: #100b14; padding: 20px; min-height: 380px;">
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 28px; max-width: 380px; margin: 0 auto;">
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<div style="font-size: 40px; margin-bottom: 8px;">🔒</div>
|
||||
<div style="font-size: 18px; font-weight: bold; color: #ebe4f1;">Forgot your password?</div>
|
||||
<div style="font-size: 12px; color: rgba(235,228,241,0.5); margin-top: 4px; line-height: 1.5;">Enter your email and we'll send you a verification code to reset your password.</div>
|
||||
</div>
|
||||
|
||||
<label style="font-size: 12px; color: rgba(235,228,241,0.6); display: block; margin-bottom: 4px;">Email</label>
|
||||
<input style="width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); color: #ebe4f1; font-size: 14px; margin-bottom: 16px; box-sizing: border-box;" placeholder="you@example.com" />
|
||||
|
||||
<button style="width: 100%; padding: 10px; border-radius: 8px; border: none; background: #eb3779; color: white; font-size: 14px; font-weight: 600;">Send verification code</button>
|
||||
|
||||
<div style="text-align: center; margin-top: 14px;">
|
||||
<a style="font-size: 12px; color: rgba(235,228,241,0.5);">← Back to sign in</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background: rgba(34,197,94,0.05); border: 1px solid rgba(34,197,94,0.15); border-radius: 8px; padding: 12px; margin-top: 12px;">
|
||||
<p style="color: rgba(235,228,241,0.7); font-size: 12px; margin: 0;">
|
||||
<strong>API:</strong> Calls <code>authClient.emailOtp.requestPasswordReset({ email })</code>. This triggers the server to send a "forget-password" OTP via the existing <code>sendVerificationOTP</code> handler.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Page 2: Reset Password — OTP + New Password</h3>
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">/reset-password?email=user@example.com</div>
|
||||
<div class="mockup-body" style="background: #100b14; padding: 20px; min-height: 450px;">
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 28px; max-width: 380px; margin: 0 auto;">
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<div style="font-size: 40px; margin-bottom: 8px;">📧</div>
|
||||
<div style="font-size: 18px; font-weight: bold; color: #ebe4f1;">Check your email</div>
|
||||
<div style="font-size: 12px; color: rgba(235,228,241,0.5); margin-top: 4px; line-height: 1.5;">We sent a 6-digit code to <strong style="color: #ebe4f1;">user@example.com</strong></div>
|
||||
</div>
|
||||
|
||||
<!-- OTP Input -->
|
||||
<label style="font-size: 12px; color: rgba(235,228,241,0.6); display: block; margin-bottom: 8px; text-align: center;">Verification code</label>
|
||||
<div style="display: flex; gap: 8px; justify-content: center; margin-bottom: 12px;">
|
||||
<div style="width: 44px; height: 52px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: bold; color: rgba(235,228,241,0.3);">_</div>
|
||||
<div style="width: 44px; height: 52px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: bold; color: rgba(235,228,241,0.3);">_</div>
|
||||
<div style="width: 44px; height: 52px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: bold; color: rgba(235,228,241,0.3);">_</div>
|
||||
<div style="width: 44px; height: 52px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: bold; color: rgba(235,228,241,0.3);">_</div>
|
||||
<div style="width: 44px; height: 52px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: bold; color: rgba(235,228,241,0.3);">_</div>
|
||||
<div style="width: 44px; height: 52px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: bold; color: rgba(235,228,241,0.3);">_</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-bottom: 16px; font-size: 12px;">
|
||||
<span style="color: rgba(235,228,241,0.4);">Resend code in </span>
|
||||
<span style="color: #eb3779; font-weight: 600;">4:23</span>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin: 16px 0;">
|
||||
<div style="flex: 1; height: 1px; background: rgba(255,255,255,0.08);"></div>
|
||||
<span style="font-size: 11px; color: rgba(235,228,241,0.4);">then set new password</span>
|
||||
<div style="flex: 1; height: 1px; background: rgba(255,255,255,0.08);"></div>
|
||||
</div>
|
||||
|
||||
<!-- New Password -->
|
||||
<label style="font-size: 12px; color: rgba(235,228,241,0.6); display: block; margin-bottom: 4px;">New password</label>
|
||||
<input style="width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); color: #ebe4f1; font-size: 14px; margin-bottom: 8px; box-sizing: border-box;" type="password" placeholder="Min. 10 characters" />
|
||||
|
||||
<!-- Password strength -->
|
||||
<div style="margin-bottom: 12px;">
|
||||
<div style="display: flex; gap: 4px; margin-bottom: 4px;">
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: rgba(255,255,255,0.1);"></div>
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: rgba(255,255,255,0.1);"></div>
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: rgba(255,255,255,0.1);"></div>
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: rgba(255,255,255,0.1);"></div>
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: rgba(255,255,255,0.1);"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<label style="font-size: 12px; color: rgba(235,228,241,0.6); display: block; margin-bottom: 4px;">Confirm new password</label>
|
||||
<input style="width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); color: #ebe4f1; font-size: 14px; margin-bottom: 16px; box-sizing: border-box;" type="password" placeholder="Re-enter password" />
|
||||
|
||||
<button style="width: 100%; padding: 10px; border-radius: 8px; border: none; background: #eb3779; color: white; font-size: 14px; font-weight: 600;">Reset password</button>
|
||||
|
||||
<div style="text-align: center; margin-top: 14px;">
|
||||
<a style="font-size: 12px; color: rgba(235,228,241,0.5);">← Back to sign in</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Success State</h3>
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">After successful reset</div>
|
||||
<div class="mockup-body" style="background: #100b14; padding: 20px; min-height: 200px;">
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 28px; max-width: 380px; margin: 0 auto; text-align: center;">
|
||||
<div style="font-size: 40px; margin-bottom: 12px;">✅</div>
|
||||
<div style="font-size: 18px; font-weight: bold; color: #ebe4f1; margin-bottom: 8px;">Password reset successful</div>
|
||||
<div style="font-size: 12px; color: rgba(235,228,241,0.5); margin-bottom: 20px;">Your password has been updated. You can now sign in with your new password.</div>
|
||||
<button style="padding: 10px 32px; border-radius: 8px; border: none; background: #eb3779; color: white; font-size: 14px; font-weight: 600;">Sign in</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>API Flow</h3>
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 16px;">
|
||||
<ol style="color: rgba(235,228,241,0.7); font-size: 13px; margin: 0; padding-left: 20px; line-height: 2;">
|
||||
<li>User enters email on <code>/forgot-password</code></li>
|
||||
<li>Client calls <code>authClient.emailOtp.requestPasswordReset({ email })</code></li>
|
||||
<li>Server sends OTP with type "forget-password" via <code>sendVerificationOTP</code></li>
|
||||
<li>User is redirected to <code>/reset-password?email=user@example.com</code></li>
|
||||
<li>User enters OTP + new password + confirm password</li>
|
||||
<li>Client calls <code>authClient.emailOtp.checkVerificationOtp({ email, otp, type: "forget-password" })</code> (optional validation)</li>
|
||||
<li>Client calls <code>authClient.resetPassword({ newPassword, otp })</code></li>
|
||||
<li>On success: show success screen, then redirect to <code>/login</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,192 @@
|
||||
<h2>Signup Wizard — Step-by-Step Flow</h2>
|
||||
<p class="subtitle">3 steps for email signup, 2 steps for social signup</p>
|
||||
|
||||
<div class="section">
|
||||
<h3>Step 1: Account Creation</h3>
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">Email Signup — Step 1 of 3</div>
|
||||
<div class="mockup-body" style="background: #100b14; padding: 20px; min-height: 380px;">
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 28px; max-width: 380px; margin: 0 auto;">
|
||||
<!-- Step indicator -->
|
||||
<div style="display: flex; gap: 8px; justify-content: center; margin-bottom: 20px;">
|
||||
<div style="width: 32px; height: 4px; border-radius: 2px; background: #eb3779;"></div>
|
||||
<div style="width: 32px; height: 4px; border-radius: 2px; background: rgba(255,255,255,0.1);"></div>
|
||||
<div style="width: 32px; height: 4px; border-radius: 2px; background: rgba(255,255,255,0.1);"></div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<div style="font-size: 18px; font-weight: bold; color: #ebe4f1;">Create your account</div>
|
||||
<div style="font-size: 12px; color: rgba(235,228,241,0.5); margin-top: 4px;">Choose how you'd like to sign up</div>
|
||||
</div>
|
||||
|
||||
<!-- Social buttons -->
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
|
||||
<button style="flex: 1; padding: 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); color: #ebe4f1; font-size: 13px;">🔵 Google</button>
|
||||
<button style="flex: 1; padding: 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); color: #ebe4f1; font-size: 13px;">🟣 Discord</button>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin: 16px 0;">
|
||||
<div style="flex: 1; height: 1px; background: rgba(255,255,255,0.08);"></div>
|
||||
<span style="font-size: 11px; color: rgba(235,228,241,0.4);">or use email</span>
|
||||
<div style="flex: 1; height: 1px; background: rgba(255,255,255,0.08);"></div>
|
||||
</div>
|
||||
|
||||
<!-- Form fields -->
|
||||
<label style="font-size: 12px; color: rgba(235,228,241,0.6); display: block; margin-bottom: 4px;">Email</label>
|
||||
<input style="width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); color: #ebe4f1; font-size: 14px; margin-bottom: 12px; box-sizing: border-box;" placeholder="you@example.com" />
|
||||
|
||||
<label style="font-size: 12px; color: rgba(235,228,241,0.6); display: block; margin-bottom: 4px;">Password</label>
|
||||
<input style="width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); color: #ebe4f1; font-size: 14px; margin-bottom: 8px; box-sizing: border-box;" type="password" placeholder="Min. 10 characters" />
|
||||
|
||||
<!-- Password strength meter -->
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="display: flex; gap: 4px; margin-bottom: 6px;">
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: #ef4444;"></div>
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: rgba(255,255,255,0.1);"></div>
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: rgba(255,255,255,0.1);"></div>
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: rgba(255,255,255,0.1);"></div>
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: rgba(255,255,255,0.1);"></div>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: #ef4444;">Weak — add uppercase, number, special char</div>
|
||||
</div>
|
||||
|
||||
<button style="width: 100%; padding: 10px; border-radius: 8px; border: none; background: #eb3779; color: white; font-size: 14px; font-weight: 600;">Continue</button>
|
||||
|
||||
<div style="text-align: center; margin-top: 14px; font-size: 12px;">
|
||||
<span style="color: rgba(235,228,241,0.5);">Already have an account? </span>
|
||||
<a style="color: #eb3779;">Sign in</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Step 2: Email Verification (OTP)</h3>
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">Email Signup — Step 2 of 3</div>
|
||||
<div class="mockup-body" style="background: #100b14; padding: 20px; min-height: 380px;">
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 28px; max-width: 380px; margin: 0 auto;">
|
||||
<!-- Step indicator -->
|
||||
<div style="display: flex; gap: 8px; justify-content: center; margin-bottom: 20px;">
|
||||
<div style="width: 32px; height: 4px; border-radius: 2px; background: #22c55e;"></div>
|
||||
<div style="width: 32px; height: 4px; border-radius: 2px; background: #eb3779;"></div>
|
||||
<div style="width: 32px; height: 4px; border-radius: 2px; background: rgba(255,255,255,0.1);"></div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<div style="font-size: 18px; font-weight: bold; color: #ebe4f1;">Verify your email</div>
|
||||
<div style="font-size: 12px; color: rgba(235,228,241,0.5); margin-top: 4px;">We sent a 6-digit code to <strong style="color: #ebe4f1;">user@example.com</strong></div>
|
||||
</div>
|
||||
|
||||
<!-- OTP Input boxes -->
|
||||
<div style="display: flex; gap: 8px; justify-content: center; margin-bottom: 16px;">
|
||||
<div style="width: 44px; height: 52px; border-radius: 8px; border: 2px solid #eb3779; background: rgba(235,55,121,0.05); display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: bold; color: #ebe4f1;">4</div>
|
||||
<div style="width: 44px; height: 52px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: bold; color: #ebe4f1;">7</div>
|
||||
<div style="width: 44px; height: 52px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: bold; color: rgba(235,228,241,0.3);">_</div>
|
||||
<div style="width: 44px; height: 52px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: bold; color: rgba(235,228,241,0.3);">_</div>
|
||||
<div style="width: 44px; height: 52px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: bold; color: rgba(235,228,241,0.3);">_</div>
|
||||
<div style="width: 44px; height: 52px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: bold; color: rgba(235,228,241,0.3);">_</div>
|
||||
</div>
|
||||
|
||||
<!-- Timer and resend -->
|
||||
<div style="text-align: center; margin-bottom: 16px; font-size: 12px;">
|
||||
<span style="color: rgba(235,228,241,0.4);">Resend code in </span>
|
||||
<span style="color: #eb3779; font-weight: 600;">4:23</span>
|
||||
</div>
|
||||
|
||||
<button style="width: 100%; padding: 10px; border-radius: 8px; border: none; background: #eb3779; color: white; font-size: 14px; font-weight: 600; opacity: 0.5;" disabled>Verify</button>
|
||||
|
||||
<div style="text-align: center; margin-top: 14px; font-size: 12px;">
|
||||
<a style="color: rgba(235,228,241,0.5);">← Back to signup</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Step 3: Passkey Setup (Optional)</h3>
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">Email Signup — Step 3 of 3 (Optional)</div>
|
||||
<div class="mockup-body" style="background: #100b14; padding: 20px; min-height: 380px;">
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 28px; max-width: 380px; margin: 0 auto;">
|
||||
<!-- Step indicator -->
|
||||
<div style="display: flex; gap: 8px; justify-content: center; margin-bottom: 20px;">
|
||||
<div style="width: 32px; height: 4px; border-radius: 2px; background: #22c55e;"></div>
|
||||
<div style="width: 32px; height: 4px; border-radius: 2px; background: #22c55e;"></div>
|
||||
<div style="width: 32px; height: 4px; border-radius: 2px; background: #eb3779;"></div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<div style="font-size: 40px; margin-bottom: 8px;">🔑</div>
|
||||
<div style="font-size: 18px; font-weight: bold; color: #ebe4f1;">Set up a passkey</div>
|
||||
<div style="font-size: 12px; color: rgba(235,228,241,0.5); margin-top: 4px; line-height: 1.5;">Sign in faster with biometrics or your device's security key. No password needed.</div>
|
||||
</div>
|
||||
|
||||
<!-- Benefits -->
|
||||
<div style="background: rgba(255,255,255,0.02); border-radius: 8px; padding: 14px; margin-bottom: 20px;">
|
||||
<div style="font-size: 12px; color: rgba(235,228,241,0.6); display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<span style="color: #22c55e;">✓</span> Faster sign-in with fingerprint or face
|
||||
</div>
|
||||
<div style="font-size: 12px; color: rgba(235,228,241,0.6); display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<span style="color: #22c55e;">✓</span> More secure than passwords
|
||||
</div>
|
||||
<div style="font-size: 12px; color: rgba(235,228,241,0.6); display: flex; align-items: center; gap: 8px;">
|
||||
<span style="color: #22c55e;">✓</span> Works across your devices
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button style="width: 100%; padding: 10px; border-radius: 8px; border: none; background: #eb3779; color: white; font-size: 14px; font-weight: 600; margin-bottom: 10px;">Set up passkey</button>
|
||||
<button style="width: 100%; padding: 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: rgba(235,228,241,0.6); font-size: 13px;">Skip for now</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Social Signup Flow (2 steps)</h3>
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 16px;">
|
||||
<p style="color: rgba(235,228,241,0.7); font-size: 13px; margin: 0;">
|
||||
<strong>Step 1:</strong> User clicks Google/Discord → OAuth redirect → authenticated<br>
|
||||
<strong>Step 2:</strong> Passkey setup (optional, same as Step 3 above) → Done<br><br>
|
||||
<em>OTP step is skipped — email is already verified by the OAuth provider.</em>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Password Strength Meter — States</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 12px;">
|
||||
<div style="display: flex; gap: 4px; margin-bottom: 4px;">
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: #ef4444;"></div>
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: rgba(255,255,255,0.1);"></div>
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: rgba(255,255,255,0.1);"></div>
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: rgba(255,255,255,0.1);"></div>
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: rgba(255,255,255,0.1);"></div>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: #ef4444;">Weak — add 8+ chars, uppercase, number, special char</div>
|
||||
</div>
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 12px;">
|
||||
<div style="display: flex; gap: 4px; margin-bottom: 4px;">
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: #f59e0b;"></div>
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: #f59e0b;"></div>
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: rgba(255,255,255,0.1);"></div>
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: rgba(255,255,255,0.1);"></div>
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: rgba(255,255,255,0.1);"></div>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: #f59e0b;">Fair — add a special character and more length</div>
|
||||
</div>
|
||||
<div style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 12px;">
|
||||
<div style="display: flex; gap: 4px; margin-bottom: 4px;">
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: #22c55e;"></div>
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: #22c55e;"></div>
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: #22c55e;"></div>
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: #22c55e;"></div>
|
||||
<div style="flex: 1; height: 3px; border-radius: 2px; background: #22c55e;"></div>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: #22c55e;">Strong — great password!</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
|
||||
<p class="subtitle">Continuing in terminal...</p>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
{"reason":"idle timeout","timestamp":1777134909078}
|
||||
@@ -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"}
|
||||
@@ -0,0 +1 @@
|
||||
26725
|
||||
@@ -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 <FaWindows className={className} aria-label="Windows" />
|
||||
}
|
||||
|
||||
export function MacIcon({ className = "h-3.5 w-3.5" }: { className?: string }) {
|
||||
return <FaApple className={className} aria-label="macOS" />
|
||||
}
|
||||
|
||||
export function LinuxIcon({ className = "h-3.5 w-3.5" }: { className?: string }) {
|
||||
return <FaLinux className={className} aria-label="Linux" />
|
||||
}
|
||||
@@ -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<TabKey>("overview")
|
||||
const [imgError, setImgError] = useState(false)
|
||||
const handleImgError = useCallback(() => setImgError(true), [])
|
||||
|
||||
const headerImage = game.headerImage || game.capsuleImage
|
||||
const headerImage = game.capsuleImage || game.headerImage
|
||||
|
||||
return (
|
||||
<section className="w-full flex flex-col">
|
||||
{/* Hero */}
|
||||
<div className="relative w-full h-48 sm:h-64 md:h-80 overflow-hidden">
|
||||
{headerImage ? (
|
||||
{headerImage && !imgError ? (
|
||||
<Image
|
||||
src={headerImage}
|
||||
alt={game.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
onError={handleImgError}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-text/10 flex items-center justify-center">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+427
-88
@@ -1,9 +1,21 @@
|
||||
"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"
|
||||
@@ -13,8 +25,21 @@ interface UnifiedResult {
|
||||
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() {
|
||||
@@ -25,10 +50,11 @@ function SearchContent() {
|
||||
const [results, setResults] = useState<UnifiedResult[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Handle direct navigation / browser back-forward
|
||||
useEffect(() => {
|
||||
if (!query) {
|
||||
if (!query || query.length < 2) {
|
||||
setResults([])
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -56,7 +82,9 @@ function SearchContent() {
|
||||
}
|
||||
|
||||
fetchResults()
|
||||
return () => { cancelled = true }
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [query])
|
||||
|
||||
function handleClick(result: UnifiedResult) {
|
||||
@@ -69,27 +97,43 @@ function SearchContent() {
|
||||
return (
|
||||
<section className="w-full min-h-[calc(100vh-3.6rem)] flex flex-col items-center p-4 md:px-[10svw]">
|
||||
<div className="w-full max-w-7xl">
|
||||
<h1 className="text-2xl font-light mb-2">
|
||||
{query ? `results for "${query}"` : "search using game name or appid"}
|
||||
</h1>
|
||||
<p className="text-text/60 text-sm mb-8">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-2xl font-light mb-2"
|
||||
>
|
||||
{query ? `Results for "${query}"` : "Search using game name or AppID"}
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { delay: 0.1 } }}
|
||||
className="text-text/60 text-sm mb-8"
|
||||
>
|
||||
{query
|
||||
? `${results.length} result${results.length !== 1 ? "s" : ""} found`
|
||||
: "Enter a game name or AppID to find benchmarks, settings, and reviews."}
|
||||
</p>
|
||||
</motion.p>
|
||||
|
||||
{!query && (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col items-center justify-center py-20 gap-4"
|
||||
>
|
||||
<Gamepad2Icon className="h-12 w-12 text-text/20" />
|
||||
<p className="text-text/40 text-sm">
|
||||
Start typing to search for games
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{query && loading && (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full" />
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ repeat: Infinity, duration: 1, ease: "linear" }}
|
||||
className="h-8 w-8 border-2 border-primary border-t-transparent rounded-full"
|
||||
/>
|
||||
<p className="text-text/60 text-sm">
|
||||
Searching for "{query}"...
|
||||
</p>
|
||||
@@ -97,30 +141,53 @@ function SearchContent() {
|
||||
)}
|
||||
|
||||
{query && !loading && error && (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex flex-col items-center justify-center py-20 gap-4"
|
||||
>
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{query && !loading && !error && results.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col items-center justify-center py-20 gap-4"
|
||||
>
|
||||
<Gamepad2Icon className="h-12 w-12 text-text/20" />
|
||||
<p className="text-text/40 text-sm">
|
||||
No results found for "{query}"
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{query && !loading && !error && results.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<motion.div
|
||||
className="flex flex-col gap-3"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.04 } },
|
||||
}}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{results.map((result, idx) => (
|
||||
<SearchResultCard
|
||||
key={result.kind === "local" ? result.id : `steam-${result.appId}-${idx}`}
|
||||
key={
|
||||
result.kind === "local"
|
||||
? result.id
|
||||
: `steam-${result.appId}-${idx}`
|
||||
}
|
||||
result={result}
|
||||
onClick={handleClick}
|
||||
index={idx}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -130,103 +197,375 @@ function SearchContent() {
|
||||
function SearchResultCard({
|
||||
result,
|
||||
onClick,
|
||||
index,
|
||||
}: {
|
||||
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 counts = result.counts
|
||||
const hasData =
|
||||
isLocal &&
|
||||
counts &&
|
||||
(counts.benchmarks > 0 || counts.presets > 0 || counts.comments > 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group flex flex-col gap-3 p-3 rounded-lg border border-border bg-text/5 hover:border-primary/50 transition-all cursor-pointer"
|
||||
onClick={() => onClick(result)}
|
||||
<motion.article
|
||||
layout
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
}}
|
||||
transition={{ duration: 0.3, delay: index * 0.02 }}
|
||||
whileHover={{ scale: 1.005 }}
|
||||
whileTap={{ scale: 0.995 }}
|
||||
className="group relative bg-text/[0.03] border border-border rounded-xl p-4 sm:p-5 transition-colors duration-200 hover:border-text/30 hover:bg-text/[0.06] cursor-pointer focus-within:outline-none focus-within:ring-2 focus-within:ring-text/20 focus-within:ring-offset-2 focus-within:ring-offset-background"
|
||||
title={isLocal
|
||||
? "Click to view game details, benchmarks, and settings"
|
||||
: "Click to add this game to DeckyVault and view its page"
|
||||
}
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="relative w-full aspect-[460/215] rounded-md overflow-hidden bg-text/10">
|
||||
{result.image ? (
|
||||
<Image
|
||||
src={result.image}
|
||||
alt={result.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
{/* Full-card click target */}
|
||||
<div
|
||||
className="absolute inset-0 after:content-[''] after:absolute after:inset-0"
|
||||
onClick={() => onClick(result)}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Gamepad2Icon className="h-8 w-8 text-text/20" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 sm:gap-5">
|
||||
{/* Cover */}
|
||||
<motion.div
|
||||
className="relative w-20 sm:w-24 md:w-28 shrink-0 aspect-[2/3] rounded-lg overflow-hidden bg-text/10 shadow-sm"
|
||||
whileHover={{ scale: 1.03 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<GameCover image={result.image} title={result.title} />
|
||||
</motion.div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
{/* Row 1: Title + metascore/price row */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm sm:text-base font-semibold text-text group-hover:text-primary transition-colors duration-200 truncate">
|
||||
{result.title}
|
||||
</h3>
|
||||
{(result.developer || result.publisher) && (
|
||||
<p className="text-[11px] text-text/45 mt-0.5 truncate">
|
||||
{result.developer}
|
||||
{result.developer && result.publisher ? " · " : ""}
|
||||
{result.publisher}
|
||||
</p>
|
||||
)}
|
||||
{/* Source badge */}
|
||||
<div className="absolute top-2 right-2 flex gap-1">
|
||||
{isLocal && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider bg-primary/90 text-white px-1.5 py-0.5 rounded">
|
||||
In Database
|
||||
</div>
|
||||
|
||||
{/* Metascore + Price row */}
|
||||
<div className="hidden sm:flex items-center gap-2 shrink-0">
|
||||
{result.metascore ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-green-500/10 border border-green-500/20 text-green-400 text-[11px] font-semibold"
|
||||
title={`Metascore: ${result.metascore}/100`}
|
||||
>
|
||||
<TrendingUpIcon className="h-3 w-3" />
|
||||
{result.metascore}
|
||||
</span>
|
||||
) : null}
|
||||
<PriceTag price={result.price} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Description */}
|
||||
{result.description && (
|
||||
<p className="text-[11px] text-text/40 line-clamp-2 leading-relaxed mt-1.5">
|
||||
{result.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Row 3: Genre tags + Platform icons + Controller */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 mt-2">
|
||||
{result.genres && result.genres.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{result.genres.slice(0, 3).map((genre) => (
|
||||
<span
|
||||
key={genre}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-text/5 border border-border text-text/35"
|
||||
>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
{result.genres.length > 3 && (
|
||||
<span className="text-[10px] text-text/25 self-center">
|
||||
+{result.genres.length - 3}
|
||||
</span>
|
||||
)}
|
||||
{!isLocal && result.appId && (
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Platform icons — always show all 3, color if present */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span title="Windows">
|
||||
<WindowsIcon
|
||||
className={`h-3.5 w-3.5 ${result.platforms?.windows ? "text-blue-400" : "text-text/20"}`}
|
||||
/>
|
||||
</span>
|
||||
<span title="macOS">
|
||||
<MacIcon
|
||||
className={`h-3.5 w-3.5 ${result.platforms?.mac ? "text-text/60" : "text-text/20"}`}
|
||||
/>
|
||||
</span>
|
||||
<span title="Linux">
|
||||
<LinuxIcon
|
||||
className={`h-3.5 w-3.5 ${result.platforms?.linux ? "text-yellow-500" : "text-text/20"}`}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{result.controllerSupport && (
|
||||
<span
|
||||
className="text-[10px] text-text/25 flex items-center gap-1"
|
||||
title="Full controller support"
|
||||
>
|
||||
<Gamepad2Icon className="h-3 w-3" />
|
||||
Controller
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 4: Stats row */}
|
||||
<div className="flex flex-wrap items-center gap-3 mt-2.5">
|
||||
{hasData ? (
|
||||
<>
|
||||
{counts!.benchmarks > 0 && (
|
||||
<StatBadge
|
||||
icon={TrendingUpIcon}
|
||||
count={counts!.benchmarks}
|
||||
label="Benchmarks"
|
||||
/>
|
||||
)}
|
||||
{counts!.presets > 0 && (
|
||||
<StatBadge
|
||||
icon={SettingsIcon}
|
||||
count={counts!.presets}
|
||||
label="Presets"
|
||||
/>
|
||||
)}
|
||||
{counts!.comments > 0 && (
|
||||
<StatBadge
|
||||
icon={MessageSquareIcon}
|
||||
count={counts!.comments}
|
||||
label="Comments"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : isLocal ? (
|
||||
<span className="text-[11px] text-text/30 italic">
|
||||
No data yet — be the first to contribute
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col items-start gap-1.5 w-full sm:w-auto">
|
||||
<a
|
||||
href={`https://store.steampowered.com/app/${result.appId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] font-medium uppercase tracking-wider bg-text/80 text-background px-1.5 py-0.5 rounded flex items-center gap-1"
|
||||
className="relative z-10 inline-flex items-center justify-center gap-1.5 px-2.5 py-1 rounded-md bg-[#1b2838] border border-[#2a475e] text-[#c6d4df] text-[11px] hover:bg-[#2a475e] transition-colors w-full"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Open store page on Steam"
|
||||
>
|
||||
<FaSteam className="h-3 w-3" />
|
||||
Steam
|
||||
<ExternalLinkIcon className="h-2.5 w-2.5" />
|
||||
<ExternalLinkIcon className="h-2.5 w-2.5 opacity-60" />
|
||||
</a>
|
||||
<a
|
||||
href={`https://www.protondb.com/app/${result.appId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="relative z-10 inline-flex items-center justify-center gap-1.5 px-2.5 py-1 rounded-md bg-purple-500/10 border border-purple-500/20 text-purple-400 text-[11px] hover:bg-purple-500/20 transition-colors w-full"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="View Proton compatibility on ProtonDB"
|
||||
>
|
||||
<SparklesIcon className="h-3 w-3" />
|
||||
ProtonDB
|
||||
<ExternalLinkIcon className="h-2.5 w-2.5 opacity-60" />
|
||||
</a>
|
||||
<a
|
||||
href={`https://steamdb.info/app/${result.appId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="relative z-10 inline-flex items-center justify-center gap-1.5 px-2.5 py-1 rounded-md bg-blue-500/10 border border-blue-500/20 text-blue-400 text-[11px] hover:bg-blue-500/20 transition-colors w-full"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="View app details on SteamDB"
|
||||
>
|
||||
<DatabaseIcon className="h-3 w-3" />
|
||||
SteamDB
|
||||
<ExternalLinkIcon className="h-2.5 w-2.5 opacity-60" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate leading-tight">
|
||||
{result.title}
|
||||
</p>
|
||||
{hasDeveloperInfo ? (
|
||||
<p className="text-xs text-text/50 truncate">
|
||||
{result.developer || result.publisher}
|
||||
</p>
|
||||
) : !isLocal && result.appId ? (
|
||||
<p className="text-xs text-text/40">
|
||||
AppID: {result.appId}
|
||||
</p>
|
||||
) : null}
|
||||
{/* Row 5: Anti-cheat info */}
|
||||
{result.platformSupport?.antiCheatRelevant && (
|
||||
<div className="mt-2">
|
||||
<span className="text-[10px] text-text/30">
|
||||
Anti-cheat: {result.platformSupport.antiCheatName || "Unknown"}
|
||||
<span className="text-text/20">
|
||||
{" "}— {result.platformSupport.antiCheatStatus}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats (local only) */}
|
||||
{isLocal && result.counts && (
|
||||
<div className="flex flex-row gap-3 text-[11px] text-text/50">
|
||||
{result.counts.benchmarks > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<TrendingUpIcon className="h-3 w-3" />
|
||||
{result.counts.benchmarks}
|
||||
{/* Right Panel — Desktop Only */}
|
||||
<div className="hidden md:flex flex-col items-end justify-center gap-2.5 shrink-0 min-w-[120px]">
|
||||
{/* Deck Status */}
|
||||
<DataField
|
||||
label="Deck"
|
||||
value={result.platformSupport ? protonLabel(result.platformSupport.protonStatus) : "—"}
|
||||
color={result.platformSupport ? protonColor(result.platformSupport.protonStatus) : undefined}
|
||||
/>
|
||||
|
||||
{/* Avg FPS */}
|
||||
<DataField label="Avg. FPS" value="—" bar />
|
||||
|
||||
{/* Version */}
|
||||
<DataField label="Version" value="—" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.article>
|
||||
)
|
||||
}
|
||||
|
||||
function DataField({
|
||||
label,
|
||||
value,
|
||||
bar,
|
||||
color,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
bar?: boolean
|
||||
color?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1 w-full">
|
||||
<span className="text-[10px] text-text/30 uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{result.counts.presets > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<SettingsIcon className="h-3 w-3" />
|
||||
{result.counts.presets}
|
||||
</span>
|
||||
)}
|
||||
{result.counts.comments > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquareIcon className="h-3 w-3" />
|
||||
{result.counts.comments}
|
||||
</span>
|
||||
)}
|
||||
{!hasData && (
|
||||
<span className="text-text/30 italic">No data yet</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 w-full justify-end">
|
||||
{bar && (
|
||||
<div className="w-14 h-1.5 rounded-full bg-text/5 overflow-hidden">
|
||||
<div className="h-full w-0 rounded-full bg-primary/40" />
|
||||
</div>
|
||||
)}
|
||||
<span className={`text-xs tabular-nums ${color || "text-text/25"}`}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function protonLabel(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
native: "Native",
|
||||
proton: "Proton",
|
||||
unsupported: "Unsupported",
|
||||
unknown: "Unknown",
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
function protonColor(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
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 (
|
||||
<Image
|
||||
src={image}
|
||||
alt={title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 80px, 112px"
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Gamepad2Icon className="h-6 w-6 text-text/20" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PriceTag({
|
||||
price,
|
||||
}: {
|
||||
price?: { currency: string; initial: number; final: number } | null
|
||||
}) {
|
||||
if (!price || price.final === 0) {
|
||||
return (
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-md bg-green-500/10 border border-green-500/20 text-green-400 text-[11px] font-medium"
|
||||
title="Free to play"
|
||||
>
|
||||
Free
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const isDiscounted = price.final < price.initial
|
||||
const fmt = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: price.currency,
|
||||
})
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{isDiscounted && (
|
||||
<span className="text-[10px] text-text/30 line-through">
|
||||
{fmt.format(price.initial / 100)}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-md bg-text/5 border border-border text-text/60 text-[11px] font-medium"
|
||||
title={isDiscounted ? "Discounted price" : "Current price"}
|
||||
>
|
||||
{fmt.format(price.final / 100)}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StatBadge({
|
||||
icon: Icon,
|
||||
count,
|
||||
label,
|
||||
}: {
|
||||
icon: React.ElementType
|
||||
count: number
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[11px] text-text/50"
|
||||
title={`${count} ${label.toLowerCase()}`}
|
||||
>
|
||||
<Icon className="h-3 w-3 text-text/30" />
|
||||
<span className="tabular-nums">{count}</span>
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+27
-2
@@ -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,
|
||||
})),
|
||||
|
||||
+1
-2
@@ -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<void> {
|
||||
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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user