Initial commit

This commit is contained in:
2026-03-09 15:03:08 -07:00
parent 4a0d0d1855
commit 0ceb2fac12
83 changed files with 7141 additions and 185 deletions
+2
View File
@@ -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 . .
+41 -41
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+86 -1
View File
@@ -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",
+1
View File
@@ -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",
+7
View File
@@ -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

+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
+24
View File
@@ -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;
}
+35
View File
@@ -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;
}
+30
View File
@@ -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;
+47
View File
@@ -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;
}
+34
View File
@@ -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;
}
+19
View File
@@ -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';
+75
View File
@@ -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;
}
+45
View File
@@ -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;
}
+48
View File
@@ -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');
}
+35
View File
@@ -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;
}
+9
View File
@@ -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;
}
+153
View File
@@ -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;
}
+35
View File
@@ -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>
);
}
+229
View File
@@ -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>
);
}
+118
View File
@@ -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>
</>
);
}
+46
View File
@@ -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>
);
}
+71
View File
@@ -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>
);
}
+73
View File
@@ -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>
);
}
+65
View File
@@ -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>
);
}
+288
View File
@@ -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>
);
}
+90
View File
@@ -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>
);
}
+29
View File
@@ -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>
);
}
+35
View File
@@ -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;
}
+159
View File
@@ -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>
);
}
+80
View File
@@ -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>
);
}
+33
View File
@@ -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>
);
}
+76
View File
@@ -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 }
+52
View File
@@ -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 }
+103
View File
@@ -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,
}
+157
View File
@@ -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,
}
+44
View File
@@ -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>
);
}
+20
View File
@@ -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 }
+54
View File
@@ -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 }
+23
View File
@@ -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 }
+13
View File
@@ -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 &middot; {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>
);
}
+164
View File
@@ -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 };
}
+12
View File
@@ -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;
}
+65
View File
@@ -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,
};
}
+39
View File
@@ -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),
};
}
+109 -58
View File
@@ -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,14 +118,63 @@
--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;
}
}
}
+253
View File
@@ -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 };
+60
View File
@@ -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),
};
}
+42
View File
@@ -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
}
}
+25
View File
@@ -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);
}
+6
View File
@@ -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}`;
}
+164
View File
@@ -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>
);
}
+185
View File
@@ -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} &harr; {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>
);
}
+274
View File
@@ -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 &quot;{movieA.title}&quot; 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>
);
}
+119
View File
@@ -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">&#8693;</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>
);
}
+155
View File
@@ -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>
);
}
+110
View File
@@ -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 &quot;{movieA.title}&quot; to close the loop!
</p>
)}
</div>
</div>
)}
</div>
<GameCompletionModal />
</PageLayout>
);
}
+152
View File
@@ -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>
);
}
+161
View File
@@ -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>
);
}
+114
View File
@@ -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>
);
}
+13
View File
@@ -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>
)
}
+223
View File
@@ -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>
);
}
+532
View File
@@ -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>
);
}
+126
View File
@@ -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 &quot;{movieA.title}&quot; to close the loop!
</p>
)}
</div>
</div>
)}
</div>
<VersusCompletionModal />
</PageLayout>
);
}
+159
View File
@@ -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>
);
}
+77
View File
@@ -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 });
}
},
}));
+259
View File
@@ -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),
}));
+123
View File
@@ -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 });
}
},
}));
+192
View File
@@ -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 });
},
}));
+52
View File
@@ -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);
}
+2
View File
@@ -0,0 +1,2 @@
export * from './tmdb';
export * from './game';
+97
View File
@@ -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;
}
+5
View File
@@ -16,6 +16,11 @@ export default defineConfig({
target: 'http://localhost:3000',
changeOrigin: true,
},
'/socket.io': {
target: 'http://localhost:3000',
ws: true,
changeOrigin: true,
},
},
},
})