Initial commit
This commit is contained in:
@@ -10,6 +10,8 @@ CMD ["npm", "run", "dev", "--", "--host"]
|
||||
# --- Build stage ---
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
ARG VITE_API_URL
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
|
||||
@@ -2,53 +2,53 @@
|
||||
|
||||
## Phase 1 — MVP (Prototype)
|
||||
|
||||
- [ ] Set up TMDB API client with axios (api key handling, base URL, interceptors)
|
||||
- [ ] Build debounced autocomplete search component (actors and movies)
|
||||
- [ ] Create movie card component (poster, title, year)
|
||||
- [ ] Create actor card component (photo, name)
|
||||
- [ ] Build chain builder UI (alternating actor/movie input)
|
||||
- [ ] Implement real-time TMDB validation for each link
|
||||
- [ ] Create basic chain visualization (list or simple node graph)
|
||||
- [ ] Implement game state store with Zustand (chain, current step, validation)
|
||||
- [ ] Build Home page with "Start Game" flow
|
||||
- [ ] Build Game page with chain builder and visualization
|
||||
- [ ] Support hardcoded or randomly selected movie pairs
|
||||
- [ ] Basic score calculation (base + chain length bonus)
|
||||
- [ ] Display score on completion
|
||||
- [x] Set up TMDB API client with axios (api key handling, base URL, interceptors)
|
||||
- [x] Build debounced autocomplete search component (actors and movies)
|
||||
- [x] Create movie card component (poster, title, year)
|
||||
- [x] Create actor card component (photo, name)
|
||||
- [x] Build chain builder UI (alternating actor/movie input)
|
||||
- [x] Implement real-time TMDB validation for each link
|
||||
- [x] Create basic chain visualization (list or simple node graph)
|
||||
- [x] Implement game state store with Zustand (chain, current step, validation)
|
||||
- [x] Build Home page with "Start Game" flow
|
||||
- [x] Build Game page with chain builder and visualization
|
||||
- [x] Support hardcoded or randomly selected movie pairs
|
||||
- [x] Basic score calculation (base + chain length bonus)
|
||||
- [x] Display score on completion
|
||||
|
||||
## Phase 2 — Scoring & Polish
|
||||
|
||||
- [ ] Full scoring system (time bonus, obscurity bonus, duplicate penalties)
|
||||
- [ ] Timer component with visual countdown
|
||||
- [ ] Animated chain graph with React Flow or custom SVG
|
||||
- [ ] Movie poster and actor photo nodes in the chain graph
|
||||
- [ ] Undo functionality (step back one link)
|
||||
- [ ] Hint system (reveal 3 random actors from a movie's cast)
|
||||
- [ ] Sound effects (valid link, invalid attempt, completion)
|
||||
- [ ] Celebration animation on loop completion
|
||||
- [ ] Score breakdown summary screen
|
||||
- [ ] Mobile-responsive design pass
|
||||
- [ ] Error handling and edge case coverage
|
||||
- [ ] Loading states and skeleton screens
|
||||
- [x] Full scoring system (time bonus, obscurity bonus, duplicate penalties)
|
||||
- [x] Timer component with visual countdown
|
||||
- [x] Animated chain display with CSS slide-in, glow, and confetti
|
||||
- [x] Movie poster and actor photo nodes in the chain display
|
||||
- [x] Undo functionality (step back one link)
|
||||
- [x] Hint system (reveal 3 random actors from a movie's cast)
|
||||
- [x] Sound effects (valid link, invalid attempt, completion)
|
||||
- [x] Celebration animation on loop completion
|
||||
- [x] Score breakdown summary screen
|
||||
- [x] Mobile-responsive design pass
|
||||
- [x] Error handling and edge case coverage
|
||||
- [x] Loading states and skeleton screens
|
||||
|
||||
## Phase 3 — Daily Challenge & Social
|
||||
|
||||
- [ ] Daily Challenge page (fetches today's puzzle from backend)
|
||||
- [ ] Shareable results card (text-based summary for social media)
|
||||
- [ ] Streak tracking UI (consecutive days played)
|
||||
- [ ] Personal stats and history (local storage + backend sync)
|
||||
- [ ] Leaderboard page (daily, weekly, all-time)
|
||||
- [ ] SEO and Open Graph meta tags
|
||||
- [ ] Login/signup pages (connect to backend auth)
|
||||
- [ ] Profile page (stats, streaks, history)
|
||||
- [x] Daily Challenge page (fetches today's puzzle from backend)
|
||||
- [x] Shareable results card (text-based summary for social media)
|
||||
- [x] Streak tracking UI (consecutive days played)
|
||||
- [x] Personal stats and history (local storage + backend sync)
|
||||
- [x] Leaderboard page (daily, weekly, all-time)
|
||||
- [x] SEO and Open Graph meta tags
|
||||
- [x] Login/signup pages (connect to backend auth)
|
||||
- [x] Profile page (stats, streaks, history)
|
||||
|
||||
## Phase 4 — Multiplayer & Expansion
|
||||
|
||||
- [ ] Versus mode UI (real-time or async)
|
||||
- [ ] Difficulty tier selection on Home page
|
||||
- [ ] User profile page with persistent data
|
||||
- [ ] Global leaderboards (daily, weekly, all-time tabs)
|
||||
- [ ] Achievement/badge system UI
|
||||
- [ ] Endless/practice mode with genre and decade filters
|
||||
- [ ] Accessibility improvements (keyboard nav, screen reader, ARIA)
|
||||
- [ ] Themed weeks/events UI
|
||||
- [x] Versus mode UI (real-time or async)
|
||||
- [x] Difficulty tier selection on Home page
|
||||
- [x] User profile page with persistent data
|
||||
- [x] Global leaderboards (daily, weekly, all-time tabs)
|
||||
- [x] Achievement/badge system UI
|
||||
- [x] Endless/practice mode with genre and decade filters
|
||||
- [x] Accessibility improvements (keyboard nav, screen reader, ARIA)
|
||||
- [x] Themed weeks/events UI
|
||||
|
||||
+20
@@ -5,8 +5,28 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>You Know Who Else Was In That Movie?</title>
|
||||
<meta name="description" content="A trivia-puzzle game inspired by Six Degrees of Kevin Bacon. Build a chain of actor-movie connections forming a complete loop between two movies." />
|
||||
<meta name="theme-color" content="#1e1b30" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="You Know Who Else Was In That Movie?" />
|
||||
<meta property="og:description" content="Build a chain of actor-movie connections forming a complete loop. Play today's daily challenge!" />
|
||||
<meta property="og:site_name" content="Movie Loop" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="You Know Who Else Was In That Movie?" />
|
||||
<meta name="twitter:description" content="Build a chain of actor-movie connections forming a complete loop. Play today's daily challenge!" />
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
(function() {
|
||||
var t = localStorage.getItem('theme');
|
||||
if (t !== 'light' && t !== 'dark') t = 'dark';
|
||||
if (t === 'dark') document.documentElement.classList.add('dark');
|
||||
})();
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
+6
-13
@@ -1,9 +1,15 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
|
||||
@@ -15,19 +21,6 @@ server {
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# SPA fallback — serve index.html for all non-file routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
Generated
+86
-1
@@ -19,6 +19,7 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router": "^7.13.1",
|
||||
"shadcn": "^4.0.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
@@ -2066,6 +2067,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
|
||||
@@ -3695,6 +3702,28 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
|
||||
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.18.3",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.20.0",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
|
||||
@@ -6830,6 +6859,34 @@
|
||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
|
||||
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
@@ -7349,7 +7406,6 @@
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
@@ -7548,6 +7604,27 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/wsl-utils": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz",
|
||||
@@ -7564,6 +7641,14 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router": "^7.13.1",
|
||||
"shadcn": "^4.0.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="185" height="278" viewBox="0 0 185 278" fill="none">
|
||||
<rect width="185" height="278" rx="8" fill="#1a1a2e"/>
|
||||
<rect x="60" y="100" width="65" height="50" rx="4" stroke="#4a4a6a" stroke-width="2" fill="none"/>
|
||||
<circle cx="77" cy="120" r="6" stroke="#4a4a6a" stroke-width="2" fill="none"/>
|
||||
<path d="M68 142L82 128L96 135L117 115" stroke="#4a4a6a" stroke-width="2" fill="none"/>
|
||||
<text x="92.5" y="180" text-anchor="middle" fill="#4a4a6a" font-family="sans-serif" font-size="12">No Poster</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 554 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="185" height="185" viewBox="0 0 185 185" fill="none">
|
||||
<rect width="185" height="185" rx="92.5" fill="#1a1a2e"/>
|
||||
<circle cx="92.5" cy="72" r="28" stroke="#4a4a6a" stroke-width="2" fill="none"/>
|
||||
<path d="M45 155C45 128 65 110 92.5 110C120 110 140 128 140 155" stroke="#4a4a6a" stroke-width="2" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 365 B |
-42
@@ -1,42 +0,0 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
+41
-30
@@ -1,35 +1,46 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from '/vite.svg'
|
||||
import './App.css'
|
||||
import { useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router'
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import Home from '@/pages/Home'
|
||||
import Game from '@/pages/Game'
|
||||
import DailyChallenge from '@/pages/DailyChallenge'
|
||||
import Leaderboard from '@/pages/Leaderboard'
|
||||
import Profile from '@/pages/Profile'
|
||||
import Login from '@/pages/Login'
|
||||
import Versus from '@/pages/Versus'
|
||||
import VersusLobby from '@/pages/VersusLobby'
|
||||
import VersusGame from '@/pages/VersusGame'
|
||||
import AsyncVersusGame from '@/pages/AsyncVersusGame'
|
||||
import AsyncMatchLeaderboard from '@/pages/AsyncMatchLeaderboard'
|
||||
import Achievements from '@/pages/Achievements'
|
||||
import Endless from '@/pages/Endless'
|
||||
import NotFound from '@/pages/NotFound'
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
export default function App() {
|
||||
const loadUser = useAuthStore((s) => s.loadUser);
|
||||
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
}, [loadUser]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<a href="https://vite.dev" target="_blank">
|
||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://react.dev" target="_blank">
|
||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
||||
</a>
|
||||
</div>
|
||||
<h1>Vite + React</h1>
|
||||
<div className="card">
|
||||
<button onClick={() => setCount((count) => count + 1)}>
|
||||
count is {count}
|
||||
</button>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test HMR
|
||||
</p>
|
||||
</div>
|
||||
<p className="read-the-docs">
|
||||
Click on the Vite and React logos to learn more
|
||||
</p>
|
||||
</>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/game" element={<Game />} />
|
||||
<Route path="/daily" element={<DailyChallenge />} />
|
||||
<Route path="/leaderboard" element={<Leaderboard />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/versus" element={<Versus />} />
|
||||
<Route path="/versus/lobby/:matchId" element={<VersusLobby />} />
|
||||
<Route path="/versus/game" element={<VersusGame />} />
|
||||
<Route path="/versus/async-game/:matchId" element={<AsyncVersusGame />} />
|
||||
<Route path="/versus/async/:matchId/leaderboard" element={<AsyncMatchLeaderboard />} />
|
||||
<Route path="/achievements" element={<Achievements />} />
|
||||
<Route path="/endless" element={<Endless />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface Achievement {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
category: string;
|
||||
threshold: number;
|
||||
progress: number;
|
||||
unlocked: boolean;
|
||||
unlockedAt: string | null;
|
||||
}
|
||||
|
||||
export async function getAllAchievements(): Promise<Achievement[]> {
|
||||
const { data } = await apiClient.get<Achievement[]>('/achievements');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getUserAchievements(): Promise<Achievement[]> {
|
||||
const { data } = await apiClient.get<Achievement[]>('/achievements/me');
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface AuthResponse {
|
||||
access_token: string;
|
||||
user: { id: string; email: string; username: string };
|
||||
}
|
||||
|
||||
export async function register(
|
||||
email: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<AuthResponse> {
|
||||
const { data } = await apiClient.post<AuthResponse>('/auth/register', {
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function login(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<AuthResponse> {
|
||||
const { data } = await apiClient.post<AuthResponse>('/auth/login', {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getProfile() {
|
||||
const { data } = await apiClient.get('/users/me');
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || '/api/v1',
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Attach JWT token to requests if available
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
const message =
|
||||
error.response?.data?.message || error.message || 'An error occurred';
|
||||
console.error('[API Error]', message);
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
@@ -0,0 +1,47 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface DailyChallenge {
|
||||
id: string;
|
||||
date: string;
|
||||
movieAId: number;
|
||||
movieATitle: string;
|
||||
movieBId: number;
|
||||
movieBTitle: string;
|
||||
par: number;
|
||||
difficulty: string;
|
||||
}
|
||||
|
||||
export interface DailyChallengeHistory {
|
||||
data: DailyChallenge[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export async function getTodaysChallenge(): Promise<DailyChallenge> {
|
||||
const { data } = await apiClient.get<DailyChallenge>(
|
||||
'/daily-challenges/today',
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getChallengeHistory(
|
||||
page = 1,
|
||||
limit = 20,
|
||||
): Promise<DailyChallengeHistory> {
|
||||
const { data } = await apiClient.get<DailyChallengeHistory>(
|
||||
'/daily-challenges',
|
||||
{ params: { page, limit } },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getChallengeByDate(
|
||||
date: string,
|
||||
): Promise<DailyChallenge> {
|
||||
const { data } = await apiClient.get<DailyChallenge>(
|
||||
`/daily-challenges/${date}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import apiClient from './client';
|
||||
import type { ChainLink } from '@/types';
|
||||
|
||||
interface CreateGameResponse {
|
||||
id: string;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
export async function createGameSession(
|
||||
movieAId: number,
|
||||
movieATitle: string,
|
||||
movieBId: number,
|
||||
movieBTitle: string,
|
||||
): Promise<CreateGameResponse> {
|
||||
const { data } = await apiClient.post<CreateGameResponse>('/games', {
|
||||
movieAId,
|
||||
movieATitle,
|
||||
movieBId,
|
||||
movieBTitle,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function completeGameSession(
|
||||
id: string,
|
||||
chain: ChainLink[],
|
||||
hintsUsed: number,
|
||||
) {
|
||||
const { data } = await apiClient.post(`/games/${id}/complete`, {
|
||||
chain,
|
||||
hintsUsed,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export { searchMovies, getMovieDetails, getMovieCredits, discoverMovies } from './movies';
|
||||
export {
|
||||
searchPersons,
|
||||
getPersonDetails,
|
||||
getPersonMovieCredits,
|
||||
} from './persons';
|
||||
export { createGameSession, completeGameSession } from './games';
|
||||
export { register, login, getProfile } from './auth';
|
||||
export {
|
||||
getTodaysChallenge,
|
||||
getChallengeHistory,
|
||||
getChallengeByDate,
|
||||
} from './daily-challenges';
|
||||
export {
|
||||
getLeaderboard,
|
||||
submitScore,
|
||||
getUserStats,
|
||||
getUserStreak,
|
||||
} from './leaderboards';
|
||||
@@ -0,0 +1,75 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface LeaderboardEntry {
|
||||
rank: number;
|
||||
username: string;
|
||||
score: number;
|
||||
chainLength: number;
|
||||
timeSeconds: number;
|
||||
hintsUsed: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface LeaderboardResponse {
|
||||
data: LeaderboardEntry[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
totalGamesPlayed: number;
|
||||
bestScore: number;
|
||||
averageScore: number;
|
||||
totalScore: number;
|
||||
currentStreak: number;
|
||||
longestStreak: number;
|
||||
recentScores: {
|
||||
score: number;
|
||||
chainLength: number;
|
||||
timeSeconds: number;
|
||||
date: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface StreakInfo {
|
||||
currentStreak: number;
|
||||
longestStreak: number;
|
||||
}
|
||||
|
||||
export async function getLeaderboard(
|
||||
period: 'daily' | 'weekly' | 'all-time' = 'daily',
|
||||
page = 1,
|
||||
limit = 20,
|
||||
): Promise<LeaderboardResponse> {
|
||||
const { data } = await apiClient.get<LeaderboardResponse>('/leaderboards', {
|
||||
params: { period, page, limit },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function submitScore(
|
||||
score: number,
|
||||
chainLength: number,
|
||||
timeSeconds: number,
|
||||
hintsUsed: number,
|
||||
) {
|
||||
const { data } = await apiClient.post('/leaderboards', {
|
||||
score,
|
||||
chainLength,
|
||||
timeSeconds,
|
||||
hintsUsed,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getUserStats(): Promise<UserStats> {
|
||||
const { data } = await apiClient.get<UserStats>('/leaderboards/me');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getUserStreak(): Promise<StreakInfo> {
|
||||
const { data } = await apiClient.get<StreakInfo>('/leaderboards/me/streak');
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import apiClient from './client';
|
||||
import type {
|
||||
TmdbSearchMovieResponse,
|
||||
TmdbMovieDetailsResponse,
|
||||
TmdbMovieCreditsResponse,
|
||||
} from '@/types';
|
||||
|
||||
export async function searchMovies(
|
||||
q: string,
|
||||
page = 1,
|
||||
): Promise<TmdbSearchMovieResponse> {
|
||||
const { data } = await apiClient.get<TmdbSearchMovieResponse>(
|
||||
'/movies/search',
|
||||
{ params: { q, page } },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getMovieDetails(
|
||||
id: number,
|
||||
): Promise<TmdbMovieDetailsResponse> {
|
||||
const { data } = await apiClient.get<TmdbMovieDetailsResponse>(
|
||||
`/movies/${id}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function discoverMovies(
|
||||
page = 1,
|
||||
): Promise<TmdbSearchMovieResponse> {
|
||||
const { data } = await apiClient.get<TmdbSearchMovieResponse>(
|
||||
'/movies/discover',
|
||||
{ params: { page } },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getMovieCredits(
|
||||
id: number,
|
||||
): Promise<TmdbMovieCreditsResponse> {
|
||||
const { data } = await apiClient.get<TmdbMovieCreditsResponse>(
|
||||
`/movies/${id}/credits`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
data: Record<string, unknown> | null;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface NotificationsResponse {
|
||||
data: Notification[];
|
||||
total: number;
|
||||
unreadCount: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export async function getNotifications(page = 1, limit = 20): Promise<NotificationsResponse> {
|
||||
const { data } = await apiClient.get<NotificationsResponse>('/notifications', {
|
||||
params: { page, limit },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getUnreadCount(): Promise<number> {
|
||||
const { data } = await apiClient.get<{ count: number }>('/notifications/unread-count');
|
||||
return data.count;
|
||||
}
|
||||
|
||||
export async function markNotificationRead(id: string): Promise<void> {
|
||||
await apiClient.patch(`/notifications/${id}/read`);
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead(): Promise<void> {
|
||||
await apiClient.post('/notifications/read-all');
|
||||
}
|
||||
|
||||
export async function deleteNotification(id: string): Promise<void> {
|
||||
await apiClient.delete(`/notifications/${id}`);
|
||||
}
|
||||
|
||||
export async function deleteAllReadNotifications(): Promise<void> {
|
||||
await apiClient.delete('/notifications/read');
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import apiClient from './client';
|
||||
import type {
|
||||
TmdbSearchPersonResponse,
|
||||
TmdbPersonDetailsResponse,
|
||||
TmdbPersonMovieCreditsResponse,
|
||||
} from '@/types';
|
||||
|
||||
export async function searchPersons(
|
||||
q: string,
|
||||
page = 1,
|
||||
): Promise<TmdbSearchPersonResponse> {
|
||||
const { data } = await apiClient.get<TmdbSearchPersonResponse>(
|
||||
'/persons/search',
|
||||
{ params: { q, page } },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getPersonDetails(
|
||||
id: number,
|
||||
): Promise<TmdbPersonDetailsResponse> {
|
||||
const { data } = await apiClient.get<TmdbPersonDetailsResponse>(
|
||||
`/persons/${id}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getPersonMovieCredits(
|
||||
id: number,
|
||||
): Promise<TmdbPersonMovieCreditsResponse> {
|
||||
const { data } = await apiClient.get<TmdbPersonMovieCreditsResponse>(
|
||||
`/persons/${id}/movie-credits`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export async function updateProfile(data: {
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
}) {
|
||||
const { data: result } = await apiClient.patch('/users/me', data);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface VersusMatch {
|
||||
id: string;
|
||||
movieAId: number;
|
||||
movieATitle?: string;
|
||||
movieBId: number;
|
||||
movieBTitle?: string;
|
||||
difficulty: string;
|
||||
status: string;
|
||||
lobbyName: string | null;
|
||||
hasPassword?: boolean;
|
||||
winnerId: string | null;
|
||||
player1: { id: string; username: string };
|
||||
player2: { id: string; username: string } | null;
|
||||
player1Score: object | null;
|
||||
player2Score: object | null;
|
||||
createdAt: string;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
isAsync?: boolean;
|
||||
chainLength?: number | null;
|
||||
expiresAt?: string | null;
|
||||
attemptCount?: number;
|
||||
}
|
||||
|
||||
export async function createMatch(
|
||||
difficulty = 'medium',
|
||||
lobbyName?: string,
|
||||
password?: string,
|
||||
isAsync?: boolean,
|
||||
): Promise<VersusMatch> {
|
||||
const { data } = await apiClient.post<VersusMatch>('/versus', {
|
||||
difficulty,
|
||||
lobbyName,
|
||||
password,
|
||||
isAsync,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getWaitingMatches(
|
||||
excludeUser?: string,
|
||||
mode?: string,
|
||||
): Promise<VersusMatch[]> {
|
||||
const params: Record<string, string> = {};
|
||||
if (excludeUser) params.excludeUser = excludeUser;
|
||||
if (mode) params.mode = mode;
|
||||
const { data } = await apiClient.get<VersusMatch[]>('/versus/waiting', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function joinMatch(matchId: string, password?: string): Promise<VersusMatch> {
|
||||
const { data } = await apiClient.post<VersusMatch>(`/versus/${matchId}/join`, {
|
||||
password,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function cancelMatch(matchId: string): Promise<void> {
|
||||
await apiClient.post(`/versus/${matchId}/cancel`);
|
||||
}
|
||||
|
||||
export async function getMyWaitingMatches(): Promise<VersusMatch[]> {
|
||||
const { data } = await apiClient.get<VersusMatch[]>('/versus/me/waiting');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getMatch(matchId: string): Promise<VersusMatch> {
|
||||
const { data } = await apiClient.get<VersusMatch>(`/versus/${matchId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getUserMatchHistory(page = 1, limit = 20) {
|
||||
const { data } = await apiClient.get('/versus/me/history', {
|
||||
params: { page, limit },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function submitCreatorScore(
|
||||
matchId: string,
|
||||
score: object,
|
||||
chainLength: number,
|
||||
): Promise<VersusMatch> {
|
||||
const { data } = await apiClient.post<VersusMatch>(`/versus/${matchId}/creator-score`, {
|
||||
score,
|
||||
chainLength,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface AsyncAttemptResponse {
|
||||
attempt: { id: string; matchId: string; playerId: string };
|
||||
movieAId: number;
|
||||
movieATitle: string;
|
||||
movieBId: number;
|
||||
movieBTitle: string;
|
||||
chainLength: number | null;
|
||||
}
|
||||
|
||||
export async function startAsyncAttempt(
|
||||
matchId: string,
|
||||
password?: string,
|
||||
): Promise<AsyncAttemptResponse> {
|
||||
const { data } = await apiClient.post<AsyncAttemptResponse>(
|
||||
`/versus/${matchId}/async-attempt`,
|
||||
{ password },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function submitAsyncAttempt(
|
||||
matchId: string,
|
||||
score: object,
|
||||
chainLength: number,
|
||||
) {
|
||||
const { data } = await apiClient.post(`/versus/${matchId}/async-attempt/submit`, {
|
||||
score,
|
||||
chainLength,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface LeaderboardEntry {
|
||||
playerId: string;
|
||||
username: string;
|
||||
score: { totalScore: number; [key: string]: unknown } | null;
|
||||
chainLength: number | null;
|
||||
isCreator: boolean;
|
||||
rank: number;
|
||||
}
|
||||
|
||||
export interface AsyncLeaderboardResponse {
|
||||
match: {
|
||||
id: string;
|
||||
difficulty: string;
|
||||
lobbyName: string | null;
|
||||
expiresAt: string | null;
|
||||
status: string;
|
||||
movieATitle: string;
|
||||
movieBTitle: string;
|
||||
};
|
||||
creator: { id: string; username: string };
|
||||
leaderboard: LeaderboardEntry[];
|
||||
}
|
||||
|
||||
export async function getAsyncLeaderboard(matchId: string): Promise<AsyncLeaderboardResponse> {
|
||||
const { data } = await apiClient.get<AsyncLeaderboardResponse>(
|
||||
`/versus/${matchId}/leaderboard`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { profileUrl } from '@/lib/tmdb';
|
||||
import type { ActorChainLink } from '@/types';
|
||||
|
||||
interface ActorCardProps {
|
||||
actor: ActorChainLink;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export default function ActorCard({ actor, compact }: ActorCardProps) {
|
||||
const imgSrc = profileUrl(actor.profilePath, 'w185');
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<img
|
||||
src={imgSrc || '/placeholder-person.svg'}
|
||||
alt={actor.name}
|
||||
className="h-10 w-10 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<p className="truncate text-sm font-medium">{actor.name}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-border p-2">
|
||||
<img
|
||||
src={imgSrc || '/placeholder-person.svg'}
|
||||
alt={actor.name}
|
||||
className="h-12 w-12 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<p className="truncate text-sm font-medium">{actor.name}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const CONFETTI_COLORS = [
|
||||
'#f44336', '#e91e63', '#9c27b0', '#673ab7',
|
||||
'#3f51b5', '#2196f3', '#03a9f4', '#00bcd4',
|
||||
'#4caf50', '#8bc34a', '#ffeb3b', '#ff9800',
|
||||
];
|
||||
|
||||
const CONFETTI_COUNT = 20;
|
||||
|
||||
export default function CelebrationOverlay() {
|
||||
const [visible, setVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setVisible(false), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-0 z-50 overflow-hidden">
|
||||
{Array.from({ length: CONFETTI_COUNT }).map((_, i) => {
|
||||
const color =
|
||||
CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)];
|
||||
const left = Math.random() * 100;
|
||||
const delay = Math.random() * 1;
|
||||
const duration = 2 + Math.random() * 1.5;
|
||||
const size = 6 + Math.random() * 6;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute animate-confetti"
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
top: '-10px',
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
backgroundColor: color,
|
||||
borderRadius: Math.random() > 0.5 ? '50%' : '2px',
|
||||
animationDelay: `${delay}s`,
|
||||
animationDuration: `${duration}s`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import MovieCard from './MovieCard';
|
||||
import ActorCard from './ActorCard';
|
||||
import SerpentineConnector from './SerpentineConnector';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import type { ActorChainLink, MovieChainLink } from '@/types';
|
||||
|
||||
interface ChainPair {
|
||||
actor: ActorChainLink;
|
||||
movie?: MovieChainLink;
|
||||
}
|
||||
|
||||
export default function ChainDisplay() {
|
||||
const chain = useGameStore((s) => s.chain);
|
||||
const movieA = useGameStore((s) => s.movieA);
|
||||
const movieB = useGameStore((s) => s.movieB);
|
||||
const status = useGameStore((s) => s.status);
|
||||
const isValidating = useGameStore((s) => s.isValidating);
|
||||
|
||||
// chain[0] = MovieA (standalone starting point)
|
||||
// chain[1..2] = pair 0 (Actor, Movie)
|
||||
// chain[3..4] = pair 1 (Actor, Movie)
|
||||
// If chain length is even, last pair is incomplete (actor only, awaiting movie)
|
||||
const pairs: ChainPair[] = [];
|
||||
for (let i = 1; i < chain.length; i += 2) {
|
||||
const actor = chain[i] as ActorChainLink;
|
||||
const movie = chain[i + 1] as MovieChainLink | undefined;
|
||||
pairs.push({ actor, movie });
|
||||
}
|
||||
|
||||
const startMovie = chain[0] as MovieChainLink | undefined;
|
||||
|
||||
// Geometry for w-[85%] offset cards with L-shaped connectors:
|
||||
// Odd pairs (0,2,4) are right-aligned → card spans 15%-100%
|
||||
// Even pairs (1,3,5) are left-aligned → card spans 0%-85%
|
||||
//
|
||||
// Vertical line at 7.5% (left) or 92.5% (right) runs through the gap
|
||||
// AND continues into the card row down to its vertical center, where
|
||||
// a horizontal arm connects to the card edge (15% or 85%).
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Starting movie (Movie A) — full width */}
|
||||
{startMovie && (
|
||||
<MovieCard movie={startMovie} isTarget="A" />
|
||||
)}
|
||||
|
||||
{/* Chain pairs */}
|
||||
{pairs.map((pair, pairIndex) => {
|
||||
const isOdd = pairIndex % 2 === 0; // 0-indexed: pair 0,2,4 are "odd" layout
|
||||
const isLastPair = pairIndex === pairs.length - 1;
|
||||
const isComplete = !!pair.movie;
|
||||
|
||||
// Determine movie target markers
|
||||
let movieTarget: 'A' | 'B' | undefined;
|
||||
if (pair.movie) {
|
||||
if (pair.movie.id === movieB?.id) movieTarget = 'B';
|
||||
else if (
|
||||
pair.movie.id === movieA?.id &&
|
||||
isLastPair &&
|
||||
status === 'completed'
|
||||
)
|
||||
movieTarget = 'A';
|
||||
}
|
||||
|
||||
// Connector geometry — L-shaped, aligned verticals
|
||||
const lineX = isOdd ? 7.5 : 92.5;
|
||||
const cardEdge = isOdd ? 15 : 85;
|
||||
// For the horizontal arm: left% and right% in CSS
|
||||
const armLeft = Math.min(lineX, cardEdge);
|
||||
const armRight = 100 - Math.max(lineX, cardEdge);
|
||||
|
||||
return (
|
||||
<div key={pairIndex}>
|
||||
{/* Vertical line through the gap */}
|
||||
<SerpentineConnector lineX={lineX} />
|
||||
|
||||
{/* Pair row — 85% width, alternating alignment */}
|
||||
<div className={cn('relative flex', isOdd ? 'justify-end' : 'justify-start')}>
|
||||
{/* Vertical continuation from top of row to vertical center */}
|
||||
<div
|
||||
className="absolute top-0 h-1/2 w-0 border-l-2 border-muted-foreground/30"
|
||||
style={{ left: `${lineX}%` }}
|
||||
/>
|
||||
{/* Horizontal arm at vertical center to card edge */}
|
||||
<div
|
||||
className="absolute top-1/2 h-0 border-t-2 border-muted-foreground/30"
|
||||
style={{ left: `${armLeft}%`, right: `${armRight}%` }}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-[85%] items-center rounded-lg border p-2.5',
|
||||
movieTarget === 'B' && 'border-green-500 bg-green-500/5',
|
||||
movieTarget === 'A' && 'border-primary bg-primary/5',
|
||||
!movieTarget && 'border-border bg-card',
|
||||
isLastPair && !isComplete && 'animate-slide-in',
|
||||
isLastPair && isComplete && status !== 'completed' && 'animate-slide-in',
|
||||
isLastPair && status === 'completed' && 'animate-glow-pulse',
|
||||
)}
|
||||
>
|
||||
{isOdd ? (
|
||||
<>
|
||||
{/* Odd pairs: [Actor | dot | Movie] */}
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<ActorCard actor={pair.actor} compact />
|
||||
</div>
|
||||
<Dot muted={!isComplete} />
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
{pair.movie ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MovieCard movie={pair.movie} compact />
|
||||
{movieTarget && <TargetBadge target={movieTarget} />}
|
||||
</div>
|
||||
) : (
|
||||
<PendingSlot loading={isValidating && status === 'playing'} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Even pairs: [Movie | dot | Actor] (reversed) */}
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
{pair.movie ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{movieTarget && <TargetBadge target={movieTarget} />}
|
||||
<MovieCard movie={pair.movie} compact />
|
||||
</div>
|
||||
) : (
|
||||
<PendingSlot loading={isValidating && status === 'playing'} />
|
||||
)}
|
||||
</div>
|
||||
<Dot muted={!isComplete} />
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<ActorCard actor={pair.actor} compact />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Validating spinner when searching for an actor (no incomplete pair yet) */}
|
||||
{isValidating && status === 'playing' && chain.length > 0 && chain.length % 2 === 1 && (
|
||||
<div>
|
||||
<SerpentineConnector
|
||||
lineX={pairs.length % 2 === 0 ? 7.5 : 92.5}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex',
|
||||
pairs.length % 2 === 0 ? 'justify-end' : 'justify-start',
|
||||
)}
|
||||
>
|
||||
{/* Vertical continuation + horizontal arm for validating row */}
|
||||
<div
|
||||
className="absolute top-0 h-1/2 w-0 border-l-2 border-muted-foreground/30"
|
||||
style={{ left: `${pairs.length % 2 === 0 ? 7.5 : 92.5}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-1/2 h-0 border-t-2 border-muted-foreground/30"
|
||||
style={{
|
||||
left: `${pairs.length % 2 === 0 ? 7.5 : 85}%`,
|
||||
right: `${pairs.length % 2 === 0 ? 85 : 7.5}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="flex w-[85%] items-center gap-3 rounded-lg border border-dashed border-border p-2.5">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Validating...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'completed' && (
|
||||
<div className="flex justify-center py-3">
|
||||
<div className="rounded-full bg-green-500/10 px-4 py-1.5 text-sm font-medium text-green-600">
|
||||
Loop Complete!
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dot({ muted }: { muted?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mx-2 h-1.5 w-1.5 shrink-0 rounded-full',
|
||||
muted ? 'bg-muted-foreground/20' : 'bg-muted-foreground/40',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingSlot({ loading }: { loading?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<div className="h-10 w-10 shrink-0 rounded border border-dashed border-muted-foreground/30" />
|
||||
)}
|
||||
<span className="text-xs">
|
||||
{loading ? 'Validating...' : '?'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetBadge({ target }: { target: 'A' | 'B' }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold',
|
||||
target === 'A'
|
||||
? 'bg-blue-500/10 text-blue-600'
|
||||
: 'bg-green-500/10 text-green-600',
|
||||
)}
|
||||
>
|
||||
{target}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { submitScore } from '@/api/leaderboards';
|
||||
import ScoreDisplay from './ScoreDisplay';
|
||||
import ShareableResult from './ShareableResult';
|
||||
import CelebrationOverlay from './CelebrationOverlay';
|
||||
import { playSound } from '@/lib/sounds';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
function formatElapsed(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function GameCompletionModal() {
|
||||
const status = useGameStore((s) => s.status);
|
||||
const score = useGameStore((s) => s.score);
|
||||
const chain = useGameStore((s) => s.chain);
|
||||
const movieA = useGameStore((s) => s.movieA);
|
||||
const movieB = useGameStore((s) => s.movieB);
|
||||
const hintsUsed = useGameStore((s) => s.hintsUsed);
|
||||
const resetGame = useGameStore((s) => s.resetGame);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const navigate = useNavigate();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'completed') {
|
||||
setShowModal(true);
|
||||
playSound('completion');
|
||||
|
||||
// Auto-submit score if logged in
|
||||
if (user && score) {
|
||||
submitScore(
|
||||
score.totalScore,
|
||||
score.chainLength,
|
||||
score.elapsedSeconds,
|
||||
hintsUsed,
|
||||
).catch(() => {
|
||||
// Silent fail — score is still shown locally
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setShowModal(false);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const handlePlayAgain = () => {
|
||||
resetGame();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const handleViewChain = () => {
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{status === 'completed' && <CelebrationOverlay />}
|
||||
<Dialog open={showModal} onOpenChange={(open) => !open && setShowModal(false)}>
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center text-2xl">
|
||||
Congratulations!
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-center">
|
||||
You completed the movie loop!
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
{score && (
|
||||
<>
|
||||
<ScoreDisplay score={score} />
|
||||
<div className="mt-3 flex justify-center gap-4 text-sm text-muted-foreground">
|
||||
<span>Completed in {formatElapsed(score.elapsedSeconds)}</span>
|
||||
{hintsUsed > 0 && (
|
||||
<span>{hintsUsed} hint{hintsUsed > 1 ? 's' : ''} used</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{score && movieA && movieB && (
|
||||
<ShareableResult
|
||||
score={score}
|
||||
chain={chain}
|
||||
movieATitle={movieA.title}
|
||||
movieBTitle={movieB.title}
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={handleViewChain}>
|
||||
View Chain
|
||||
</Button>
|
||||
<Button onClick={handlePlayAgain} size="lg">
|
||||
Play Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import { getLinkCount } from '@/types';
|
||||
import MovieCard from './MovieCard';
|
||||
import Timer from './Timer';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { CheckCircle2, Circle } from 'lucide-react';
|
||||
|
||||
export default function GameHeader() {
|
||||
const movieA = useGameStore((s) => s.movieA);
|
||||
const movieB = useGameStore((s) => s.movieB);
|
||||
const chain = useGameStore((s) => s.chain);
|
||||
const passedMovieB = useGameStore((s) => s.passedMovieB);
|
||||
|
||||
if (!movieA || !movieB) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<MovieCard movie={movieA} isTarget="A" />
|
||||
<MovieCard movie={movieB} isTarget="B" />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge variant="outline">Chain: {getLinkCount(chain)} links</Badge>
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
{passedMovieB ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
passedMovieB ? 'text-green-600' : 'text-muted-foreground'
|
||||
}
|
||||
>
|
||||
Passed Movie B
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<Timer />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useHints } from '@/hooks/use-hints';
|
||||
import { useChainValidation } from '@/hooks/use-chain-validation';
|
||||
import { profileUrl } from '@/lib/tmdb';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Lightbulb, Loader2 } from 'lucide-react';
|
||||
import type { TmdbPersonResult } from '@/types';
|
||||
|
||||
export default function HintButton() {
|
||||
const { hints, isLoading, getHint, clearHints, canHint, hintsUsed, maxHints } =
|
||||
useHints();
|
||||
const { validateAndAddActor } = useChainValidation();
|
||||
|
||||
const handleSelectHint = (member: { id: number; name: string; profile_path: string | null; popularity: number; known_for_department: string }) => {
|
||||
clearHints();
|
||||
// Convert cast member to TmdbPersonResult shape for validation
|
||||
const person: TmdbPersonResult = {
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
profile_path: member.profile_path,
|
||||
popularity: member.popularity,
|
||||
known_for_department: member.known_for_department,
|
||||
known_for: [],
|
||||
adult: false,
|
||||
};
|
||||
validateAndAddActor(person);
|
||||
};
|
||||
|
||||
const remaining = maxHints - hintsUsed;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={getHint}
|
||||
disabled={!canHint}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Lightbulb className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
Hint ({remaining} left)
|
||||
<span className="ml-1 text-xs text-red-500">-150 pts</span>
|
||||
</Button>
|
||||
|
||||
{hints.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hints.map((member) => (
|
||||
<button
|
||||
key={member.id}
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-2 py-1.5 text-left transition-colors hover:bg-accent"
|
||||
onClick={() => handleSelectHint(member)}
|
||||
>
|
||||
<img
|
||||
src={
|
||||
profileUrl(member.profile_path, 'w45') ||
|
||||
'/placeholder-person.svg'
|
||||
}
|
||||
alt={member.name}
|
||||
className="h-8 w-8 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<span className="text-xs font-medium">{member.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { posterUrl, releaseYear } from '@/lib/tmdb';
|
||||
import type { MovieChainLink } from '@/types';
|
||||
|
||||
interface MovieCardProps {
|
||||
movie: MovieChainLink;
|
||||
highlight?: boolean;
|
||||
isTarget?: 'A' | 'B';
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export default function MovieCard({
|
||||
movie,
|
||||
highlight,
|
||||
isTarget,
|
||||
compact,
|
||||
}: MovieCardProps) {
|
||||
const imgSrc = posterUrl(movie.posterPath, 'w154');
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<img
|
||||
src={imgSrc || '/placeholder-movie.svg'}
|
||||
alt={movie.title}
|
||||
className="h-12 w-8 shrink-0 rounded object-cover"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{movie.title}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{releaseYear(movie.releaseDate)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg border p-2 transition-colors',
|
||||
highlight && 'border-primary bg-primary/5',
|
||||
isTarget === 'A' && 'border-blue-500 bg-blue-500/5',
|
||||
isTarget === 'B' && 'border-green-500 bg-green-500/5',
|
||||
!highlight && !isTarget && 'border-border',
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={imgSrc || '/placeholder-movie.svg'}
|
||||
alt={movie.title}
|
||||
className="h-16 w-11 shrink-0 rounded object-cover"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{movie.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{releaseYear(movie.releaseDate)}
|
||||
</p>
|
||||
{isTarget && (
|
||||
<span
|
||||
className={cn(
|
||||
'mt-1 inline-block rounded-full px-2 py-0.5 text-[10px] font-semibold',
|
||||
isTarget === 'A'
|
||||
? 'bg-blue-500/10 text-blue-600'
|
||||
: 'bg-green-500/10 text-green-600',
|
||||
)}
|
||||
>
|
||||
Movie {isTarget}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ScoreBreakdown } from '@/types';
|
||||
|
||||
interface ScoreDisplayProps {
|
||||
score: ScoreBreakdown;
|
||||
}
|
||||
|
||||
export default function ScoreDisplay({ score }: ScoreDisplayProps) {
|
||||
return (
|
||||
<div className="space-y-3 text-center">
|
||||
<p className="text-4xl font-bold">{score.totalScore}</p>
|
||||
<p className="text-sm text-muted-foreground">points</p>
|
||||
|
||||
<div className="mx-auto max-w-xs space-y-1.5 text-sm">
|
||||
<ScoreLine label="Base score" value={score.baseScore} />
|
||||
<ScoreLine
|
||||
label={`Chain length (${score.linkCount} link${score.linkCount !== 1 ? 's' : ''})`}
|
||||
value={score.chainLengthBonus}
|
||||
bonus
|
||||
/>
|
||||
<ScoreLine label="Time bonus" value={score.timeBonus} bonus />
|
||||
<ScoreLine
|
||||
label="Obscurity bonus"
|
||||
value={score.obscurityBonus}
|
||||
bonus
|
||||
/>
|
||||
{score.hintPenalty > 0 && (
|
||||
<ScoreLine
|
||||
label="Hint penalty"
|
||||
value={-score.hintPenalty}
|
||||
penalty
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreLine({
|
||||
label,
|
||||
value,
|
||||
bonus,
|
||||
penalty,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
bonus?: boolean;
|
||||
penalty?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium',
|
||||
bonus && value > 0 && 'text-green-600',
|
||||
penalty && 'text-red-500',
|
||||
)}
|
||||
>
|
||||
{value > 0 && bonus ? '+' : ''}
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useDebounce } from '@/hooks/use-debounce';
|
||||
import { searchMovies } from '@/api/movies';
|
||||
import { searchPersons } from '@/api/persons';
|
||||
import { posterUrl, profileUrl, releaseYear } from '@/lib/tmdb';
|
||||
import type {
|
||||
SearchMode,
|
||||
TmdbMovieResult,
|
||||
TmdbPersonResult,
|
||||
} from '@/types';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface SearchAutocompleteProps {
|
||||
mode: SearchMode;
|
||||
onSelectActor: (person: TmdbPersonResult) => void;
|
||||
onSelectMovie: (movie: TmdbMovieResult) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export default function SearchAutocomplete({
|
||||
mode,
|
||||
onSelectActor,
|
||||
onSelectMovie,
|
||||
disabled,
|
||||
placeholder,
|
||||
}: SearchAutocompleteProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
const [movieResults, setMovieResults] = useState<TmdbMovieResult[]>([]);
|
||||
const [personResults, setPersonResults] = useState<TmdbPersonResult[]>([]);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listId = `search-listbox-${mode}`;
|
||||
|
||||
const results = mode === 'actor' ? personResults : movieResults;
|
||||
|
||||
useEffect(() => {
|
||||
if (!debouncedQuery || debouncedQuery.length < 2) {
|
||||
setMovieResults([]);
|
||||
setPersonResults([]);
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
setSearchError(null);
|
||||
|
||||
const doSearch = async () => {
|
||||
try {
|
||||
if (mode === 'actor') {
|
||||
const data = await searchPersons(debouncedQuery);
|
||||
if (!cancelled) {
|
||||
setPersonResults(
|
||||
data.results.filter(
|
||||
(p) => p.known_for_department === 'Acting',
|
||||
),
|
||||
);
|
||||
setMovieResults([]);
|
||||
setIsOpen(true);
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
} else {
|
||||
const data = await searchMovies(debouncedQuery);
|
||||
if (!cancelled) {
|
||||
setMovieResults(data.results);
|
||||
setPersonResults([]);
|
||||
setIsOpen(true);
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setSearchError('Search failed. Please try again.');
|
||||
setIsOpen(true);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
doSearch();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [debouncedQuery, mode]);
|
||||
|
||||
// Reset when mode changes
|
||||
useEffect(() => {
|
||||
setQuery('');
|
||||
setMovieResults([]);
|
||||
setPersonResults([]);
|
||||
setIsOpen(false);
|
||||
setActiveIndex(-1);
|
||||
// Auto-focus on mode change
|
||||
inputRef.current?.focus();
|
||||
}, [mode]);
|
||||
|
||||
// Close dropdown on click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSelectPerson = useCallback((person: TmdbPersonResult) => {
|
||||
setQuery('');
|
||||
setIsOpen(false);
|
||||
setPersonResults([]);
|
||||
setActiveIndex(-1);
|
||||
onSelectActor(person);
|
||||
}, [onSelectActor]);
|
||||
|
||||
const handleSelectMovie = useCallback((movie: TmdbMovieResult) => {
|
||||
setQuery('');
|
||||
setIsOpen(false);
|
||||
setMovieResults([]);
|
||||
setActiveIndex(-1);
|
||||
onSelectMovie(movie);
|
||||
}, [onSelectMovie]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isOpen || results.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) =>
|
||||
prev < results.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : results.length - 1,
|
||||
);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && activeIndex < results.length) {
|
||||
if (mode === 'actor') {
|
||||
handleSelectPerson(personResults[activeIndex]);
|
||||
} else {
|
||||
handleSelectMovie(movieResults[activeIndex]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
setActiveIndex(-1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative" role="combobox" aria-expanded={isOpen} aria-haspopup="listbox" aria-owns={listId}>
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder || (mode === 'actor' ? 'Search for an actor...' : 'Search for a movie...')}
|
||||
disabled={disabled}
|
||||
onFocus={() => {
|
||||
if (results.length > 0) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}}
|
||||
role="searchbox"
|
||||
aria-autocomplete="list"
|
||||
aria-controls={listId}
|
||||
aria-activedescendant={
|
||||
activeIndex >= 0 ? `${listId}-option-${activeIndex}` : undefined
|
||||
}
|
||||
aria-label={mode === 'actor' ? 'Search for an actor' : 'Search for a movie'}
|
||||
/>
|
||||
{isLoading && (
|
||||
<Loader2 className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground" aria-label="Searching..." />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<ul
|
||||
id={listId}
|
||||
role="listbox"
|
||||
aria-label={mode === 'actor' ? 'Actor results' : 'Movie results'}
|
||||
className="absolute z-50 mt-1 max-h-[min(16rem,50vh)] w-full overflow-auto rounded-md border border-border bg-popover shadow-md"
|
||||
>
|
||||
{mode === 'actor' &&
|
||||
personResults.map((person, idx) => (
|
||||
<li
|
||||
key={person.id}
|
||||
id={`${listId}-option-${idx}`}
|
||||
role="option"
|
||||
aria-selected={idx === activeIndex}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-accent ${idx === activeIndex ? 'bg-accent' : ''}`}
|
||||
onClick={() => handleSelectPerson(person)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<img
|
||||
src={profileUrl(person.profile_path, 'w45') || '/placeholder-person.svg'}
|
||||
alt=""
|
||||
className="h-10 w-10 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{person.name}</p>
|
||||
{person.known_for.length > 0 && (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
Known for:{' '}
|
||||
{person.known_for
|
||||
.slice(0, 2)
|
||||
.map((m) => m.title)
|
||||
.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
{mode === 'movie' &&
|
||||
movieResults.map((movie, idx) => (
|
||||
<li
|
||||
key={movie.id}
|
||||
id={`${listId}-option-${idx}`}
|
||||
role="option"
|
||||
aria-selected={idx === activeIndex}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-accent ${idx === activeIndex ? 'bg-accent' : ''}`}
|
||||
onClick={() => handleSelectMovie(movie)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<img
|
||||
src={posterUrl(movie.poster_path, 'w92') || '/placeholder-movie.svg'}
|
||||
alt=""
|
||||
className="h-14 w-10 shrink-0 rounded object-cover"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{movie.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{releaseYear(movie.release_date)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
{searchError && (
|
||||
<li role="option" aria-selected={false}>
|
||||
<p className="px-3 py-4 text-center text-sm text-destructive">
|
||||
{searchError}
|
||||
</p>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
!searchError &&
|
||||
results.length === 0 && (
|
||||
<li role="option" aria-selected={false}>
|
||||
<p className="px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
No results found
|
||||
</p>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
interface SerpentineConnectorProps {
|
||||
/** X% where the vertical line runs */
|
||||
lineX: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders only the vertical portion of the connector in the gap between rows.
|
||||
* The horizontal arm is rendered by the card row itself so it can align
|
||||
* with the card's vertical center.
|
||||
*/
|
||||
export default function SerpentineConnector({ lineX }: SerpentineConnectorProps) {
|
||||
return (
|
||||
<div className="relative h-8">
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0 border-l-2 border-muted-foreground/30"
|
||||
style={{ left: `${lineX}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { ScoreBreakdown, ChainLink } from '@/types';
|
||||
import { getLinkCount } from '@/types';
|
||||
import { Share2, Check, Copy } from 'lucide-react';
|
||||
|
||||
interface ShareableResultProps {
|
||||
score: ScoreBreakdown;
|
||||
chain: ChainLink[];
|
||||
movieATitle: string;
|
||||
movieBTitle: string;
|
||||
}
|
||||
|
||||
function generateShareText(props: ShareableResultProps): string {
|
||||
const { score, chain, movieATitle, movieBTitle } = props;
|
||||
const movieCount = chain.filter((l) => l.type === 'movie').length;
|
||||
const actorCount = chain.filter((l) => l.type === 'actor').length;
|
||||
const minutes = Math.floor(score.elapsedSeconds / 60);
|
||||
const seconds = score.elapsedSeconds % 60;
|
||||
|
||||
const lines = [
|
||||
'You Know Who Else Was In That Movie?',
|
||||
'',
|
||||
`${movieATitle} <-> ${movieBTitle}`,
|
||||
`Score: ${score.totalScore.toLocaleString()}`,
|
||||
`Chain: ${getLinkCount(chain)} links (${actorCount} actors, ${movieCount} movies)`,
|
||||
`Time: ${minutes}:${seconds.toString().padStart(2, '0')}`,
|
||||
'',
|
||||
// Visual chain representation
|
||||
chain
|
||||
.map((link) => (link.type === 'movie' ? '\u{1F3AC}' : '\u{1F9D1}'))
|
||||
.join(' \u2192 '),
|
||||
'',
|
||||
'Play at youknowwhoelse.com',
|
||||
];
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export default function ShareableResult(props: ShareableResultProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const text = generateShareText(props);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard API not available
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
const text = generateShareText(props);
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({ text });
|
||||
} catch {
|
||||
// User cancelled share
|
||||
}
|
||||
} else {
|
||||
handleCopy();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleCopy}>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="mr-1.5 h-3.5 w-3.5" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="mr-1.5 h-3.5 w-3.5" />
|
||||
Copy Result
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{typeof navigator.share === 'function' && (
|
||||
<Button variant="outline" size="sm" onClick={handleShare}>
|
||||
<Share2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Share
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useState } from 'react';
|
||||
import { isSoundEnabled, toggleSound } from '@/lib/sounds';
|
||||
import { Volume2, VolumeX } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function SoundToggle() {
|
||||
const [enabled, setEnabled] = useState(isSoundEnabled);
|
||||
|
||||
const handleToggle = () => {
|
||||
const next = toggleSound();
|
||||
setEnabled(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleToggle}
|
||||
className="h-8 w-8"
|
||||
aria-label={enabled ? 'Mute sounds' : 'Enable sounds'}
|
||||
>
|
||||
{enabled ? (
|
||||
<Volume2 className="h-4 w-4" />
|
||||
) : (
|
||||
<VolumeX className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import { useTimer, type BonusTier } from '@/hooks/use-timer';
|
||||
import { Timer as TimerIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const tierColors: Record<BonusTier, string> = {
|
||||
high: 'text-green-600',
|
||||
medium: 'text-yellow-600',
|
||||
low: 'text-orange-500',
|
||||
none: 'text-red-500',
|
||||
};
|
||||
|
||||
const tierLabels: Record<BonusTier, string> = {
|
||||
high: '+500',
|
||||
medium: '+300',
|
||||
low: '+100',
|
||||
none: '+0',
|
||||
};
|
||||
|
||||
export default function Timer() {
|
||||
const startTime = useGameStore((s) => s.startTime);
|
||||
const status = useGameStore((s) => s.status);
|
||||
const { formattedTime, bonusTier } = useTimer(
|
||||
startTime,
|
||||
status === 'playing',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1.5 text-sm', tierColors[bonusTier])}>
|
||||
<TimerIcon className="h-4 w-4" />
|
||||
<span className="font-mono font-medium">{formattedTime}</span>
|
||||
<span className="text-xs opacity-75">({tierLabels[bonusTier]})</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import { Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function ValidationFeedback() {
|
||||
const isValidating = useGameStore((s) => s.isValidating);
|
||||
const validationError = useGameStore((s) => s.validationError);
|
||||
const chain = useGameStore((s) => s.chain);
|
||||
const [showCheck, setShowCheck] = useState(false);
|
||||
|
||||
// Flash a green check when chain grows (successful validation)
|
||||
useEffect(() => {
|
||||
if (chain.length > 1) {
|
||||
setShowCheck(true);
|
||||
const timer = setTimeout(() => setShowCheck(false), 600);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [chain.length]);
|
||||
|
||||
if (isValidating) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Validating connection...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (validationError) {
|
||||
return (
|
||||
<div
|
||||
key={validationError}
|
||||
className="flex animate-shake items-center gap-2 text-sm text-destructive"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{validationError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showCheck) {
|
||||
return (
|
||||
<div className="flex animate-check-flash items-center gap-2 text-sm text-green-600">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Connected!
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNotificationStore } from '@/stores/notification-store';
|
||||
import { Bell, Check, CheckCheck, Trash2, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function timeAgo(dateStr: string) {
|
||||
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
||||
if (seconds < 60) return 'just now';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
export default function NotificationBell() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const notifications = useNotificationStore((s) => s.notifications);
|
||||
const unreadCount = useNotificationStore((s) => s.unreadCount);
|
||||
const fetchNotifications = useNotificationStore((s) => s.fetchNotifications);
|
||||
const markRead = useNotificationStore((s) => s.markRead);
|
||||
const markAllRead = useNotificationStore((s) => s.markAllRead);
|
||||
const dismiss = useNotificationStore((s) => s.dismiss);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!open) {
|
||||
fetchNotifications();
|
||||
}
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
const handleNotificationClick = (notification: (typeof notifications)[0]) => {
|
||||
if (!notification.read) {
|
||||
markRead(notification.id);
|
||||
}
|
||||
// Navigate to leaderboard if it's a versus attempt notification
|
||||
if (notification.data?.matchId) {
|
||||
navigate(`/versus/async/${notification.data.matchId}/leaderboard`);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className="relative flex items-center justify-center rounded-md p-1.5 text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-lg border border-border bg-card shadow-lg">
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||
<span className="text-sm font-semibold">Notifications</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllRead}
|
||||
className="rounded p-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
title="Mark all read"
|
||||
>
|
||||
<CheckCheck className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{notifications.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
No notifications
|
||||
</p>
|
||||
) : (
|
||||
notifications.map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
onClick={() => handleNotificationClick(n)}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-start gap-2 border-b border-border px-3 py-2.5 transition-colors last:border-0 hover:bg-muted/50',
|
||||
!n.read && 'bg-primary/5',
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!n.read && (
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
)}
|
||||
<p className="truncate text-sm font-medium">{n.title}</p>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">
|
||||
{n.message}
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
{timeAgo(n.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{!n.read && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
markRead(n.id);
|
||||
}}
|
||||
className="rounded p-1 text-muted-foreground hover:text-foreground"
|
||||
title="Mark as read"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
dismiss(n.id);
|
||||
}}
|
||||
className="rounded p-1 text-muted-foreground hover:text-destructive"
|
||||
title="Dismiss"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Link, useLocation } from 'react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import SoundToggle from '@/components/game/SoundToggle';
|
||||
import ThemeToggle from '@/components/layout/ThemeToggle';
|
||||
import NotificationBell from '@/components/layout/NotificationBell';
|
||||
import { User } from 'lucide-react';
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ path: '/', label: 'Home' },
|
||||
{ path: '/daily', label: 'Daily' },
|
||||
{ path: '/versus', label: 'Versus' },
|
||||
{ path: '/leaderboard', label: 'Ranks' },
|
||||
] as const;
|
||||
|
||||
export default function PageLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-gradient-to-b from-background to-background/95">
|
||||
<header className="border-b border-border bg-card/80 backdrop-blur-sm">
|
||||
<div className="mx-auto flex h-14 max-w-4xl items-center justify-between px-4">
|
||||
<Link to="/" className="text-lg font-bold tracking-tight text-primary">
|
||||
Movie Loop
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<nav className="flex gap-4" aria-label="Main navigation">
|
||||
{NAV_LINKS.map(({ path, label }) => (
|
||||
<Link
|
||||
key={path}
|
||||
to={path}
|
||||
className={cn(
|
||||
'text-sm font-medium transition-colors hover:text-foreground',
|
||||
location.pathname === path
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<SoundToggle />
|
||||
<ThemeToggle />
|
||||
{user && <NotificationBell />}
|
||||
{user ? (
|
||||
<Link
|
||||
to="/profile"
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-sm font-medium transition-colors hover:text-foreground',
|
||||
location.pathname === '/profile'
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{user.username}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto w-full max-w-4xl flex-1 px-4 py-6" role="main">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
|
||||
function getInitialTheme(): 'dark' | 'light' {
|
||||
const stored = localStorage.getItem('theme');
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>(getInitialTheme);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn("absolute top-2 right-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-base leading-none font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Difficulty = 'easy' | 'medium' | 'hard';
|
||||
|
||||
const DIFFICULTY_STYLES: Record<Difficulty, string> = {
|
||||
easy: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
medium: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
hard: 'bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300',
|
||||
};
|
||||
|
||||
const DIFFICULTY_BUTTON_ACTIVE: Record<Difficulty, string> = {
|
||||
easy: 'border-emerald-500 bg-emerald-100 text-emerald-800 dark:border-emerald-500 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
medium: 'border-amber-500 bg-amber-100 text-amber-800 dark:border-amber-500 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
hard: 'border-rose-500 bg-rose-100 text-rose-800 dark:border-rose-500 dark:bg-rose-900/40 dark:text-rose-300',
|
||||
};
|
||||
|
||||
export function getDifficultyButtonClass(difficulty: Difficulty, isActive: boolean): string {
|
||||
if (!isActive) return '';
|
||||
return DIFFICULTY_BUTTON_ACTIVE[difficulty];
|
||||
}
|
||||
|
||||
export function DifficultyBadge({
|
||||
difficulty,
|
||||
className,
|
||||
}: {
|
||||
difficulty: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const level = (difficulty as Difficulty) in DIFFICULTY_STYLES
|
||||
? (difficulty as Difficulty)
|
||||
: 'medium';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold capitalize',
|
||||
DIFFICULTY_STYLES[level],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{difficulty}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client"
|
||||
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Root.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useNavigate } from 'react-router';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Trophy } from 'lucide-react';
|
||||
import type { ScoreBreakdown } from '@/types';
|
||||
|
||||
interface AsyncCompletionModalProps {
|
||||
open: boolean;
|
||||
matchId: string;
|
||||
mode: 'creator' | 'challenger';
|
||||
myScore: ScoreBreakdown | null;
|
||||
creatorScore?: { totalScore: number } | null;
|
||||
creatorChainLength?: number | null;
|
||||
myChainLength: number;
|
||||
rank?: number;
|
||||
}
|
||||
|
||||
export default function AsyncCompletionModal({
|
||||
open,
|
||||
matchId,
|
||||
mode,
|
||||
myScore,
|
||||
creatorScore,
|
||||
creatorChainLength,
|
||||
myChainLength,
|
||||
rank,
|
||||
}: AsyncCompletionModalProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!open || !myScore) return null;
|
||||
|
||||
const myTotal = myScore.totalScore ?? 0;
|
||||
|
||||
if (mode === 'creator') {
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogContent className="sm:max-w-md" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5 text-yellow-500" />
|
||||
Score Submitted!
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold">{myTotal}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Your score · {myChainLength} links
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Your match is now open for challengers!
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => navigate('/versus')}
|
||||
>
|
||||
Back to Versus
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => navigate(`/versus/async/${matchId}/leaderboard`)}
|
||||
>
|
||||
View Leaderboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Challenger mode
|
||||
const creatorTotal = creatorScore?.totalScore ?? 0;
|
||||
const won = myTotal > creatorTotal;
|
||||
const tied = myTotal === creatorTotal;
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogContent className="sm:max-w-md" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5 text-yellow-500" />
|
||||
Challenge Complete!
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="text-center">
|
||||
{tied ? (
|
||||
<p className="text-xl font-bold text-muted-foreground">It's a tie!</p>
|
||||
) : won ? (
|
||||
<p className="text-xl font-bold text-green-600">You beat the creator!</p>
|
||||
) : (
|
||||
<p className="text-xl font-bold text-red-500">Creator wins!</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-around rounded-lg border border-border p-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">You</p>
|
||||
<p className="text-2xl font-bold">{myTotal}</p>
|
||||
<p className="text-xs text-muted-foreground">{myChainLength} links</p>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-muted-foreground">vs</span>
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">Creator</p>
|
||||
<p className="text-2xl font-bold">{creatorTotal}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{creatorChainLength ?? '?'} links
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rank && (
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Your rank: <span className="font-semibold">#{rank}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => navigate('/versus')}
|
||||
>
|
||||
Back to Versus
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => navigate(`/versus/async/${matchId}/leaderboard`)}
|
||||
>
|
||||
Full Leaderboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useVersusStore } from '@/stores/versus-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { rawToLinkCount } from '@/types';
|
||||
import { User, CheckCircle } from 'lucide-react';
|
||||
|
||||
export default function OpponentProgress() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const player1 = useVersusStore((s) => s.player1);
|
||||
const player2 = useVersusStore((s) => s.player2);
|
||||
const opponentChainLength = useVersusStore((s) => s.opponentChainLength);
|
||||
const opponentFinished = useVersusStore((s) => s.opponentFinished);
|
||||
|
||||
const opponent = user?.id === player1?.id ? player2 : player1;
|
||||
if (!opponent) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{opponent.username}</span>
|
||||
</div>
|
||||
{opponentFinished ? (
|
||||
<span className="flex items-center gap-1 text-xs font-medium text-green-600">
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
Finished!
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Chain: {rawToLinkCount(opponentChainLength)} links
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{opponentFinished && (
|
||||
<p className="mt-1 text-xs text-amber-600">
|
||||
Your opponent has finished — hurry up!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useVersusStore } from '@/stores/versus-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Trophy, Loader2 } from 'lucide-react';
|
||||
|
||||
export default function VersusCompletionModal() {
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const lobbyState = useVersusStore((s) => s.lobbyState);
|
||||
const matchResult = useVersusStore((s) => s.matchResult);
|
||||
const myScore = useVersusStore((s) => s.myScore);
|
||||
const reset = useVersusStore((s) => s.reset);
|
||||
|
||||
const iAmDone = !!myScore;
|
||||
const isOpen = iAmDone;
|
||||
if (!isOpen) return null;
|
||||
|
||||
const waitingForOpponent = iAmDone && lobbyState !== 'finished';
|
||||
const myTotal = (myScore as any)?.totalScore ?? 0;
|
||||
|
||||
const handleBackToVersus = () => {
|
||||
reset();
|
||||
navigate('/versus');
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
<DialogContent className="sm:max-w-md" showCloseButton={false}>
|
||||
{waitingForOpponent ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>You finished!</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<p className="text-3xl font-bold">{myTotal}</p>
|
||||
<p className="text-sm text-muted-foreground">Your score</p>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Waiting for opponent to finish...</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : matchResult ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5 text-yellow-500" />
|
||||
Match Complete!
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<MatchResultDisplay matchResult={matchResult} userId={user?.id} />
|
||||
<Button className="w-full" onClick={handleBackToVersus}>
|
||||
Back to Versus
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchResultDisplay({
|
||||
matchResult,
|
||||
userId,
|
||||
}: {
|
||||
matchResult: NonNullable<ReturnType<typeof useVersusStore.getState>['matchResult']>;
|
||||
userId?: string;
|
||||
}) {
|
||||
const p1Score = (matchResult.player1Score as any)?.totalScore ?? 0;
|
||||
const p2Score = (matchResult.player2Score as any)?.totalScore ?? 0;
|
||||
const isWinner = matchResult.winnerId === userId;
|
||||
const isTie = matchResult.winnerId === null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
{isTie ? (
|
||||
<p className="text-xl font-bold text-muted-foreground">It's a tie!</p>
|
||||
) : isWinner ? (
|
||||
<p className="text-xl font-bold text-green-600">You win!</p>
|
||||
) : (
|
||||
<p className="text-xl font-bold text-red-500">You lose!</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-around rounded-lg border border-border p-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">{matchResult.player1.username}</p>
|
||||
<p className="text-2xl font-bold">{p1Score}</p>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-muted-foreground">vs</span>
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">{matchResult.player2.username}</p>
|
||||
<p className="text-2xl font-bold">{p2Score}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import { getMovieCredits } from '@/api/movies';
|
||||
import { getPersonMovieCredits } from '@/api/persons';
|
||||
import { playSound } from '@/lib/sounds';
|
||||
import type {
|
||||
ActorChainLink,
|
||||
MovieChainLink,
|
||||
TmdbPersonResult,
|
||||
TmdbMovieResult,
|
||||
} from '@/types';
|
||||
|
||||
export function useChainValidation() {
|
||||
const {
|
||||
chain,
|
||||
addActorToChain,
|
||||
addMovieToChain,
|
||||
setValidating,
|
||||
setValidationError,
|
||||
} = useGameStore();
|
||||
|
||||
const validateAndAddActor = useCallback(
|
||||
async (person: TmdbPersonResult) => {
|
||||
const lastLink = chain[chain.length - 1];
|
||||
if (lastLink.type !== 'movie') {
|
||||
setValidationError('Expected a movie as the last chain link.');
|
||||
playSound('invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if actor is already in the chain
|
||||
if (chain.some((link) => link.type === 'actor' && link.id === person.id)) {
|
||||
setValidationError(`${person.name} is already in your chain.`);
|
||||
playSound('invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
setValidating(true);
|
||||
setValidationError(null);
|
||||
|
||||
try {
|
||||
const credits = await getMovieCredits(lastLink.id);
|
||||
const isInCast = credits.cast.some(
|
||||
(member) => member.id === person.id,
|
||||
);
|
||||
|
||||
if (!isInCast) {
|
||||
setValidationError(
|
||||
`${person.name} was not in "${lastLink.title}".`,
|
||||
);
|
||||
playSound('invalid');
|
||||
setValidating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const actorLink: ActorChainLink = {
|
||||
type: 'actor',
|
||||
id: person.id,
|
||||
name: person.name,
|
||||
profilePath: person.profile_path,
|
||||
popularity: person.popularity,
|
||||
};
|
||||
|
||||
addActorToChain(actorLink);
|
||||
playSound('valid');
|
||||
} catch (error: unknown) {
|
||||
const isNetwork =
|
||||
error instanceof Error && error.message === 'Network Error';
|
||||
setValidationError(
|
||||
isNetwork
|
||||
? 'Network error — check your connection and try again.'
|
||||
: 'Failed to validate. Please try again.',
|
||||
);
|
||||
playSound('invalid');
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
},
|
||||
[chain, addActorToChain, setValidating, setValidationError],
|
||||
);
|
||||
|
||||
const validateAndAddMovie = useCallback(
|
||||
async (movie: TmdbMovieResult) => {
|
||||
const lastLink = chain[chain.length - 1];
|
||||
if (lastLink.type !== 'actor') {
|
||||
setValidationError('Expected an actor as the last chain link.');
|
||||
playSound('invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if movie is already in the chain (except movieA for loop closure)
|
||||
const movieA = useGameStore.getState().movieA;
|
||||
const movieB = useGameStore.getState().movieB;
|
||||
const passedMovieB = useGameStore.getState().passedMovieB;
|
||||
|
||||
// Allow movieA only if we've passed movieB (closing the loop)
|
||||
const isMovieA = movie.id === movieA?.id;
|
||||
const isMovieB = movie.id === movieB?.id;
|
||||
|
||||
if (
|
||||
!isMovieA &&
|
||||
!isMovieB &&
|
||||
chain.some((link) => link.type === 'movie' && link.id === movie.id)
|
||||
) {
|
||||
setValidationError(`"${movie.title}" is already in your chain.`);
|
||||
playSound('invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMovieA && !passedMovieB) {
|
||||
setValidationError(
|
||||
`You need to pass through "${movieB?.title}" before returning to "${movieA?.title}".`,
|
||||
);
|
||||
playSound('invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
setValidating(true);
|
||||
setValidationError(null);
|
||||
|
||||
try {
|
||||
const credits = await getPersonMovieCredits(lastLink.id);
|
||||
const isInFilmography = credits.cast.some(
|
||||
(credit) => credit.id === movie.id,
|
||||
);
|
||||
|
||||
if (!isInFilmography) {
|
||||
setValidationError(
|
||||
`${lastLink.name} was not in "${movie.title}".`,
|
||||
);
|
||||
playSound('invalid');
|
||||
setValidating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const movieLink: MovieChainLink = {
|
||||
type: 'movie',
|
||||
id: movie.id,
|
||||
title: movie.title,
|
||||
posterPath: movie.poster_path,
|
||||
releaseDate: movie.release_date,
|
||||
popularity: movie.popularity,
|
||||
};
|
||||
|
||||
addMovieToChain(movieLink);
|
||||
playSound('valid');
|
||||
} catch (error: unknown) {
|
||||
const isNetwork =
|
||||
error instanceof Error && error.message === 'Network Error';
|
||||
setValidationError(
|
||||
isNetwork
|
||||
? 'Network error — check your connection and try again.'
|
||||
: 'Failed to validate. Please try again.',
|
||||
);
|
||||
playSound('invalid');
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
},
|
||||
[chain, addMovieToChain, setValidating, setValidationError],
|
||||
);
|
||||
|
||||
return { validateAndAddActor, validateAndAddMovie };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useDebounce<T>(value: T, delayMs = 300): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delayMs]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import { getMovieCredits } from '@/api/movies';
|
||||
import type { TmdbCastMember } from '@/types';
|
||||
|
||||
const MAX_HINTS = 3;
|
||||
const HINTS_PER_REQUEST = 3;
|
||||
|
||||
export function useHints() {
|
||||
const [hints, setHints] = useState<TmdbCastMember[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const chain = useGameStore((s) => s.chain);
|
||||
const currentSearchMode = useGameStore((s) => s.currentSearchMode);
|
||||
const hintsUsed = useGameStore((s) => s.hintsUsed);
|
||||
const incrementHintsUsed = useGameStore((s) => s.incrementHintsUsed);
|
||||
const status = useGameStore((s) => s.status);
|
||||
|
||||
// Can only hint when looking for an actor after a movie
|
||||
const lastLink = chain[chain.length - 1];
|
||||
const canHint =
|
||||
status === 'playing' &&
|
||||
currentSearchMode === 'actor' &&
|
||||
lastLink?.type === 'movie' &&
|
||||
hintsUsed < MAX_HINTS &&
|
||||
!isLoading;
|
||||
|
||||
const getHint = useCallback(async () => {
|
||||
if (!canHint || lastLink?.type !== 'movie') return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const credits = await getMovieCredits(lastLink.id);
|
||||
|
||||
// Filter out actors already in the chain
|
||||
const chainActorIds = new Set(
|
||||
chain.filter((l) => l.type === 'actor').map((l) => l.id),
|
||||
);
|
||||
const available = credits.cast.filter(
|
||||
(member) => !chainActorIds.has(member.id),
|
||||
);
|
||||
|
||||
// Pick random actors from available cast
|
||||
const shuffled = [...available].sort(() => Math.random() - 0.5);
|
||||
setHints(shuffled.slice(0, HINTS_PER_REQUEST));
|
||||
incrementHintsUsed();
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [canHint, lastLink, chain, incrementHintsUsed]);
|
||||
|
||||
const clearHints = useCallback(() => setHints([]), []);
|
||||
|
||||
return {
|
||||
hints,
|
||||
isLoading,
|
||||
getHint,
|
||||
clearHints,
|
||||
canHint,
|
||||
hintsUsed,
|
||||
maxHints: MAX_HINTS,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export type BonusTier = 'high' | 'medium' | 'low' | 'none';
|
||||
|
||||
function getBonusTier(seconds: number): BonusTier {
|
||||
if (seconds < 60) return 'high';
|
||||
if (seconds < 180) return 'medium';
|
||||
if (seconds < 300) return 'low';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function useTimer(startTime: number | null, isRunning: boolean) {
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!startTime || !isRunning) return;
|
||||
|
||||
// Set initial value
|
||||
setElapsedSeconds(Math.floor((Date.now() - startTime) / 1000));
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setElapsedSeconds(Math.floor((Date.now() - startTime) / 1000));
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [startTime, isRunning]);
|
||||
|
||||
return {
|
||||
elapsedSeconds,
|
||||
formattedTime: formatTime(elapsedSeconds),
|
||||
bonusTier: getBonusTier(elapsedSeconds),
|
||||
};
|
||||
}
|
||||
+108
-57
@@ -5,73 +5,75 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* Light theme — soft warm gray with indigo accent */
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--background: oklch(0.975 0.005 260);
|
||||
--foreground: oklch(0.175 0.015 260);
|
||||
--card: oklch(0.995 0.002 260);
|
||||
--card-foreground: oklch(0.175 0.015 260);
|
||||
--popover: oklch(0.995 0.002 260);
|
||||
--popover-foreground: oklch(0.175 0.015 260);
|
||||
--primary: oklch(0.51 0.17 265);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.945 0.01 260);
|
||||
--secondary-foreground: oklch(0.25 0.02 260);
|
||||
--muted: oklch(0.945 0.01 260);
|
||||
--muted-foreground: oklch(0.52 0.015 260);
|
||||
--accent: oklch(0.935 0.015 260);
|
||||
--accent-foreground: oklch(0.25 0.02 260);
|
||||
--destructive: oklch(0.58 0.22 27);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--border: oklch(0.905 0.01 260);
|
||||
--input: oklch(0.905 0.01 260);
|
||||
--ring: oklch(0.51 0.17 265);
|
||||
--chart-1: oklch(0.65 0.18 265);
|
||||
--chart-2: oklch(0.60 0.16 340);
|
||||
--chart-3: oklch(0.70 0.14 150);
|
||||
--chart-4: oklch(0.65 0.15 45);
|
||||
--chart-5: oklch(0.55 0.20 265);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar: oklch(0.97 0.005 260);
|
||||
--sidebar-foreground: oklch(0.175 0.015 260);
|
||||
--sidebar-primary: oklch(0.51 0.17 265);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--sidebar-accent: oklch(0.94 0.01 260);
|
||||
--sidebar-accent-foreground: oklch(0.25 0.02 260);
|
||||
--sidebar-border: oklch(0.905 0.01 260);
|
||||
--sidebar-ring: oklch(0.51 0.17 265);
|
||||
}
|
||||
|
||||
/* Dark theme — deep slate with warm amber accent */
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.87 0.00 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.371 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--background: oklch(0.155 0.015 265);
|
||||
--foreground: oklch(0.935 0.01 260);
|
||||
--card: oklch(0.195 0.015 265);
|
||||
--card-foreground: oklch(0.935 0.01 260);
|
||||
--popover: oklch(0.195 0.015 265);
|
||||
--popover-foreground: oklch(0.935 0.01 260);
|
||||
--primary: oklch(0.72 0.15 75);
|
||||
--primary-foreground: oklch(0.16 0.02 265);
|
||||
--secondary: oklch(0.24 0.015 265);
|
||||
--secondary-foreground: oklch(0.90 0.01 260);
|
||||
--muted: oklch(0.24 0.015 265);
|
||||
--muted-foreground: oklch(0.62 0.01 260);
|
||||
--accent: oklch(0.28 0.015 265);
|
||||
--accent-foreground: oklch(0.93 0.01 260);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--input: oklch(1 0 0 / 12%);
|
||||
--ring: oklch(0.72 0.15 75);
|
||||
--chart-1: oklch(0.72 0.15 75);
|
||||
--chart-2: oklch(0.65 0.16 340);
|
||||
--chart-3: oklch(0.70 0.14 150);
|
||||
--chart-4: oklch(0.60 0.18 265);
|
||||
--chart-5: oklch(0.55 0.15 45);
|
||||
--sidebar: oklch(0.185 0.015 265);
|
||||
--sidebar-foreground: oklch(0.935 0.01 260);
|
||||
--sidebar-primary: oklch(0.72 0.15 75);
|
||||
--sidebar-primary-foreground: oklch(0.16 0.02 265);
|
||||
--sidebar-accent: oklch(0.26 0.015 265);
|
||||
--sidebar-accent-foreground: oklch(0.93 0.01 260);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--sidebar-ring: oklch(0.72 0.15 75);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -116,12 +118,61 @@
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--animate-confetti: confetti-fall 2.5s ease-in forwards;
|
||||
--animate-slide-in: slide-in-from-bottom 0.3s ease-out;
|
||||
--animate-shake: shake 0.4s ease-in-out;
|
||||
--animate-check-flash: check-flash 0.6s ease-out;
|
||||
--animate-glow-pulse: glow-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes confetti-fall {
|
||||
0% {
|
||||
transform: translateY(0) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(100vh) rotate(720deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-from-bottom {
|
||||
0% {
|
||||
transform: translateY(12px);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-6px); }
|
||||
40% { transform: translateX(6px); }
|
||||
60% { transform: translateX(-4px); }
|
||||
80% { transform: translateX(4px); }
|
||||
}
|
||||
|
||||
@keyframes check-flash {
|
||||
0% { opacity: 0; transform: scale(0.5); }
|
||||
50% { opacity: 1; transform: scale(1.2); }
|
||||
100% { opacity: 0; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% { box-shadow: 0 0 4px oklch(0.72 0.15 75 / 0.3); }
|
||||
50% { box-shadow: 0 0 16px oklch(0.72 0.15 75 / 0.5); }
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground antialiased;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import type { PresetMoviePair } from '@/types';
|
||||
|
||||
export const GENRES = [
|
||||
'Action',
|
||||
'Drama',
|
||||
'Sci-Fi',
|
||||
'Comedy',
|
||||
'Thriller',
|
||||
'Crime',
|
||||
'Fantasy',
|
||||
'Animation',
|
||||
] as const;
|
||||
|
||||
export const DECADES = ['1970s', '1980s', '1990s', '2000s', '2010s', '2020s'] as const;
|
||||
|
||||
interface TaggedPair extends PresetMoviePair {
|
||||
genres?: string[];
|
||||
decade?: string;
|
||||
}
|
||||
|
||||
const PRESET_PAIRS: TaggedPair[] = [
|
||||
{
|
||||
movieA: { id: 27205, title: 'Inception' },
|
||||
movieB: { id: 155, title: 'The Dark Knight' },
|
||||
description: 'Christopher Nolan classics',
|
||||
genres: ['Action', 'Sci-Fi', 'Thriller'],
|
||||
decade: '2000s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 680, title: 'Pulp Fiction' },
|
||||
movieB: { id: 600, title: 'Full Metal Jacket' },
|
||||
description: 'Iconic 90s/80s cinema',
|
||||
genres: ['Crime', 'Drama'],
|
||||
decade: '1990s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 120, title: 'The Lord of the Rings: The Fellowship of the Ring' },
|
||||
movieB: { id: 22, title: 'Pirates of the Caribbean: The Curse of the Black Pearl' },
|
||||
description: 'Epic fantasy adventures',
|
||||
genres: ['Fantasy', 'Action'],
|
||||
decade: '2000s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 550, title: 'Fight Club' },
|
||||
movieB: { id: 807, title: 'Se7en' },
|
||||
description: 'David Fincher thrillers',
|
||||
genres: ['Thriller', 'Drama'],
|
||||
decade: '1990s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 13, title: 'Forrest Gump' },
|
||||
movieB: { id: 862, title: 'Toy Story' },
|
||||
description: 'Tom Hanks classics',
|
||||
genres: ['Drama', 'Comedy', 'Animation'],
|
||||
decade: '1990s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 11, title: 'Star Wars' },
|
||||
movieB: { id: 85, title: 'Raiders of the Lost Ark' },
|
||||
description: 'Harrison Ford adventures',
|
||||
genres: ['Sci-Fi', 'Action'],
|
||||
decade: '1970s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 603, title: 'The Matrix' },
|
||||
movieB: { id: 24, title: 'Kill Bill: Vol. 1' },
|
||||
description: 'Action masterpieces',
|
||||
genres: ['Action', 'Sci-Fi'],
|
||||
decade: '1990s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 238, title: 'The Godfather' },
|
||||
movieB: { id: 769, title: 'GoodFellas' },
|
||||
description: 'Mob masterpieces',
|
||||
genres: ['Crime', 'Drama'],
|
||||
decade: '1970s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 278, title: 'The Shawshank Redemption' },
|
||||
movieB: { id: 510, title: 'One Flew Over the Cuckoo\'s Nest' },
|
||||
description: 'Prison dramas',
|
||||
genres: ['Drama'],
|
||||
decade: '1990s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 497, title: 'The Green Mile' },
|
||||
movieB: { id: 194, title: 'Amélie' },
|
||||
description: 'Unexpected connections',
|
||||
genres: ['Drama', 'Comedy'],
|
||||
decade: '1990s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 299536, title: 'Avengers: Infinity War' },
|
||||
movieB: { id: 264660, title: 'Ex Machina' },
|
||||
description: 'Oscar Isaac sci-fi',
|
||||
genres: ['Action', 'Sci-Fi'],
|
||||
decade: '2010s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 157336, title: 'Interstellar' },
|
||||
movieB: { id: 11324, title: 'Shutter Island' },
|
||||
description: 'Mind-bending epics',
|
||||
genres: ['Sci-Fi', 'Thriller'],
|
||||
decade: '2010s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 68718, title: 'Django Unchained' },
|
||||
movieB: { id: 16869, title: 'Inglourious Basterds' },
|
||||
description: 'Tarantino history',
|
||||
genres: ['Action', 'Drama'],
|
||||
decade: '2010s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 105, title: 'Back to the Future' },
|
||||
movieB: { id: 329, title: 'Jurassic Park' },
|
||||
description: 'Spielberg sci-fi',
|
||||
genres: ['Sci-Fi', 'Action'],
|
||||
decade: '1980s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 568, title: 'Apollo 13' },
|
||||
movieB: { id: 607, title: 'Men in Black' },
|
||||
description: '90s blockbusters',
|
||||
genres: ['Sci-Fi', 'Action', 'Drama'],
|
||||
decade: '1990s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 122, title: 'The Lord of the Rings: The Return of the King' },
|
||||
movieB: { id: 49026, title: 'The Dark Knight Rises' },
|
||||
description: 'Epic trilogy finales',
|
||||
genres: ['Fantasy', 'Action'],
|
||||
decade: '2000s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 98, title: 'Gladiator' },
|
||||
movieB: { id: 76341, title: 'Mad Max: Fury Road' },
|
||||
description: 'Arena of chaos',
|
||||
genres: ['Action', 'Drama'],
|
||||
decade: '2000s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 274, title: 'The Silence of the Lambs' },
|
||||
movieB: { id: 578, title: 'Jaws' },
|
||||
description: 'Iconic predators',
|
||||
genres: ['Thriller'],
|
||||
decade: '1990s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 245891, title: 'John Wick' },
|
||||
movieB: { id: 82992, title: 'Fast & Furious 6' },
|
||||
description: 'High-octane action',
|
||||
genres: ['Action', 'Thriller'],
|
||||
decade: '2010s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 101, title: 'Léon: The Professional' },
|
||||
movieB: { id: 153, title: 'Lost in Translation' },
|
||||
description: 'Scarlett Johansson range',
|
||||
genres: ['Drama', 'Thriller'],
|
||||
decade: '1990s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 807, title: 'Se7en' },
|
||||
movieB: { id: 745, title: 'The Sixth Sense' },
|
||||
description: 'Dark twist endings',
|
||||
genres: ['Thriller'],
|
||||
decade: '1990s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 640, title: 'Catch Me If You Can' },
|
||||
movieB: { id: 1422, title: 'The Departed' },
|
||||
description: 'Leonardo DiCaprio crime',
|
||||
genres: ['Crime', 'Drama'],
|
||||
decade: '2000s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 274870, title: 'Whiplash' },
|
||||
movieB: { id: 313369, title: 'La La Land' },
|
||||
description: 'Damien Chazelle music',
|
||||
genres: ['Drama'],
|
||||
decade: '2010s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 346, title: 'Seven Samurai' },
|
||||
movieB: { id: 620, title: 'Ghostbusters' },
|
||||
description: 'Assembling the team',
|
||||
genres: ['Action', 'Comedy'],
|
||||
decade: '1980s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 11036, title: 'The Notebook' },
|
||||
movieB: { id: 12445, title: 'Harry Potter and the Deathly Hallows: Part 2' },
|
||||
description: 'Cross-genre fan favorites',
|
||||
genres: ['Drama', 'Fantasy'],
|
||||
decade: '2000s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 530385, title: 'Midsommar' },
|
||||
movieB: { id: 419704, title: 'Ad Astra' },
|
||||
description: '2019 slow burns',
|
||||
genres: ['Thriller', 'Sci-Fi'],
|
||||
decade: '2010s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 324857, title: 'Spider-Man: Into the Spider-Verse' },
|
||||
movieB: { id: 508947, title: 'Turning Red' },
|
||||
description: 'Animated hits',
|
||||
genres: ['Animation', 'Action'],
|
||||
decade: '2010s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 121, title: 'The Lord of the Rings: The Two Towers' },
|
||||
movieB: { id: 114, title: 'Pretty Woman' },
|
||||
description: 'Wildly different vibes',
|
||||
genres: ['Fantasy', 'Comedy'],
|
||||
decade: '2000s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 429617, title: 'Spider-Man: Far From Home' },
|
||||
movieB: { id: 399566, title: 'Godzilla vs. Kong' },
|
||||
description: 'Blockbuster spectacles',
|
||||
genres: ['Action', 'Sci-Fi'],
|
||||
decade: '2010s',
|
||||
},
|
||||
];
|
||||
|
||||
export function getRandomPair(): PresetMoviePair {
|
||||
const index = Math.floor(Math.random() * PRESET_PAIRS.length);
|
||||
return PRESET_PAIRS[index];
|
||||
}
|
||||
|
||||
export function getFilteredPair(
|
||||
genre: string | null,
|
||||
decade: string | null,
|
||||
): PresetMoviePair {
|
||||
let filtered = PRESET_PAIRS;
|
||||
|
||||
if (genre) {
|
||||
filtered = filtered.filter((p) => p.genres?.includes(genre));
|
||||
}
|
||||
if (decade) {
|
||||
filtered = filtered.filter((p) => p.decade === decade);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return getRandomPair();
|
||||
}
|
||||
|
||||
const index = Math.floor(Math.random() * filtered.length);
|
||||
return filtered[index];
|
||||
}
|
||||
|
||||
export { PRESET_PAIRS };
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ChainLink, ScoreBreakdown } from '@/types';
|
||||
import { getLinkCount } from '@/types';
|
||||
|
||||
export function calculateScore(
|
||||
chain: ChainLink[],
|
||||
startTime: number,
|
||||
completedAt: number,
|
||||
hintsUsed: number,
|
||||
): ScoreBreakdown {
|
||||
const baseScore = 1000;
|
||||
const chainLength = chain.length;
|
||||
const linkCount = getLinkCount(chain);
|
||||
|
||||
// Chain length bonus: fewer links are rewarded (par = 4 links)
|
||||
const chainLengthBonus = Math.max(0, (4 - linkCount) * 200);
|
||||
|
||||
// Time bonus
|
||||
const elapsedSeconds = Math.floor((completedAt - startTime) / 1000);
|
||||
let timeBonus: number;
|
||||
if (elapsedSeconds < 60) {
|
||||
timeBonus = 500;
|
||||
} else if (elapsedSeconds < 180) {
|
||||
timeBonus = 300;
|
||||
} else if (elapsedSeconds < 300) {
|
||||
timeBonus = 100;
|
||||
} else {
|
||||
timeBonus = 0;
|
||||
}
|
||||
|
||||
// Obscurity bonus (skip the first link which is the starting movie)
|
||||
let obscurityBonus = 0;
|
||||
for (let i = 1; i < chain.length; i++) {
|
||||
const link = chain[i];
|
||||
if (link.type === 'actor') {
|
||||
if (link.popularity < 5) obscurityBonus += 250;
|
||||
else if (link.popularity < 10) obscurityBonus += 150;
|
||||
} else if (link.type === 'movie') {
|
||||
if (link.popularity < 5) obscurityBonus += 250;
|
||||
else if (link.popularity < 20) obscurityBonus += 100;
|
||||
}
|
||||
}
|
||||
|
||||
// Hint penalty
|
||||
const hintPenalty = hintsUsed * 150;
|
||||
|
||||
const totalScore =
|
||||
baseScore + chainLengthBonus + timeBonus + obscurityBonus - hintPenalty;
|
||||
|
||||
return {
|
||||
baseScore,
|
||||
chainLength,
|
||||
linkCount,
|
||||
chainLengthBonus,
|
||||
timeBonus,
|
||||
obscurityBonus,
|
||||
hintPenalty,
|
||||
elapsedSeconds,
|
||||
totalScore: Math.max(0, totalScore),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
type SoundName = 'valid' | 'invalid' | 'completion';
|
||||
|
||||
const SOUND_PATHS: Record<SoundName, string> = {
|
||||
valid: '/sounds/valid.mp3',
|
||||
invalid: '/sounds/invalid.mp3',
|
||||
completion: '/sounds/completion.mp3',
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'movieloop-sound-enabled';
|
||||
|
||||
export function isSoundEnabled(): boolean {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored === null ? true : stored === 'true';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleSound(): boolean {
|
||||
const next = !isSoundEnabled();
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, String(next));
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function playSound(name: SoundName): void {
|
||||
if (!isSoundEnabled()) return;
|
||||
|
||||
try {
|
||||
const audio = new Audio(SOUND_PATHS[name]);
|
||||
audio.volume = 0.5;
|
||||
audio.play().catch(() => {
|
||||
// Sound file missing or autoplay blocked — no-op
|
||||
});
|
||||
} catch {
|
||||
// Audio API unavailable
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const TMDB_IMAGE_BASE = 'https://image.tmdb.org/t/p';
|
||||
|
||||
type PosterSize = 'w92' | 'w154' | 'w185' | 'w342' | 'w500' | 'w780' | 'original';
|
||||
type ProfileSize = 'w45' | 'w185' | 'h632' | 'original';
|
||||
|
||||
export function posterUrl(
|
||||
path: string | null,
|
||||
size: PosterSize = 'w342',
|
||||
): string | null {
|
||||
if (!path) return null;
|
||||
return `${TMDB_IMAGE_BASE}/${size}${path}`;
|
||||
}
|
||||
|
||||
export function profileUrl(
|
||||
path: string | null,
|
||||
size: ProfileSize = 'w185',
|
||||
): string | null {
|
||||
if (!path) return null;
|
||||
return `${TMDB_IMAGE_BASE}/${size}${path}`;
|
||||
}
|
||||
|
||||
export function releaseYear(date: string | undefined | null): string {
|
||||
if (!date) return '';
|
||||
return date.substring(0, 4);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function getWsUrl(namespace: string): string {
|
||||
const apiUrl = import.meta.env.VITE_API_URL;
|
||||
if (!apiUrl) return namespace;
|
||||
const origin = apiUrl.replace(/\/api\/v1\/?$/, '');
|
||||
return `${origin}${namespace}`;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { getUserAchievements, type Achievement } from '@/api/achievements';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Trophy,
|
||||
Zap,
|
||||
Link,
|
||||
EyeOff,
|
||||
Compass,
|
||||
Flame,
|
||||
Star,
|
||||
Circle,
|
||||
Play,
|
||||
Award,
|
||||
Film,
|
||||
Swords,
|
||||
Crown,
|
||||
} from 'lucide-react';
|
||||
|
||||
const ICON_MAP: Record<string, React.ReactNode> = {
|
||||
trophy: <Trophy className="h-5 w-5" />,
|
||||
zap: <Zap className="h-5 w-5" />,
|
||||
link: <Link className="h-5 w-5" />,
|
||||
'eye-off': <EyeOff className="h-5 w-5" />,
|
||||
compass: <Compass className="h-5 w-5" />,
|
||||
flame: <Flame className="h-5 w-5" />,
|
||||
star: <Star className="h-5 w-5" />,
|
||||
circle: <Circle className="h-5 w-5" />,
|
||||
play: <Play className="h-5 w-5" />,
|
||||
award: <Award className="h-5 w-5" />,
|
||||
film: <Film className="h-5 w-5" />,
|
||||
swords: <Swords className="h-5 w-5" />,
|
||||
crown: <Crown className="h-5 w-5" />,
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
general: 'General',
|
||||
speed: 'Speed',
|
||||
chain: 'Chain',
|
||||
obscurity: 'Obscurity',
|
||||
streak: 'Streaks',
|
||||
score: 'Score',
|
||||
versus: 'Versus',
|
||||
};
|
||||
|
||||
export default function Achievements() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const navigate = useNavigate();
|
||||
const [achievements, setAchievements] = useState<Achievement[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
getUserAchievements()
|
||||
.then(setAchievements)
|
||||
.catch(() => setAchievements([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [user, navigate]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const unlocked = achievements.filter((a) => a.unlocked);
|
||||
const grouped = achievements.reduce(
|
||||
(acc, a) => {
|
||||
const cat = a.category;
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
acc[cat].push(a);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Achievement[]>,
|
||||
);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5" />
|
||||
<h1 className="text-2xl font-bold">Achievements</h1>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{unlocked.length} / {achievements.length} unlocked
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
Object.entries(grouped).map(([category, items]) => (
|
||||
<div key={category}>
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase text-muted-foreground">
|
||||
{CATEGORY_LABELS[category] ?? category}
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{items.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border p-4 transition-colors',
|
||||
a.unlocked
|
||||
? 'border-green-500/30 bg-green-500/5'
|
||||
: 'border-border bg-card opacity-60',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-10 w-10 shrink-0 items-center justify-center rounded-full',
|
||||
a.unlocked
|
||||
? 'bg-green-500/20 text-green-600'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{ICON_MAP[a.icon] ?? <Trophy className="h-5 w-5" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">{a.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{a.description}
|
||||
</p>
|
||||
{!a.unlocked && a.threshold > 1 && (
|
||||
<div className="mt-2">
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-foreground/30"
|
||||
style={{
|
||||
width: `${Math.min(100, (a.progress / a.threshold) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{a.progress} / {a.threshold}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{a.unlocked && a.unlockedAt && (
|
||||
<p className="mt-1 text-xs text-green-600">
|
||||
Unlocked{' '}
|
||||
{new Date(a.unlockedAt).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DifficultyBadge } from '@/components/ui/difficulty-badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import {
|
||||
getAsyncLeaderboard,
|
||||
type AsyncLeaderboardResponse,
|
||||
type LeaderboardEntry,
|
||||
} from '@/api/versus';
|
||||
import { Trophy, Crown, Clock, ArrowLeft } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { rawToLinkCount } from '@/types';
|
||||
|
||||
function timeRemaining(expiresAt: string | null) {
|
||||
if (!expiresAt) return null;
|
||||
const remaining = new Date(expiresAt).getTime() - Date.now();
|
||||
if (remaining <= 0) return 'Expired';
|
||||
const hours = Math.floor(remaining / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
|
||||
return `${hours}h ${minutes}m remaining`;
|
||||
}
|
||||
|
||||
export default function AsyncMatchLeaderboard() {
|
||||
const { matchId } = useParams<{ matchId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [data, setData] = useState<AsyncLeaderboardResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!matchId) return;
|
||||
setLoading(true);
|
||||
getAsyncLeaderboard(matchId)
|
||||
.then(setData)
|
||||
.catch((err) => setError(err?.response?.data?.message ?? 'Failed to load'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [matchId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-4 py-6">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex flex-col items-center gap-4 py-12">
|
||||
<p className="text-red-500">{error ?? 'Match not found'}</p>
|
||||
<Button onClick={() => navigate('/versus')}>Back to Versus</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const { match, leaderboard } = data;
|
||||
const expiry = timeRemaining(match.expiresAt);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6 py-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/versus')}>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">Async Challenge</h1>
|
||||
</div>
|
||||
|
||||
{/* Match info */}
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold">
|
||||
{match.lobbyName || `${data.creator.username}'s challenge`}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{match.movieATitle} ↔ {match.movieBTitle}
|
||||
</p>
|
||||
</div>
|
||||
<DifficultyBadge difficulty={match.difficulty} />
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span>by {data.creator.username}</span>
|
||||
{expiry && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{expiry}
|
||||
</span>
|
||||
)}
|
||||
{match.status === 'expired' && (
|
||||
<span className="rounded bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
Expired
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leaderboard */}
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className="h-4 w-4 text-yellow-500" />
|
||||
<h2 className="font-semibold">Leaderboard</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({leaderboard.length} player{leaderboard.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{leaderboard.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
No results yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{leaderboard.map((entry) => (
|
||||
<LeaderboardRow
|
||||
key={entry.playerId}
|
||||
entry={entry}
|
||||
isMe={entry.playerId === user?.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function LeaderboardRow({
|
||||
entry,
|
||||
isMe,
|
||||
}: {
|
||||
entry: LeaderboardEntry;
|
||||
isMe: boolean;
|
||||
}) {
|
||||
const totalScore = entry.score?.totalScore ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-3',
|
||||
isMe && 'bg-primary/5',
|
||||
entry.isCreator && 'bg-yellow-50/50 dark:bg-yellow-900/10',
|
||||
)}
|
||||
>
|
||||
<span className="w-8 text-center text-sm font-bold text-muted-foreground">
|
||||
#{entry.rank}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn('text-sm font-medium', isMe && 'text-primary')}>
|
||||
{entry.username}
|
||||
</span>
|
||||
{entry.isCreator && (
|
||||
<span title="Creator"><Crown className="h-3.5 w-3.5 text-yellow-500" /></span>
|
||||
)}
|
||||
{isMe && (
|
||||
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-semibold text-primary">
|
||||
YOU
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-bold">{totalScore}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{entry.chainLength != null ? rawToLinkCount(entry.chainLength) : '?'} links
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useChainValidation } from '@/hooks/use-chain-validation';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import GameHeader from '@/components/game/GameHeader';
|
||||
import ChainDisplay from '@/components/game/ChainDisplay';
|
||||
import SearchAutocomplete from '@/components/game/SearchAutocomplete';
|
||||
import ValidationFeedback from '@/components/game/ValidationFeedback';
|
||||
import HintButton from '@/components/game/HintButton';
|
||||
import AsyncCompletionModal from '@/components/versus/AsyncCompletionModal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Undo2, Target, Trophy } from 'lucide-react';
|
||||
import {
|
||||
getMatch,
|
||||
submitCreatorScore,
|
||||
startAsyncAttempt,
|
||||
submitAsyncAttempt,
|
||||
getAsyncLeaderboard,
|
||||
} from '@/api/versus';
|
||||
import { getLinkCount, rawToLinkCount } from '@/types';
|
||||
|
||||
type AsyncMode = 'creator' | 'challenger';
|
||||
|
||||
export default function AsyncVersusGame() {
|
||||
const { matchId } = useParams<{ matchId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const status = useGameStore((s) => s.status);
|
||||
const chain = useGameStore((s) => s.chain);
|
||||
const currentSearchMode = useGameStore((s) => s.currentSearchMode);
|
||||
const passedMovieB = useGameStore((s) => s.passedMovieB);
|
||||
const movieA = useGameStore((s) => s.movieA);
|
||||
const movieB = useGameStore((s) => s.movieB);
|
||||
const isValidating = useGameStore((s) => s.isValidating);
|
||||
const score = useGameStore((s) => s.score);
|
||||
const undoLastLink = useGameStore((s) => s.undoLastLink);
|
||||
const startGameFromVersus = useGameStore((s) => s.startGameFromVersus);
|
||||
const resetGame = useGameStore((s) => s.resetGame);
|
||||
const { validateAndAddActor, validateAndAddMovie } = useChainValidation();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [mode, setMode] = useState<AsyncMode>('creator');
|
||||
const [targetChainLength, setTargetChainLength] = useState<number | null>(null);
|
||||
const [showCompletion, setShowCompletion] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [creatorScore, setCreatorScore] = useState<{ totalScore: number } | null>(null);
|
||||
const [myRank, setMyRank] = useState<number | undefined>();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!matchId || !user) {
|
||||
navigate('/versus');
|
||||
return;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const match = await getMatch(matchId!);
|
||||
|
||||
if (!match.isAsync) {
|
||||
navigate('/versus');
|
||||
return;
|
||||
}
|
||||
|
||||
if (match.player1.id === user!.id) {
|
||||
// Creator mode
|
||||
if (match.status !== 'waiting') {
|
||||
// Already submitted — go to leaderboard
|
||||
navigate(`/versus/async/${matchId}/leaderboard`);
|
||||
return;
|
||||
}
|
||||
setMode('creator');
|
||||
await startGameFromVersus(
|
||||
{
|
||||
movieA: { id: match.movieAId, title: match.movieATitle! },
|
||||
movieB: { id: match.movieBId, title: match.movieBTitle! },
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} else {
|
||||
// Challenger mode
|
||||
const result = await startAsyncAttempt(matchId!);
|
||||
setMode('challenger');
|
||||
setTargetChainLength(result.chainLength);
|
||||
await startGameFromVersus(
|
||||
{
|
||||
movieA: { id: result.movieAId, title: result.movieATitle },
|
||||
movieB: { id: result.movieBId, title: result.movieBTitle },
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
setError(message ?? 'Failed to load match');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
resetGame();
|
||||
init();
|
||||
|
||||
return () => {
|
||||
resetGame();
|
||||
};
|
||||
}, [matchId, user, navigate]);
|
||||
|
||||
// Handle game completion
|
||||
useEffect(() => {
|
||||
if (status !== 'completed' || !score || !matchId || submitting || showCompletion) return;
|
||||
|
||||
setSubmitting(true);
|
||||
const chainLength = chain.length;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
if (mode === 'creator') {
|
||||
await submitCreatorScore(matchId, score, chainLength);
|
||||
} else {
|
||||
await submitAsyncAttempt(matchId, score, chainLength);
|
||||
// Fetch leaderboard to get rank and creator score
|
||||
try {
|
||||
const lb = await getAsyncLeaderboard(matchId);
|
||||
const creatorEntry = lb.leaderboard.find((e) => e.isCreator);
|
||||
if (creatorEntry?.score) {
|
||||
setCreatorScore(creatorEntry.score as { totalScore: number });
|
||||
}
|
||||
const myEntry = lb.leaderboard.find((e) => e.playerId === user?.id);
|
||||
if (myEntry) setMyRank(myEntry.rank);
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
setShowCompletion(true);
|
||||
} catch {
|
||||
setError('Failed to submit score');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
})();
|
||||
}, [status, score, matchId, mode, submitting, showCompletion]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-4 py-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex flex-col items-center gap-4 py-12">
|
||||
<p className="text-red-500">{error}</p>
|
||||
<Button onClick={() => navigate('/versus')}>Back to Versus</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'idle' || !movieA || !movieB) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastLink = chain[chain.length - 1];
|
||||
let searchLabel = '';
|
||||
let searchPlaceholder = '';
|
||||
|
||||
if (currentSearchMode === 'actor' && lastLink?.type === 'movie') {
|
||||
searchLabel = `Who was in "${lastLink.title}"?`;
|
||||
searchPlaceholder = 'Search for an actor...';
|
||||
} else if (currentSearchMode === 'movie' && lastLink?.type === 'actor') {
|
||||
searchLabel = `What movie was ${lastLink.name} also in?`;
|
||||
searchPlaceholder = 'Search for a movie...';
|
||||
}
|
||||
|
||||
const showLoopHint = passedMovieB && currentSearchMode === 'movie' && movieA;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-4">
|
||||
<GameHeader />
|
||||
|
||||
{/* Mode indicator */}
|
||||
<div className="rounded-lg border border-border bg-card p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
{mode === 'creator' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className="h-4 w-4 text-yellow-500" />
|
||||
<span className="text-sm font-medium">
|
||||
Your score will be the benchmark!
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">
|
||||
Target: {targetChainLength != null ? rawToLinkCount(targetChainLength) : '?'} links
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<ChainDisplay />
|
||||
|
||||
{status === 'playing' && (
|
||||
<div className="space-y-3">
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">{searchLabel}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<HintButton />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={undoLastLink}
|
||||
disabled={chain.length <= 1 || isValidating}
|
||||
>
|
||||
<Undo2 className="mr-1 h-3.5 w-3.5" />
|
||||
Undo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchAutocomplete
|
||||
mode={currentSearchMode}
|
||||
onSelectActor={validateAndAddActor}
|
||||
onSelectMovie={validateAndAddMovie}
|
||||
disabled={isValidating}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
|
||||
<ValidationFeedback />
|
||||
|
||||
{showLoopHint && (
|
||||
<p className="text-sm text-green-600">
|
||||
Select "{movieA.title}" to close the loop!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AsyncCompletionModal
|
||||
open={showCompletion}
|
||||
matchId={matchId!}
|
||||
mode={mode}
|
||||
myScore={score}
|
||||
creatorScore={creatorScore}
|
||||
creatorChainLength={targetChainLength != null ? rawToLinkCount(targetChainLength) : null}
|
||||
myChainLength={getLinkCount(chain)}
|
||||
rank={myRank}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DifficultyBadge } from '@/components/ui/difficulty-badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import { getTodaysChallenge, type DailyChallenge as DailyChallengeType } from '@/api/daily-challenges';
|
||||
import { Loader2, Calendar, Trophy } from 'lucide-react';
|
||||
|
||||
export default function DailyChallenge() {
|
||||
const [challenge, setChallenge] = useState<DailyChallengeType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const startGame = useGameStore((s) => s.startGame);
|
||||
const isValidating = useGameStore((s) => s.isValidating);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
getTodaysChallenge()
|
||||
.then(setChallenge)
|
||||
.catch(() => setError('Failed to load today\'s challenge.'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handlePlay = async () => {
|
||||
if (!challenge) return;
|
||||
await startGame({
|
||||
movieA: { id: challenge.movieAId, title: challenge.movieATitle },
|
||||
movieB: { id: challenge.movieBId, title: challenge.movieBTitle },
|
||||
});
|
||||
navigate('/game');
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex flex-col items-center gap-8 py-12">
|
||||
<div className="space-y-2 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Calendar className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-2xl font-bold">Daily Challenge</h1>
|
||||
</div>
|
||||
{challenge && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatDate(challenge.date)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
{challenge && (
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<DifficultyBadge difficulty={challenge.difficulty} />
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Trophy className="h-3.5 w-3.5" />
|
||||
Par: {challenge.par} links
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="rounded-md bg-muted/50 p-3 text-center">
|
||||
<p className="text-xs font-medium uppercase text-muted-foreground">
|
||||
Movie A
|
||||
</p>
|
||||
<p className="text-lg font-semibold">{challenge.movieATitle}</p>
|
||||
</div>
|
||||
<div className="flex justify-center text-muted-foreground">
|
||||
<span className="text-xl">⇵</span>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/50 p-3 text-center">
|
||||
<p className="text-xs font-medium uppercase text-muted-foreground">
|
||||
Movie B
|
||||
</p>
|
||||
<p className="text-lg font-semibold">{challenge.movieBTitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="mt-6 w-full"
|
||||
size="lg"
|
||||
onClick={handlePlay}
|
||||
disabled={isValidating}
|
||||
>
|
||||
{isValidating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Play Today\'s Challenge'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import { getFilteredPair, GENRES, DECADES } from '@/lib/movie-pairs';
|
||||
import { discoverMovies } from '@/api/movies';
|
||||
import { getDifficultyButtonClass } from '@/components/ui/difficulty-badge';
|
||||
import { Loader2, Shuffle, Filter } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Difficulty = 'easy' | 'medium' | 'hard';
|
||||
|
||||
export default function Endless() {
|
||||
const startGame = useGameStore((s) => s.startGame);
|
||||
const isValidating = useGameStore((s) => s.isValidating);
|
||||
const navigate = useNavigate();
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>('medium');
|
||||
const [genre, setGenre] = useState<string | null>(null);
|
||||
const [decade, setDecade] = useState<string | null>(null);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
const handleStart = async () => {
|
||||
let pair;
|
||||
if (genre || decade) {
|
||||
pair = getFilteredPair(genre, decade);
|
||||
} else {
|
||||
// Use TMDB discover for unfiltered random pairs
|
||||
const page = Math.floor(Math.random() * 20) + 1;
|
||||
const data = await discoverMovies(page);
|
||||
const movies = data.results.filter((m) => m.title && m.release_date);
|
||||
if (movies.length < 2) throw new Error('Not enough movies');
|
||||
const shuffled = movies.sort(() => Math.random() - 0.5);
|
||||
pair = {
|
||||
movieA: { id: shuffled[0].id, title: shuffled[0].title },
|
||||
movieB: { id: shuffled[1].id, title: shuffled[1].title },
|
||||
};
|
||||
}
|
||||
await startGame(pair);
|
||||
navigate('/game');
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex flex-col items-center gap-8 py-12">
|
||||
<div className="space-y-2 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Shuffle className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-2xl font-bold">Endless Mode</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Practice with random movie pairs. No pressure, no leaderboard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase text-muted-foreground">
|
||||
Difficulty
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
{(['easy', 'medium', 'hard'] as Difficulty[]).map((d) => (
|
||||
<Button
|
||||
key={d}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn('flex-1', getDifficultyButtonClass(d, difficulty === d))}
|
||||
onClick={() => setDifficulty(d)}
|
||||
>
|
||||
{d.charAt(0).toUpperCase() + d.slice(1)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
{showFilters ? 'Hide Filters' : 'Show Filters'}
|
||||
</button>
|
||||
|
||||
{showFilters && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">Genre</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge
|
||||
variant={genre === null ? 'default' : 'secondary'}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setGenre(null)}
|
||||
>
|
||||
Any
|
||||
</Badge>
|
||||
{GENRES.map((g) => (
|
||||
<Badge
|
||||
key={g}
|
||||
variant={genre === g ? 'default' : 'secondary'}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setGenre(g)}
|
||||
>
|
||||
{g}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">Decade</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge
|
||||
variant={decade === null ? 'default' : 'secondary'}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDecade(null)}
|
||||
>
|
||||
Any
|
||||
</Badge>
|
||||
{DECADES.map((d) => (
|
||||
<Badge
|
||||
key={d}
|
||||
variant={decade === d ? 'default' : 'secondary'}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDecade(d)}
|
||||
>
|
||||
{d}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full text-base"
|
||||
onClick={handleStart}
|
||||
disabled={isValidating}
|
||||
>
|
||||
{isValidating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Start Practice Game'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import { useChainValidation } from '@/hooks/use-chain-validation';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import GameHeader from '@/components/game/GameHeader';
|
||||
import ChainDisplay from '@/components/game/ChainDisplay';
|
||||
import SearchAutocomplete from '@/components/game/SearchAutocomplete';
|
||||
import ValidationFeedback from '@/components/game/ValidationFeedback';
|
||||
import GameCompletionModal from '@/components/game/GameCompletionModal';
|
||||
import HintButton from '@/components/game/HintButton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Undo2 } from 'lucide-react';
|
||||
|
||||
export default function Game() {
|
||||
const status = useGameStore((s) => s.status);
|
||||
const chain = useGameStore((s) => s.chain);
|
||||
const currentSearchMode = useGameStore((s) => s.currentSearchMode);
|
||||
const passedMovieB = useGameStore((s) => s.passedMovieB);
|
||||
const movieA = useGameStore((s) => s.movieA);
|
||||
const movieB = useGameStore((s) => s.movieB);
|
||||
const isValidating = useGameStore((s) => s.isValidating);
|
||||
const undoLastLink = useGameStore((s) => s.undoLastLink);
|
||||
const navigate = useNavigate();
|
||||
const { validateAndAddActor, validateAndAddMovie } = useChainValidation();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'idle') {
|
||||
navigate('/');
|
||||
}
|
||||
}, [status, navigate]);
|
||||
|
||||
if (status === 'idle' || !movieA || !movieB) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build contextual search label
|
||||
const lastLink = chain[chain.length - 1];
|
||||
let searchLabel = '';
|
||||
let searchPlaceholder = '';
|
||||
|
||||
if (currentSearchMode === 'actor' && lastLink?.type === 'movie') {
|
||||
searchLabel = `Who was in "${lastLink.title}"?`;
|
||||
searchPlaceholder = 'Search for an actor...';
|
||||
} else if (currentSearchMode === 'movie' && lastLink?.type === 'actor') {
|
||||
searchLabel = `What movie was ${lastLink.name} also in?`;
|
||||
searchPlaceholder = 'Search for a movie...';
|
||||
}
|
||||
|
||||
// Hint for closing the loop
|
||||
const showLoopHint =
|
||||
passedMovieB &&
|
||||
currentSearchMode === 'movie' &&
|
||||
movieA;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-4">
|
||||
<GameHeader />
|
||||
|
||||
<Separator />
|
||||
|
||||
<ChainDisplay />
|
||||
|
||||
{status === 'playing' && (
|
||||
<div className="space-y-3">
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">{searchLabel}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<HintButton />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={undoLastLink}
|
||||
disabled={chain.length <= 1 || isValidating}
|
||||
>
|
||||
<Undo2 className="mr-1 h-3.5 w-3.5" />
|
||||
Undo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchAutocomplete
|
||||
mode={currentSearchMode}
|
||||
onSelectActor={validateAndAddActor}
|
||||
onSelectMovie={validateAndAddMovie}
|
||||
disabled={isValidating}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
|
||||
<ValidationFeedback />
|
||||
|
||||
{showLoopHint && (
|
||||
<p className="text-sm text-green-600">
|
||||
Select "{movieA.title}" to close the loop!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<GameCompletionModal />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useNavigate, Link } from 'react-router';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import { discoverMovies } from '@/api/movies';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Loader2, Calendar, Swords, Shuffle } from 'lucide-react';
|
||||
import type { MoviePair } from '@/types';
|
||||
|
||||
/** Known franchise groups — movies in the same group won't be paired together. */
|
||||
const FRANCHISE_TAGS: Record<number, string> = {
|
||||
// MCU
|
||||
299536: 'mcu', 299534: 'mcu', 24428: 'mcu', 100402: 'mcu', 284053: 'mcu',
|
||||
118340: 'mcu', 99861: 'mcu', 68721: 'mcu', 1726: 'mcu', 10195: 'mcu',
|
||||
315635: 'mcu', 284054: 'mcu', 271110: 'mcu', 283995: 'mcu', 429617: 'mcu',
|
||||
// Star Wars
|
||||
11: 'sw', 1891: 'sw', 1892: 'sw', 1893: 'sw', 1894: 'sw', 1895: 'sw',
|
||||
140607: 'sw', 181808: 'sw', 181812: 'sw', 330459: 'sw', 348350: 'sw',
|
||||
// LotR / Hobbit
|
||||
120: 'lotr', 121: 'lotr', 122: 'lotr', 49051: 'lotr', 57158: 'lotr', 122917: 'lotr',
|
||||
// Harry Potter
|
||||
671: 'hp', 672: 'hp', 673: 'hp', 674: 'hp', 675: 'hp', 767: 'hp', 12444: 'hp', 12445: 'hp',
|
||||
// Fast & Furious
|
||||
9799: 'ff', 13804: 'ff', 51497: 'ff', 82992: 'ff', 168259: 'ff', 337339: 'ff', 385128: 'ff',
|
||||
// Godfather
|
||||
238: 'gf', 240: 'gf', 242: 'gf',
|
||||
// Dark Knight
|
||||
155: 'dk', 49026: 'dk', 272: 'dk',
|
||||
// Jurassic
|
||||
329: 'jp', 330: 'jp', 331: 'jp', 135397: 'jp', 351286: 'jp',
|
||||
// Toy Story
|
||||
862: 'ts', 863: 'ts', 10193: 'ts', 301528: 'ts',
|
||||
// Pirates
|
||||
22: 'potc', 58: 'potc', 285: 'potc', 1865: 'potc', 166426: 'potc',
|
||||
};
|
||||
|
||||
function areSameFranchise(idA: number, idB: number): boolean {
|
||||
const tagA = FRANCHISE_TAGS[idA];
|
||||
const tagB = FRANCHISE_TAGS[idB];
|
||||
return !!(tagA && tagB && tagA === tagB);
|
||||
}
|
||||
|
||||
async function getRandomTmdbPair(): Promise<MoviePair> {
|
||||
// Pick a random page from the first 20 pages of popular movies
|
||||
const page = Math.floor(Math.random() * 20) + 1;
|
||||
const data = await discoverMovies(page);
|
||||
const movies = data.results.filter((m) => m.title && m.release_date);
|
||||
|
||||
if (movies.length < 2) {
|
||||
throw new Error('Not enough movies returned from TMDB');
|
||||
}
|
||||
|
||||
// Shuffle and pick two that aren't from the same franchise
|
||||
const shuffled = movies.sort(() => Math.random() - 0.5);
|
||||
let movieA = shuffled[0];
|
||||
let movieB = shuffled[1];
|
||||
|
||||
for (let i = 2; i < shuffled.length; i++) {
|
||||
if (!areSameFranchise(movieA.id, shuffled[i].id)) {
|
||||
movieB = shuffled[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
movieA: { id: movieA.id, title: movieA.title },
|
||||
movieB: { id: movieB.id, title: movieB.title },
|
||||
};
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const startGame = useGameStore((s) => s.startGame);
|
||||
const isValidating = useGameStore((s) => s.isValidating);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleStartGame = async () => {
|
||||
const pair = await getRandomTmdbPair();
|
||||
await startGame(pair);
|
||||
navigate('/game');
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex flex-col items-center justify-center gap-8 py-12">
|
||||
<div className="space-y-3 text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-4xl">
|
||||
You Know Who Else Was In That Movie?
|
||||
</h1>
|
||||
<p className="mx-auto max-w-lg text-lg text-muted-foreground">
|
||||
Build a chain of actor-movie connections forming a complete loop
|
||||
between two movies. Can you find the path?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground">
|
||||
<h2 className="mb-3 text-base font-semibold text-foreground">
|
||||
How to Play
|
||||
</h2>
|
||||
<ol className="list-inside list-decimal space-y-1.5">
|
||||
<li>You get two movies (Movie A and Movie B)</li>
|
||||
<li>Find an actor who was in Movie A</li>
|
||||
<li>Find another movie that actor was also in</li>
|
||||
<li>Keep going through Movie B...</li>
|
||||
<li>...and back to Movie A to complete the loop!</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full max-w-lg gap-3 sm:grid-cols-2">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleStartGame}
|
||||
disabled={isValidating}
|
||||
className="text-base"
|
||||
>
|
||||
{isValidating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Quick Play'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Link
|
||||
to="/daily"
|
||||
className={cn(buttonVariants({ size: 'lg', variant: 'outline' }), 'text-base')}
|
||||
>
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
Daily Challenge
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/endless"
|
||||
className={cn(buttonVariants({ size: 'lg', variant: 'outline' }), 'text-base')}
|
||||
>
|
||||
<Shuffle className="mr-2 h-4 w-4" />
|
||||
Endless Mode
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/versus"
|
||||
className={cn(buttonVariants({ size: 'lg', variant: 'outline' }), 'text-base')}
|
||||
>
|
||||
<Swords className="mr-2 h-4 w-4" />
|
||||
Versus Mode
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import { getLeaderboard, type LeaderboardEntry, type LeaderboardResponse } from '@/api/leaderboards';
|
||||
import { Trophy, Clock, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Period = 'daily' | 'weekly' | 'all-time';
|
||||
|
||||
const PERIODS: { value: Period; label: string }[] = [
|
||||
{ value: 'daily', label: 'Today' },
|
||||
{ value: 'weekly', label: 'This Week' },
|
||||
{ value: 'all-time', label: 'All Time' },
|
||||
];
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function Leaderboard() {
|
||||
const [period, setPeriod] = useState<Period>('daily');
|
||||
const [data, setData] = useState<LeaderboardResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getLeaderboard(period, page);
|
||||
setData(result);
|
||||
} catch {
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [period, page]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handlePeriodChange = (p: Period) => {
|
||||
setPeriod(p);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5" />
|
||||
<h1 className="text-2xl font-bold">Leaderboard</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{PERIODS.map((p) => (
|
||||
<Button
|
||||
key={p.value}
|
||||
variant={period === p.value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handlePeriodChange(p.value)}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && data && data.data.length === 0 && (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
No scores yet for this period. Be the first!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && data && data.data.length > 0 && (
|
||||
<>
|
||||
<div className="rounded-lg border border-border">
|
||||
<div className="grid grid-cols-[3rem_1fr_5rem_5rem_4rem] gap-2 border-b border-border bg-muted/50 px-4 py-2 text-xs font-medium uppercase text-muted-foreground">
|
||||
<span>#</span>
|
||||
<span>Player</span>
|
||||
<span className="text-right">Score</span>
|
||||
<span className="text-right">Time</span>
|
||||
<span className="text-right">Chain</span>
|
||||
</div>
|
||||
{data.data.map((entry: LeaderboardEntry) => (
|
||||
<div
|
||||
key={`${entry.rank}-${entry.username}`}
|
||||
className={cn(
|
||||
'grid grid-cols-[3rem_1fr_5rem_5rem_4rem] gap-2 border-b border-border px-4 py-3 last:border-0',
|
||||
entry.rank <= 3 && 'bg-card',
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{entry.rank <= 3 ? (
|
||||
<Badge
|
||||
variant={entry.rank === 1 ? 'default' : 'secondary'}
|
||||
className="h-6 w-6 justify-center rounded-full p-0"
|
||||
>
|
||||
{entry.rank}
|
||||
</Badge>
|
||||
) : (
|
||||
entry.rank
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate font-medium">{entry.username}</span>
|
||||
<span className="text-right font-semibold tabular-nums">
|
||||
{entry.score.toLocaleString()}
|
||||
</span>
|
||||
<span className="flex items-center justify-end gap-1 text-sm text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTime(entry.timeSeconds)}
|
||||
</span>
|
||||
<span className="text-right text-sm text-muted-foreground">
|
||||
{entry.chainLength}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {page} of {data.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page === data.totalPages}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export default function Login() {
|
||||
const [isRegister, setIsRegister] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const register = useAuthStore((s) => s.register);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const error = useAuthStore((s) => s.error);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (isRegister) {
|
||||
await register(email, username, password);
|
||||
} else {
|
||||
await login(email, password);
|
||||
}
|
||||
navigate('/profile');
|
||||
} catch {
|
||||
// Error is set in the store
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="mx-auto max-w-sm py-20">
|
||||
<h1 className="mb-6 text-center text-2xl font-bold">
|
||||
{isRegister ? 'Create Account' : 'Sign In'}
|
||||
</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">Email</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isRegister && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">Username</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={30}
|
||||
placeholder="moviebuff42"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">Password</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
placeholder="Min 8 characters"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{isRegister ? 'Creating account...' : 'Signing in...'}
|
||||
</>
|
||||
) : isRegister ? (
|
||||
'Create Account'
|
||||
) : (
|
||||
'Sign In'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
{isRegister ? 'Already have an account?' : "Don't have an account?"}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsRegister(!isRegister)}
|
||||
className="text-foreground underline underline-offset-2"
|
||||
>
|
||||
{isRegister ? 'Sign in' : 'Create one'}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Link } from 'react-router'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
|
||||
<h1 className="text-4xl font-bold">404</h1>
|
||||
<p className="text-muted-foreground">Page not found.</p>
|
||||
<Link to="/" className="text-primary underline">
|
||||
Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { getUserStats, type UserStats } from '@/api/leaderboards';
|
||||
import { updateProfile } from '@/api/users';
|
||||
import { User, Flame, Trophy, BarChart3, LogOut, Pencil, Check, X } from 'lucide-react';
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function Profile() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const loadUser = useAuthStore((s) => s.loadUser);
|
||||
const navigate = useNavigate();
|
||||
const [stats, setStats] = useState<UserStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
getUserStats()
|
||||
.then(setStats)
|
||||
.catch(() => setStats(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [user, navigate]);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
try {
|
||||
await updateProfile({ displayName: displayName || undefined });
|
||||
await loadUser();
|
||||
setEditing(false);
|
||||
} catch {
|
||||
// Error handled by API client
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<User className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
{editing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Display name"
|
||||
className="h-8 w-40"
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={handleSaveProfile}>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditing(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-bold">{user.username}</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDisplayName('');
|
||||
setEditing(true);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Edit display name"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={handleLogout}>
|
||||
<LogOut className="mr-1.5 h-4 w-4" />
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{loading && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
icon={<BarChart3 className="h-4 w-4" />}
|
||||
label="Games Played"
|
||||
value={stats.totalGamesPlayed.toString()}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Trophy className="h-4 w-4" />}
|
||||
label="Best Score"
|
||||
value={stats.bestScore.toLocaleString()}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Flame className="h-4 w-4" />}
|
||||
label="Current Streak"
|
||||
value={`${stats.currentStreak} day${stats.currentStreak !== 1 ? 's' : ''}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Flame className="h-4 w-4" />}
|
||||
label="Longest Streak"
|
||||
value={`${stats.longestStreak} day${stats.longestStreak !== 1 ? 's' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Average Score</span>
|
||||
<span className="font-semibold">{stats.averageScore.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Total Score</span>
|
||||
<span className="font-semibold">{stats.totalScore.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/achievements"
|
||||
className={buttonVariants({ variant: 'outline' })}
|
||||
>
|
||||
<Trophy className="mr-2 h-4 w-4" />
|
||||
View Achievements
|
||||
</Link>
|
||||
|
||||
{stats.recentScores.length > 0 && (
|
||||
<div>
|
||||
<h2 className="mb-3 text-lg font-semibold">Recent Games</h2>
|
||||
<div className="rounded-lg border border-border">
|
||||
<div className="grid grid-cols-[1fr_5rem_5rem_4rem] gap-2 border-b border-border bg-muted/50 px-4 py-2 text-xs font-medium uppercase text-muted-foreground">
|
||||
<span>Date</span>
|
||||
<span className="text-right">Score</span>
|
||||
<span className="text-right">Time</span>
|
||||
<span className="text-right">Chain</span>
|
||||
</div>
|
||||
{stats.recentScores.map((entry, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid grid-cols-[1fr_5rem_5rem_4rem] gap-2 border-b border-border px-4 py-2.5 last:border-0"
|
||||
>
|
||||
<span className="text-sm">
|
||||
{new Date(entry.date).toLocaleDateString()}
|
||||
</span>
|
||||
<span className="text-right font-medium tabular-nums">
|
||||
{entry.score.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-right text-sm text-muted-foreground">
|
||||
{formatTime(entry.timeSeconds)}
|
||||
</span>
|
||||
<span className="text-right text-sm text-muted-foreground">
|
||||
{entry.chainLength}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && !stats && (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
No stats yet. Play a daily challenge to get started!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-muted-foreground">
|
||||
{icon}
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DifficultyBadge, getDifficultyButtonClass } from '@/components/ui/difficulty-badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import {
|
||||
createMatch,
|
||||
getWaitingMatches,
|
||||
getMyWaitingMatches,
|
||||
joinMatch,
|
||||
cancelMatch,
|
||||
startAsyncAttempt,
|
||||
type VersusMatch,
|
||||
} from '@/api/versus';
|
||||
import {
|
||||
Swords,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Users,
|
||||
Lock,
|
||||
X,
|
||||
ArrowRight,
|
||||
Clock,
|
||||
Zap,
|
||||
Timer,
|
||||
BarChart3,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { rawToLinkCount } from '@/types';
|
||||
|
||||
type Difficulty = 'easy' | 'medium' | 'hard';
|
||||
type FilterMode = 'all' | 'sync' | 'async';
|
||||
|
||||
function timeRemaining(expiresAt: string | null | undefined) {
|
||||
if (!expiresAt) return null;
|
||||
const remaining = new Date(expiresAt).getTime() - Date.now();
|
||||
if (remaining <= 0) return 'Expired';
|
||||
const hours = Math.floor(remaining / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
export default function Versus() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const navigate = useNavigate();
|
||||
const [matches, setMatches] = useState<VersusMatch[]>([]);
|
||||
const [myMatches, setMyMatches] = useState<VersusMatch[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [joining, setJoining] = useState<string | null>(null);
|
||||
const [cancelling, setCancelling] = useState<string | null>(null);
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>('medium');
|
||||
const [lobbyName, setLobbyName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isAsync, setIsAsync] = useState(false);
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>('all');
|
||||
|
||||
// Password prompt state
|
||||
const [passwordPromptMatch, setPasswordPromptMatch] = useState<VersusMatch | null>(null);
|
||||
const [passwordPromptAction, setPasswordPromptAction] = useState<'join' | 'challenge'>('join');
|
||||
const [joinPassword, setJoinPassword] = useState('');
|
||||
const [joinError, setJoinError] = useState('');
|
||||
|
||||
const fetchMatches = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [open, mine] = await Promise.all([
|
||||
getWaitingMatches(user?.id, filterMode === 'all' ? undefined : filterMode),
|
||||
getMyWaitingMatches(),
|
||||
]);
|
||||
setMatches(open);
|
||||
setMyMatches(mine);
|
||||
} catch {
|
||||
setMatches([]);
|
||||
setMyMatches([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (matchId: string) => {
|
||||
setCancelling(matchId);
|
||||
try {
|
||||
await cancelMatch(matchId);
|
||||
setMyMatches((prev) => prev.filter((m) => m.id !== matchId));
|
||||
} catch {
|
||||
// Error handled by API client
|
||||
} finally {
|
||||
setCancelling(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
fetchMatches();
|
||||
}, [user, navigate, filterMode]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setCreating(true);
|
||||
try {
|
||||
const match = await createMatch(
|
||||
difficulty,
|
||||
lobbyName.trim() || undefined,
|
||||
password.trim() || undefined,
|
||||
isAsync || undefined,
|
||||
);
|
||||
if (isAsync) {
|
||||
navigate(`/versus/async-game/${match.id}`);
|
||||
} else {
|
||||
navigate(`/versus/lobby/${match.id}`);
|
||||
}
|
||||
} catch {
|
||||
// Error handled by API client
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJoin = async (matchId: string, match: VersusMatch) => {
|
||||
if (match.hasPassword) {
|
||||
setPasswordPromptMatch(match);
|
||||
setPasswordPromptAction('join');
|
||||
setJoinPassword('');
|
||||
setJoinError('');
|
||||
return;
|
||||
}
|
||||
await doJoin(matchId);
|
||||
};
|
||||
|
||||
const handleChallenge = async (matchId: string, match: VersusMatch) => {
|
||||
if (match.hasPassword) {
|
||||
setPasswordPromptMatch(match);
|
||||
setPasswordPromptAction('challenge');
|
||||
setJoinPassword('');
|
||||
setJoinError('');
|
||||
return;
|
||||
}
|
||||
await doChallenge(matchId);
|
||||
};
|
||||
|
||||
const doJoin = async (matchId: string, pw?: string) => {
|
||||
setJoining(matchId);
|
||||
try {
|
||||
await joinMatch(matchId, pw);
|
||||
setPasswordPromptMatch(null);
|
||||
navigate(`/versus/lobby/${matchId}`);
|
||||
} catch (err: unknown) {
|
||||
if (pw) {
|
||||
const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
setJoinError(message ?? 'Incorrect password');
|
||||
}
|
||||
} finally {
|
||||
setJoining(null);
|
||||
}
|
||||
};
|
||||
|
||||
const doChallenge = async (matchId: string, pw?: string) => {
|
||||
setJoining(matchId);
|
||||
try {
|
||||
await startAsyncAttempt(matchId, pw);
|
||||
setPasswordPromptMatch(null);
|
||||
navigate(`/versus/async-game/${matchId}`);
|
||||
} catch (err: unknown) {
|
||||
const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
if (pw) {
|
||||
setJoinError(message ?? 'Incorrect password');
|
||||
} else if (message === 'You have already attempted this match') {
|
||||
navigate(`/versus/async/${matchId}/leaderboard`);
|
||||
}
|
||||
} finally {
|
||||
setJoining(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordSubmit = () => {
|
||||
if (!passwordPromptMatch) return;
|
||||
if (passwordPromptAction === 'challenge') {
|
||||
doChallenge(passwordPromptMatch.id, joinPassword);
|
||||
} else {
|
||||
doJoin(passwordPromptMatch.id, joinPassword);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6 py-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Swords className="h-5 w-5" />
|
||||
<h1 className="text-2xl font-bold">Versus Mode</h1>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold">Create a Match</h2>
|
||||
<div className="mb-4 flex gap-2">
|
||||
{(['easy', 'medium', 'hard'] as Difficulty[]).map((d) => (
|
||||
<Button
|
||||
key={d}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={getDifficultyButtonClass(d, difficulty === d)}
|
||||
onClick={() => setDifficulty(d)}
|
||||
>
|
||||
{d.charAt(0).toUpperCase() + d.slice(1)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mb-4 grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
placeholder="Lobby name (optional)"
|
||||
value={lobbyName}
|
||||
onChange={(e) => setLobbyName(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Password (optional)"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAsync}
|
||||
onChange={(e) => setIsAsync(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Async Challenge
|
||||
</label>
|
||||
{isAsync && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
— Play first, then others challenge your score (24h)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={handleCreate} disabled={creating}>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create Match'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* My Waiting Matches */}
|
||||
{!loading && myMatches.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h2 className="mb-3 text-lg font-semibold">My Matches</h2>
|
||||
<div className="space-y-2">
|
||||
{myMatches.map((match) => (
|
||||
<div
|
||||
key={match.id}
|
||||
className="flex items-center justify-between rounded-lg border border-border bg-card p-4"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">
|
||||
{match.lobbyName || 'Unnamed match'}
|
||||
</p>
|
||||
{match.isAsync ? (
|
||||
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] font-semibold text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
ASYNC
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded bg-green-100 px-1.5 py-0.5 text-[10px] font-semibold text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||
LIVE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<DifficultyBadge difficulty={match.difficulty} />
|
||||
{match.hasPassword && (
|
||||
<Lock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
{match.isAsync && match.status === 'open' && (
|
||||
<span className="flex items-center gap-1 text-xs">
|
||||
<Users className="h-3 w-3" />
|
||||
{match.attemptCount ?? 0} challengers
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{match.isAsync && match.status === 'open' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/versus/async/${match.id}/leaderboard`)}
|
||||
>
|
||||
<BarChart3 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Leaderboard
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (match.isAsync) {
|
||||
navigate(`/versus/async-game/${match.id}`);
|
||||
} else {
|
||||
navigate(`/versus/lobby/${match.id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowRight className="mr-1.5 h-3.5 w-3.5" />
|
||||
{match.isAsync ? 'Play' : 'Rejoin'}
|
||||
</Button>
|
||||
)}
|
||||
{match.status === 'waiting' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleCancel(match.id)}
|
||||
disabled={cancelling === match.id}
|
||||
>
|
||||
{cancelling === match.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<X className="mr-1.5 h-3.5 w-3.5" />
|
||||
Cancel
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold">Open Matches</h2>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={fetchMatches}>
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="mb-4 flex gap-1 rounded-lg border border-border bg-muted/50 p-1">
|
||||
{([
|
||||
{ key: 'all' as const, label: 'All', icon: undefined },
|
||||
{ key: 'sync' as const, label: 'Live', icon: Zap },
|
||||
{ key: 'async' as const, label: 'Async', icon: Clock },
|
||||
]).map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setFilterMode(key)}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
filterMode === key
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className="h-3.5 w-3.5" />}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && matches.length === 0 && (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
No open matches right now. Create one!
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && matches.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{matches.map((match) => (
|
||||
<div
|
||||
key={match.id}
|
||||
className="flex items-center justify-between rounded-lg border border-border bg-card p-4"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">
|
||||
{match.lobbyName || `${match.player1?.username ?? 'Unknown'}'s match`}
|
||||
</p>
|
||||
{match.isAsync ? (
|
||||
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] font-semibold text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
ASYNC
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded bg-green-100 px-1.5 py-0.5 text-[10px] font-semibold text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||
LIVE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>by {match.player1?.username ?? 'Unknown'}</span>
|
||||
<DifficultyBadge difficulty={match.difficulty} />
|
||||
{match.hasPassword && (
|
||||
<Lock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
{match.isAsync && (
|
||||
<>
|
||||
{match.chainLength != null && (
|
||||
<span className="flex items-center gap-1 text-xs">
|
||||
Chain: {rawToLinkCount(match.chainLength!)} links
|
||||
</span>
|
||||
)}
|
||||
{match.expiresAt && (
|
||||
<span className="flex items-center gap-1 text-xs">
|
||||
<Timer className="h-3 w-3" />
|
||||
{timeRemaining(match.expiresAt)}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 text-xs">
|
||||
<Users className="h-3 w-3" />
|
||||
{match.attemptCount ?? 0} challengers
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{match.isAsync ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleChallenge(match.id, match)}
|
||||
disabled={joining === match.id}
|
||||
>
|
||||
{joining === match.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
'Challenge'
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleJoin(match.id, match)}
|
||||
disabled={joining === match.id}
|
||||
>
|
||||
{joining === match.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
'Join'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password prompt dialog */}
|
||||
<Dialog
|
||||
open={!!passwordPromptMatch}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPasswordPromptMatch(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enter Password</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Match password"
|
||||
value={joinPassword}
|
||||
onChange={(e) => {
|
||||
setJoinPassword(e.target.value);
|
||||
setJoinError('');
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handlePasswordSubmit()}
|
||||
/>
|
||||
{joinError && (
|
||||
<p className="text-sm text-red-500">{joinError}</p>
|
||||
)}
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handlePasswordSubmit}
|
||||
disabled={!joinPassword || joining === passwordPromptMatch?.id}
|
||||
>
|
||||
{joining === passwordPromptMatch?.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : passwordPromptAction === 'challenge' ? (
|
||||
'Start Challenge'
|
||||
) : (
|
||||
'Join Match'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import { useVersusStore } from '@/stores/versus-store';
|
||||
import { useChainValidation } from '@/hooks/use-chain-validation';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import GameHeader from '@/components/game/GameHeader';
|
||||
import ChainDisplay from '@/components/game/ChainDisplay';
|
||||
import SearchAutocomplete from '@/components/game/SearchAutocomplete';
|
||||
import ValidationFeedback from '@/components/game/ValidationFeedback';
|
||||
import HintButton from '@/components/game/HintButton';
|
||||
import OpponentProgress from '@/components/versus/OpponentProgress';
|
||||
import VersusCompletionModal from '@/components/versus/VersusCompletionModal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Undo2 } from 'lucide-react';
|
||||
|
||||
export default function VersusGame() {
|
||||
const status = useGameStore((s) => s.status);
|
||||
const chain = useGameStore((s) => s.chain);
|
||||
const currentSearchMode = useGameStore((s) => s.currentSearchMode);
|
||||
const passedMovieB = useGameStore((s) => s.passedMovieB);
|
||||
const movieA = useGameStore((s) => s.movieA);
|
||||
const movieB = useGameStore((s) => s.movieB);
|
||||
const isValidating = useGameStore((s) => s.isValidating);
|
||||
const score = useGameStore((s) => s.score);
|
||||
const undoLastLink = useGameStore((s) => s.undoLastLink);
|
||||
const navigate = useNavigate();
|
||||
const { validateAndAddActor, validateAndAddMovie } = useChainValidation();
|
||||
const sendChainUpdate = useVersusStore((s) => s.sendChainUpdate);
|
||||
const sendMatchComplete = useVersusStore((s) => s.sendMatchComplete);
|
||||
|
||||
// Broadcast chain updates to opponent
|
||||
useEffect(() => {
|
||||
if (chain.length > 0) {
|
||||
sendChainUpdate(chain.length);
|
||||
}
|
||||
}, [chain.length, sendChainUpdate]);
|
||||
|
||||
// Send match completion when game completes
|
||||
useEffect(() => {
|
||||
if (status === 'completed' && score) {
|
||||
sendMatchComplete(score);
|
||||
}
|
||||
}, [status, score, sendMatchComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'idle') {
|
||||
navigate('/versus');
|
||||
}
|
||||
}, [status, navigate]);
|
||||
|
||||
if (status === 'idle' || !movieA || !movieB) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastLink = chain[chain.length - 1];
|
||||
let searchLabel = '';
|
||||
let searchPlaceholder = '';
|
||||
|
||||
if (currentSearchMode === 'actor' && lastLink?.type === 'movie') {
|
||||
searchLabel = `Who was in "${lastLink.title}"?`;
|
||||
searchPlaceholder = 'Search for an actor...';
|
||||
} else if (currentSearchMode === 'movie' && lastLink?.type === 'actor') {
|
||||
searchLabel = `What movie was ${lastLink.name} also in?`;
|
||||
searchPlaceholder = 'Search for a movie...';
|
||||
}
|
||||
|
||||
const showLoopHint = passedMovieB && currentSearchMode === 'movie' && movieA;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-4">
|
||||
<GameHeader />
|
||||
|
||||
<OpponentProgress />
|
||||
|
||||
<Separator />
|
||||
|
||||
<ChainDisplay />
|
||||
|
||||
{status === 'playing' && (
|
||||
<div className="space-y-3">
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">{searchLabel}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<HintButton />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={undoLastLink}
|
||||
disabled={chain.length <= 1 || isValidating}
|
||||
>
|
||||
<Undo2 className="mr-1 h-3.5 w-3.5" />
|
||||
Undo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchAutocomplete
|
||||
mode={currentSearchMode}
|
||||
onSelectActor={validateAndAddActor}
|
||||
onSelectMovie={validateAndAddMovie}
|
||||
disabled={isValidating}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
|
||||
<ValidationFeedback />
|
||||
|
||||
{showLoopHint && (
|
||||
<p className="text-sm text-green-600">
|
||||
Select "{movieA.title}" to close the loop!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<VersusCompletionModal />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useVersusStore } from '@/stores/versus-store';
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import { Loader2, X, Play } from 'lucide-react';
|
||||
import type { MoviePair } from '@/types';
|
||||
|
||||
export default function VersusLobby() {
|
||||
const { matchId } = useParams<{ matchId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const startGameFromVersus = useGameStore((s) => s.startGameFromVersus);
|
||||
|
||||
const {
|
||||
connect,
|
||||
joinLobby,
|
||||
startCountdown,
|
||||
leaveLobby,
|
||||
reset,
|
||||
lobbyState,
|
||||
countdownValue,
|
||||
player1,
|
||||
player2,
|
||||
connected,
|
||||
} = useVersusStore();
|
||||
|
||||
const isCreator = user?.id === player1?.id;
|
||||
|
||||
const handleGameStart = useCallback(
|
||||
async (data: { movieA: MoviePair['movieA']; movieB: MoviePair['movieB']; startTime: number }) => {
|
||||
await startGameFromVersus(
|
||||
{ movieA: data.movieA, movieB: data.movieB },
|
||||
data.startTime,
|
||||
);
|
||||
navigate('/versus/game');
|
||||
},
|
||||
[startGameFromVersus, navigate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !matchId) {
|
||||
navigate('/versus');
|
||||
return;
|
||||
}
|
||||
|
||||
connect();
|
||||
}, [user, matchId, connect, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connected && matchId) {
|
||||
joinLobby(matchId);
|
||||
}
|
||||
}, [connected, matchId, joinLobby]);
|
||||
|
||||
// Listen for game-start event
|
||||
useEffect(() => {
|
||||
const socket = useVersusStore.getState().socket;
|
||||
if (!socket) return;
|
||||
|
||||
const handler = (data: any) => handleGameStart(data);
|
||||
socket.on('game-start', handler);
|
||||
return () => {
|
||||
socket.off('game-start', handler);
|
||||
};
|
||||
}, [connected, handleGameStart]);
|
||||
|
||||
// Handle match cancellation
|
||||
useEffect(() => {
|
||||
if (lobbyState === 'idle' && connected) {
|
||||
navigate('/versus');
|
||||
}
|
||||
}, [lobbyState, connected, navigate]);
|
||||
|
||||
const handleLeave = () => {
|
||||
leaveLobby();
|
||||
reset();
|
||||
navigate('/versus');
|
||||
};
|
||||
|
||||
if (!user || !matchId) return null;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center space-y-8">
|
||||
{/* Countdown overlay */}
|
||||
{lobbyState === 'countdown' && countdownValue !== null && (
|
||||
<div className="text-center">
|
||||
<div className="animate-pulse text-8xl font-black text-primary">
|
||||
{countdownValue}
|
||||
</div>
|
||||
<p className="mt-4 text-lg text-muted-foreground">Get ready...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Waiting state */}
|
||||
{lobbyState === 'waiting' && (
|
||||
<div className="text-center space-y-4">
|
||||
<h2 className="text-2xl font-bold">Versus Lobby</h2>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Waiting for opponent...</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card p-6 space-y-3">
|
||||
<p className="font-medium">{player1?.username ?? 'You'}</p>
|
||||
{isCreator && (
|
||||
<Button variant="destructive" size="sm" onClick={handleLeave}>
|
||||
<X className="mr-1.5 h-3.5 w-3.5" />
|
||||
Cancel Match
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Opponent joined */}
|
||||
{lobbyState === 'opponent-joined' && (
|
||||
<div className="text-center space-y-6">
|
||||
<h2 className="text-2xl font-bold">Opponent Found!</h2>
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="rounded-lg border border-border bg-card p-6 text-center min-w-[140px]">
|
||||
<p className="font-semibold">{player1?.username}</p>
|
||||
<p className="text-xs text-muted-foreground">Creator</p>
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-muted-foreground">vs</span>
|
||||
<div className="rounded-lg border border-border bg-card p-6 text-center min-w-[140px]">
|
||||
<p className="font-semibold">{player2?.username}</p>
|
||||
<p className="text-xs text-muted-foreground">Challenger</p>
|
||||
</div>
|
||||
</div>
|
||||
{isCreator ? (
|
||||
<Button size="lg" onClick={startCountdown}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Game
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
Waiting for {player1?.username} to start...
|
||||
</p>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={handleLeave}>
|
||||
Leave Lobby
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading / connecting */}
|
||||
{(lobbyState === 'idle' || !connected) && lobbyState !== 'countdown' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span className="text-muted-foreground">Connecting...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { create } from 'zustand';
|
||||
import * as authApi from '@/api/auth';
|
||||
import { useNotificationStore } from '@/stores/notification-store';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
loadUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
login: async (email, password) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await authApi.login(email, password);
|
||||
localStorage.setItem('auth_token', response.access_token);
|
||||
set({ user: response.user, isLoading: false });
|
||||
useNotificationStore.getState().connect();
|
||||
} catch (err: any) {
|
||||
const message =
|
||||
err.response?.data?.message || 'Login failed. Please try again.';
|
||||
set({ error: message, isLoading: false });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
register: async (email, username, password) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await authApi.register(email, username, password);
|
||||
localStorage.setItem('auth_token', response.access_token);
|
||||
set({ user: response.user, isLoading: false });
|
||||
useNotificationStore.getState().connect();
|
||||
} catch (err: any) {
|
||||
const message =
|
||||
err.response?.data?.message || 'Registration failed. Please try again.';
|
||||
set({ error: message, isLoading: false });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('auth_token');
|
||||
useNotificationStore.getState().disconnect();
|
||||
set({ user: null, error: null });
|
||||
},
|
||||
|
||||
loadUser: async () => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const profile = await authApi.getProfile();
|
||||
set({ user: { id: profile.id, email: profile.email, username: profile.username }, isLoading: false });
|
||||
useNotificationStore.getState().connect();
|
||||
} catch {
|
||||
localStorage.removeItem('auth_token');
|
||||
set({ user: null, isLoading: false });
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,259 @@
|
||||
import { create } from 'zustand';
|
||||
import { getMovieDetails } from '@/api/movies';
|
||||
import { calculateScore } from '@/lib/scoring';
|
||||
import type {
|
||||
ChainLink,
|
||||
MovieChainLink,
|
||||
ActorChainLink,
|
||||
SearchMode,
|
||||
GameStatus,
|
||||
ScoreBreakdown,
|
||||
MoviePair,
|
||||
} from '@/types';
|
||||
|
||||
interface GameState {
|
||||
movieA: MovieChainLink | null;
|
||||
movieB: MovieChainLink | null;
|
||||
status: GameStatus;
|
||||
chain: ChainLink[];
|
||||
currentSearchMode: SearchMode;
|
||||
passedMovieB: boolean;
|
||||
startTime: number | null;
|
||||
isValidating: boolean;
|
||||
validationError: string | null;
|
||||
score: ScoreBreakdown | null;
|
||||
hintsUsed: number;
|
||||
sessionId: string | null;
|
||||
|
||||
startGame: (pair: MoviePair) => Promise<void>;
|
||||
startGameFromVersus: (pair: MoviePair, startTime: number) => Promise<void>;
|
||||
addActorToChain: (actor: ActorChainLink) => void;
|
||||
addMovieToChain: (movie: MovieChainLink) => void;
|
||||
undoLastLink: () => void;
|
||||
setValidating: (isValidating: boolean) => void;
|
||||
setValidationError: (error: string | null) => void;
|
||||
incrementHintsUsed: () => void;
|
||||
resetGame: () => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
movieA: null as MovieChainLink | null,
|
||||
movieB: null as MovieChainLink | null,
|
||||
status: 'idle' as GameStatus,
|
||||
chain: [] as ChainLink[],
|
||||
currentSearchMode: 'actor' as SearchMode,
|
||||
passedMovieB: false,
|
||||
startTime: null as number | null,
|
||||
isValidating: false,
|
||||
validationError: null as string | null,
|
||||
score: null as ScoreBreakdown | null,
|
||||
hintsUsed: 0,
|
||||
sessionId: null as string | null,
|
||||
};
|
||||
|
||||
export const useGameStore = create<GameState>((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
startGame: async (pair: MoviePair) => {
|
||||
// Reset if already playing
|
||||
set({ ...initialState, status: 'playing', isValidating: true });
|
||||
|
||||
try {
|
||||
const [detailsA, detailsB] = await Promise.all([
|
||||
getMovieDetails(pair.movieA.id),
|
||||
getMovieDetails(pair.movieB.id),
|
||||
]);
|
||||
|
||||
const movieALink: MovieChainLink = {
|
||||
type: 'movie',
|
||||
id: detailsA.id,
|
||||
title: detailsA.title,
|
||||
posterPath: detailsA.poster_path,
|
||||
releaseDate: detailsA.release_date,
|
||||
popularity: detailsA.popularity,
|
||||
};
|
||||
|
||||
const movieBLink: MovieChainLink = {
|
||||
type: 'movie',
|
||||
id: detailsB.id,
|
||||
title: detailsB.title,
|
||||
posterPath: detailsB.poster_path,
|
||||
releaseDate: detailsB.release_date,
|
||||
popularity: detailsB.popularity,
|
||||
};
|
||||
|
||||
set({
|
||||
movieA: movieALink,
|
||||
movieB: movieBLink,
|
||||
chain: [movieALink],
|
||||
currentSearchMode: 'actor',
|
||||
startTime: Date.now(),
|
||||
isValidating: false,
|
||||
});
|
||||
|
||||
// Fire-and-forget backend session creation
|
||||
import('@/api/games').then(({ createGameSession }) => {
|
||||
createGameSession(
|
||||
pair.movieA.id,
|
||||
pair.movieA.title,
|
||||
pair.movieB.id,
|
||||
pair.movieB.title,
|
||||
)
|
||||
.then((session) => set({ sessionId: session.id }))
|
||||
.catch(() => {
|
||||
// Backend unavailable — game continues without persistence
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
set({
|
||||
status: 'idle',
|
||||
isValidating: false,
|
||||
validationError: 'Failed to load movie details. Please try again.',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
startGameFromVersus: async (pair: MoviePair, startTime: number) => {
|
||||
set({ ...initialState, status: 'playing', isValidating: true });
|
||||
|
||||
try {
|
||||
const [detailsA, detailsB] = await Promise.all([
|
||||
getMovieDetails(pair.movieA.id),
|
||||
getMovieDetails(pair.movieB.id),
|
||||
]);
|
||||
|
||||
const movieALink: MovieChainLink = {
|
||||
type: 'movie',
|
||||
id: detailsA.id,
|
||||
title: detailsA.title,
|
||||
posterPath: detailsA.poster_path,
|
||||
releaseDate: detailsA.release_date,
|
||||
popularity: detailsA.popularity,
|
||||
};
|
||||
|
||||
const movieBLink: MovieChainLink = {
|
||||
type: 'movie',
|
||||
id: detailsB.id,
|
||||
title: detailsB.title,
|
||||
posterPath: detailsB.poster_path,
|
||||
releaseDate: detailsB.release_date,
|
||||
popularity: detailsB.popularity,
|
||||
};
|
||||
|
||||
set({
|
||||
movieA: movieALink,
|
||||
movieB: movieBLink,
|
||||
chain: [movieALink],
|
||||
currentSearchMode: 'actor',
|
||||
startTime,
|
||||
isValidating: false,
|
||||
});
|
||||
} catch {
|
||||
set({
|
||||
status: 'idle',
|
||||
isValidating: false,
|
||||
validationError: 'Failed to load movie details. Please try again.',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
addActorToChain: (actor: ActorChainLink) => {
|
||||
const { status, chain } = get();
|
||||
if (status !== 'playing') return;
|
||||
|
||||
set({
|
||||
chain: [...chain, actor],
|
||||
currentSearchMode: 'movie',
|
||||
validationError: null,
|
||||
});
|
||||
},
|
||||
|
||||
addMovieToChain: (movie: MovieChainLink) => {
|
||||
const { status, chain, movieA, movieB, passedMovieB, startTime, hintsUsed } = get();
|
||||
if (status !== 'playing') return;
|
||||
|
||||
const newChain = [...chain, movie];
|
||||
|
||||
// Check if this movie is movieB
|
||||
const hasPassedMovieB = passedMovieB || movie.id === movieB?.id;
|
||||
|
||||
// Check for loop completion:
|
||||
// Chain must have >= 5 links, have visited movieB, and last movie is movieA
|
||||
if (
|
||||
hasPassedMovieB &&
|
||||
movieA &&
|
||||
movie.id === movieA.id &&
|
||||
newChain.length >= 5
|
||||
) {
|
||||
const completedAt = Date.now();
|
||||
const score = calculateScore(
|
||||
newChain,
|
||||
startTime!,
|
||||
completedAt,
|
||||
hintsUsed,
|
||||
);
|
||||
|
||||
set({
|
||||
chain: newChain,
|
||||
status: 'completed',
|
||||
passedMovieB: true,
|
||||
score,
|
||||
currentSearchMode: 'actor',
|
||||
validationError: null,
|
||||
});
|
||||
|
||||
// Fire-and-forget backend completion
|
||||
const { sessionId } = get();
|
||||
if (sessionId) {
|
||||
import('@/api/games').then(({ completeGameSession }) => {
|
||||
completeGameSession(sessionId, newChain, hintsUsed).catch(() => {
|
||||
// Backend unavailable — score already calculated client-side
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
set({
|
||||
chain: newChain,
|
||||
currentSearchMode: 'actor',
|
||||
passedMovieB: hasPassedMovieB,
|
||||
validationError: null,
|
||||
});
|
||||
},
|
||||
|
||||
undoLastLink: () => {
|
||||
const { status, chain, movieB } = get();
|
||||
if (status !== 'playing') return;
|
||||
if (chain.length <= 1) return;
|
||||
|
||||
const newChain = chain.slice(0, -1);
|
||||
const lastLink = newChain[newChain.length - 1];
|
||||
|
||||
// Recompute passedMovieB
|
||||
const hasPassedMovieB = newChain.some(
|
||||
(link) => link.type === 'movie' && link.id === movieB?.id,
|
||||
);
|
||||
|
||||
// Determine search mode from the last link
|
||||
const newSearchMode: SearchMode =
|
||||
lastLink.type === 'movie' ? 'actor' : 'movie';
|
||||
|
||||
set({
|
||||
chain: newChain,
|
||||
currentSearchMode: newSearchMode,
|
||||
passedMovieB: hasPassedMovieB,
|
||||
validationError: null,
|
||||
});
|
||||
},
|
||||
|
||||
setValidating: (isValidating: boolean) => set({ isValidating }),
|
||||
|
||||
setValidationError: (error: string | null) =>
|
||||
set({ validationError: error }),
|
||||
|
||||
incrementHintsUsed: () =>
|
||||
set((state) => ({ hintsUsed: state.hintsUsed + 1 })),
|
||||
|
||||
resetGame: () => set(initialState),
|
||||
}));
|
||||
@@ -0,0 +1,123 @@
|
||||
import { create } from 'zustand';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { getWsUrl } from '@/lib/ws';
|
||||
import * as notificationsApi from '@/api/notifications';
|
||||
import type { Notification } from '@/api/notifications';
|
||||
|
||||
interface NotificationState {
|
||||
notifications: Notification[];
|
||||
unreadCount: number;
|
||||
socket: Socket | null;
|
||||
|
||||
connect: () => void;
|
||||
disconnect: () => void;
|
||||
fetchNotifications: () => Promise<void>;
|
||||
fetchUnreadCount: () => Promise<void>;
|
||||
markRead: (id: string) => Promise<void>;
|
||||
markAllRead: () => Promise<void>;
|
||||
dismiss: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useNotificationStore = create<NotificationState>((set, get) => ({
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
socket: null,
|
||||
|
||||
connect: () => {
|
||||
const existing = get().socket;
|
||||
if (existing?.connected) return;
|
||||
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
|
||||
const socket = io(getWsUrl('/notifications'), {
|
||||
auth: { token },
|
||||
transports: ['websocket'],
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
// Fetch initial state on connect
|
||||
get().fetchUnreadCount();
|
||||
});
|
||||
|
||||
socket.on('notification', (notification: Notification) => {
|
||||
set((state) => ({
|
||||
notifications: [notification, ...state.notifications],
|
||||
}));
|
||||
});
|
||||
|
||||
socket.on('unread-count', (count: number) => {
|
||||
set({ unreadCount: count });
|
||||
});
|
||||
|
||||
set({ socket });
|
||||
},
|
||||
|
||||
disconnect: () => {
|
||||
const { socket } = get();
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
set({ socket: null, notifications: [], unreadCount: 0 });
|
||||
}
|
||||
},
|
||||
|
||||
fetchNotifications: async () => {
|
||||
try {
|
||||
const result = await notificationsApi.getNotifications();
|
||||
set({ notifications: result.data, unreadCount: result.unreadCount });
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
},
|
||||
|
||||
fetchUnreadCount: async () => {
|
||||
try {
|
||||
const count = await notificationsApi.getUnreadCount();
|
||||
set({ unreadCount: count });
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
},
|
||||
|
||||
markRead: async (id: string) => {
|
||||
// Optimistic update
|
||||
set((state) => ({
|
||||
notifications: state.notifications.map((n) =>
|
||||
n.id === id ? { ...n, read: true } : n,
|
||||
),
|
||||
unreadCount: Math.max(0, state.unreadCount - 1),
|
||||
}));
|
||||
try {
|
||||
await notificationsApi.markNotificationRead(id);
|
||||
} catch {
|
||||
// Revert on failure
|
||||
get().fetchNotifications();
|
||||
}
|
||||
},
|
||||
|
||||
markAllRead: async () => {
|
||||
const prev = get().notifications;
|
||||
set((state) => ({
|
||||
notifications: state.notifications.map((n) => ({ ...n, read: true })),
|
||||
unreadCount: 0,
|
||||
}));
|
||||
try {
|
||||
await notificationsApi.markAllNotificationsRead();
|
||||
} catch {
|
||||
set({ notifications: prev });
|
||||
get().fetchUnreadCount();
|
||||
}
|
||||
},
|
||||
|
||||
dismiss: async (id: string) => {
|
||||
const prev = get().notifications;
|
||||
set((state) => ({
|
||||
notifications: state.notifications.filter((n) => n.id !== id),
|
||||
}));
|
||||
try {
|
||||
await notificationsApi.deleteNotification(id);
|
||||
} catch {
|
||||
set({ notifications: prev });
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,192 @@
|
||||
import { create } from 'zustand';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { getWsUrl } from '@/lib/ws';
|
||||
|
||||
export type LobbyState =
|
||||
| 'idle'
|
||||
| 'waiting'
|
||||
| 'opponent-joined'
|
||||
| 'countdown'
|
||||
| 'playing'
|
||||
| 'finished';
|
||||
|
||||
interface MatchResult {
|
||||
winnerId: string | null;
|
||||
player1: { id: string; username: string };
|
||||
player2: { id: string; username: string };
|
||||
player1Score: object | null;
|
||||
player2Score: object | null;
|
||||
}
|
||||
|
||||
interface VersusState {
|
||||
socket: Socket | null;
|
||||
connected: boolean;
|
||||
matchId: string | null;
|
||||
lobbyState: LobbyState;
|
||||
countdownValue: number | null;
|
||||
player1: { id: string; username: string } | null;
|
||||
player2: { id: string; username: string } | null;
|
||||
opponentChainLength: number;
|
||||
opponentFinished: boolean;
|
||||
matchResult: MatchResult | null;
|
||||
myScore: object | null;
|
||||
|
||||
connect: () => void;
|
||||
disconnect: () => void;
|
||||
joinLobby: (matchId: string) => void;
|
||||
startCountdown: () => void;
|
||||
sendChainUpdate: (chainLength: number) => void;
|
||||
sendMatchComplete: (score: object) => void;
|
||||
leaveLobby: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
socket: null as Socket | null,
|
||||
connected: false,
|
||||
matchId: null as string | null,
|
||||
lobbyState: 'idle' as LobbyState,
|
||||
countdownValue: null as number | null,
|
||||
player1: null as { id: string; username: string } | null,
|
||||
player2: null as { id: string; username: string } | null,
|
||||
opponentChainLength: 0,
|
||||
opponentFinished: false,
|
||||
matchResult: null as MatchResult | null,
|
||||
myScore: null as object | null,
|
||||
};
|
||||
|
||||
export const useVersusStore = create<VersusState>((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
connect: () => {
|
||||
const existing = get().socket;
|
||||
if (existing?.connected) return;
|
||||
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
|
||||
const socket = io(getWsUrl('/versus'), {
|
||||
auth: { token },
|
||||
transports: ['websocket'],
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
set({ connected: true });
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
set({ connected: false });
|
||||
});
|
||||
|
||||
socket.on('lobby-state', (data) => {
|
||||
set({
|
||||
lobbyState: data.player2 ? 'opponent-joined' : 'waiting',
|
||||
player1: data.player1,
|
||||
player2: data.player2,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('player-joined', (data) => {
|
||||
set({
|
||||
lobbyState: 'opponent-joined',
|
||||
player1: data.player1,
|
||||
player2: data.player2,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('player-left', () => {
|
||||
set({
|
||||
lobbyState: 'waiting',
|
||||
player2: null,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('countdown', (data: { value: number }) => {
|
||||
set({ lobbyState: 'countdown', countdownValue: data.value });
|
||||
});
|
||||
|
||||
socket.on('game-start', (data) => {
|
||||
set({ lobbyState: 'playing' });
|
||||
// The VersusLobby page will handle navigation using this event
|
||||
// Store the event data for the page to consume
|
||||
const handler = (get() as any)._onGameStart;
|
||||
if (handler) handler(data);
|
||||
});
|
||||
|
||||
socket.on('opponent-progress', (data: { chainLength: number }) => {
|
||||
set({ opponentChainLength: data.chainLength });
|
||||
});
|
||||
|
||||
socket.on('opponent-finished', () => {
|
||||
set({ opponentFinished: true });
|
||||
});
|
||||
|
||||
socket.on('match-finished', (data) => {
|
||||
set({
|
||||
lobbyState: 'finished',
|
||||
matchResult: {
|
||||
winnerId: data.winnerId,
|
||||
player1: data.player1,
|
||||
player2: data.player2,
|
||||
player1Score: data.player1Score,
|
||||
player2Score: data.player2Score,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('match-cancelled', () => {
|
||||
set({ lobbyState: 'idle', matchId: null });
|
||||
});
|
||||
|
||||
set({ socket });
|
||||
},
|
||||
|
||||
disconnect: () => {
|
||||
const { socket } = get();
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
set({ socket: null, connected: false });
|
||||
}
|
||||
},
|
||||
|
||||
joinLobby: (matchId: string) => {
|
||||
const { socket } = get();
|
||||
if (!socket) return;
|
||||
set({ matchId });
|
||||
socket.emit('join-lobby', { matchId });
|
||||
},
|
||||
|
||||
startCountdown: () => {
|
||||
const { socket, matchId } = get();
|
||||
if (!socket || !matchId) return;
|
||||
socket.emit('start-countdown', { matchId });
|
||||
},
|
||||
|
||||
sendChainUpdate: (chainLength: number) => {
|
||||
const { socket, matchId } = get();
|
||||
if (!socket || !matchId) return;
|
||||
socket.emit('chain-update', { matchId, chainLength });
|
||||
},
|
||||
|
||||
sendMatchComplete: (score: object) => {
|
||||
const { socket, matchId } = get();
|
||||
if (!socket || !matchId) return;
|
||||
set({ myScore: score });
|
||||
socket.emit('match-complete', { matchId, score });
|
||||
},
|
||||
|
||||
leaveLobby: () => {
|
||||
const { socket, matchId } = get();
|
||||
if (!socket || !matchId) return;
|
||||
socket.emit('leave-lobby', { matchId });
|
||||
set({ matchId: null, lobbyState: 'idle' });
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
const { socket } = get();
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
}
|
||||
set({ ...initialState });
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,52 @@
|
||||
export type SearchMode = 'actor' | 'movie';
|
||||
export type GameStatus = 'idle' | 'playing' | 'completed';
|
||||
|
||||
export interface MovieChainLink {
|
||||
type: 'movie';
|
||||
id: number;
|
||||
title: string;
|
||||
posterPath: string | null;
|
||||
releaseDate: string;
|
||||
popularity: number;
|
||||
}
|
||||
|
||||
export interface ActorChainLink {
|
||||
type: 'actor';
|
||||
id: number;
|
||||
name: string;
|
||||
profilePath: string | null;
|
||||
popularity: number;
|
||||
}
|
||||
|
||||
export type ChainLink = MovieChainLink | ActorChainLink;
|
||||
|
||||
export interface MoviePair {
|
||||
movieA: { id: number; title: string };
|
||||
movieB: { id: number; title: string };
|
||||
}
|
||||
|
||||
export interface PresetMoviePair extends MoviePair {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ScoreBreakdown {
|
||||
baseScore: number;
|
||||
chainLength: number;
|
||||
linkCount: number;
|
||||
chainLengthBonus: number;
|
||||
timeBonus: number;
|
||||
obscurityBonus: number;
|
||||
hintPenalty: number;
|
||||
elapsedSeconds: number;
|
||||
totalScore: number;
|
||||
}
|
||||
|
||||
/** Number of actor-movie pair "links" in a chain (excludes starting movie) */
|
||||
export function getLinkCount(chain: ChainLink[]): number {
|
||||
return Math.floor((chain.length - 1) / 2);
|
||||
}
|
||||
|
||||
/** Convert a raw chain length number to link count */
|
||||
export function rawToLinkCount(rawLength: number): number {
|
||||
return Math.floor((rawLength - 1) / 2);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './tmdb';
|
||||
export * from './game';
|
||||
@@ -0,0 +1,97 @@
|
||||
// TMDB API response types (mirrors backend types)
|
||||
|
||||
export interface TmdbMovieResult {
|
||||
id: number;
|
||||
title: string;
|
||||
original_title: string;
|
||||
overview: string;
|
||||
poster_path: string | null;
|
||||
backdrop_path: string | null;
|
||||
release_date: string;
|
||||
popularity: number;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
genre_ids: number[];
|
||||
adult: boolean;
|
||||
}
|
||||
|
||||
export interface TmdbPersonResult {
|
||||
id: number;
|
||||
name: string;
|
||||
profile_path: string | null;
|
||||
popularity: number;
|
||||
known_for_department: string;
|
||||
known_for: TmdbMovieResult[];
|
||||
adult: boolean;
|
||||
}
|
||||
|
||||
export interface TmdbCastMember {
|
||||
id: number;
|
||||
name: string;
|
||||
character: string;
|
||||
profile_path: string | null;
|
||||
popularity: number;
|
||||
known_for_department: string;
|
||||
order: number;
|
||||
adult: boolean;
|
||||
}
|
||||
|
||||
export interface TmdbPersonCastCredit {
|
||||
id: number;
|
||||
title: string;
|
||||
original_title: string;
|
||||
character: string;
|
||||
poster_path: string | null;
|
||||
release_date: string;
|
||||
popularity: number;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
adult: boolean;
|
||||
}
|
||||
|
||||
export interface TmdbSearchMovieResponse {
|
||||
page: number;
|
||||
results: TmdbMovieResult[];
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
}
|
||||
|
||||
export interface TmdbSearchPersonResponse {
|
||||
page: number;
|
||||
results: TmdbPersonResult[];
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
}
|
||||
|
||||
export interface TmdbMovieCreditsResponse {
|
||||
id: number;
|
||||
cast: TmdbCastMember[];
|
||||
}
|
||||
|
||||
export interface TmdbPersonMovieCreditsResponse {
|
||||
id: number;
|
||||
cast: TmdbPersonCastCredit[];
|
||||
}
|
||||
|
||||
export interface TmdbMovieDetailsResponse extends TmdbMovieResult {
|
||||
runtime: number | null;
|
||||
budget: number;
|
||||
revenue: number;
|
||||
status: string;
|
||||
tagline: string;
|
||||
genres: { id: number; name: string }[];
|
||||
}
|
||||
|
||||
export interface TmdbPersonDetailsResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
biography: string;
|
||||
profile_path: string | null;
|
||||
birthday: string | null;
|
||||
deathday: string | null;
|
||||
place_of_birth: string | null;
|
||||
popularity: number;
|
||||
known_for_department: string;
|
||||
also_known_as: string[];
|
||||
adult: boolean;
|
||||
}
|
||||
@@ -16,6 +16,11 @@ export default defineConfig({
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://localhost:3000',
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user