Merge pull request 'Bugfix/multiplayer scoring and duplicates' (#2) from bugfix/multiplayer-scoring-and-duplicates into main
frontend-ci / secrets-scan (push) Successful in 5s
frontend-ci / sast (push) Successful in 6s
frontend-ci / fs-scan (push) Successful in 11s
frontend-ci / typecheck (push) Successful in 14s
frontend-ci / lint (push) Successful in 14s
frontend-ci / build (push) Successful in 35s
frontend-ci / push (push) Successful in 33s
frontend-ci / secrets-scan (push) Successful in 5s
frontend-ci / sast (push) Successful in 6s
frontend-ci / fs-scan (push) Successful in 11s
frontend-ci / typecheck (push) Successful in 14s
frontend-ci / lint (push) Successful in 14s
frontend-ci / build (push) Successful in 35s
frontend-ci / push (push) Successful in 33s
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
commit
51dbc45608
42 files changed
+3216
-2317
No files matched your search
@@ -16,7 +16,7 @@ jobs:
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- run: npm ci
|
||||
- run: npx eslint .
|
||||
- run: npx eslint . --max-warnings 0
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> History prior to 1.1.0 has not yet been backfilled from git.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.1] - 2026-09-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- All high-severity dependency advisories cleared (`npm audit`: 23 -> 0),
|
||||
entirely within existing semver ranges.
|
||||
- Five further `set-state-in-effect` violations caught by the updated
|
||||
react-hooks plugin, including search state that was cleared on every
|
||||
keystroke below the search threshold, and an async submit guard that re-ran
|
||||
its own effect.
|
||||
|
||||
### Changed
|
||||
|
||||
- In-range dependency updates, notably react 19.2 -> 19.3 and
|
||||
`@base-ui/react` 1.2 -> 1.8.
|
||||
- `shadcn`, `tailwindcss`, `@tailwindcss/vite` and `tw-animate-css` moved to
|
||||
devDependencies. They are build-time only - `shadcn` is a scaffolding CLI
|
||||
with no runtime imports - but as runtime dependencies they pulled `msw`,
|
||||
`@babel/core` and `ts-morph` into the production dependency tree that
|
||||
vulnerability scanning reads.
|
||||
|
||||
## [1.2.0] - 2026-09-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Game completion, hints and all versus submissions follow the server's
|
||||
authoritative scoring contract: completion posts no payload, hints go through
|
||||
the server (which also records them), and match submissions send a session id
|
||||
rather than a score.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stale hints are cleared when the chain changes; the previous movie's cast
|
||||
used to stay on screen after picking an actor another way.
|
||||
|
||||
## [1.1.0] - 2026-09-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- Versus, Game Night and Async Versus can complete a match again. None of the
|
||||
three ever created a backend game session, so no score was produced, nothing
|
||||
was submitted, the opponent was never notified, and the completion modal had
|
||||
no way out.
|
||||
- Chain links may be reused. Repeating an actor or movie was rejected outright
|
||||
instead of being allowed and penalised, which could strand a player mid-chain.
|
||||
Reuse now warns that it costs points, and the penalty appears in the score
|
||||
breakdown.
|
||||
- A failed async score submission is no longer retried on every render.
|
||||
- Confetti no longer re-randomises mid-flight.
|
||||
- Page data fetches are cancelled on unmount, so a late response cannot write
|
||||
into a view the player has left.
|
||||
- Profile stats and history load independently, fixing a guard that could stop
|
||||
the stats request from ever re-running.
|
||||
|
||||
### Changed
|
||||
|
||||
- The dev container starts the Vite server correctly and now picks up source
|
||||
edits, so hot reload works.
|
||||
- The lint CI job fails on warnings, and the two React hook rules that had been
|
||||
demoted to warnings are back at error.
|
||||
+4
-1
@@ -5,7 +5,10 @@ COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev", "--", "--host"]
|
||||
# Invoke vite directly rather than through `npm run`: under `docker compose
|
||||
# up -d` (no TTY, no stdin) the npm wrapper printed its banner and exited 0
|
||||
# immediately, so the dev server never came up.
|
||||
CMD ["node", "node_modules/vite/bin/vite.js", "--host"]
|
||||
|
||||
# --- Build stage ---
|
||||
# No VITE_API_URL build arg: the production image uses runtime config (/config.js)
|
||||
|
||||
+7
-6
@@ -16,15 +16,16 @@ export default defineConfig([
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
// Matches the ES2022 target in tsconfig.app.json.
|
||||
ecmaVersion: 2022,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
// The react-hooks v6 "set-state-in-effect" and "purity" rules flag
|
||||
// standard data-fetching patterns. Surface as warnings so CI passes
|
||||
// and they remain visible in the editor.
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
'react-hooks/purity': 'warn',
|
||||
// Left at their recommended 'error' severity. These were previously
|
||||
// downgraded to 'warn' so CI would pass, which meant every violation was
|
||||
// invisible in CI, including the stale-closure and cascading-render bugs
|
||||
// that downgrade was hiding. The underlying violations are fixed, so the
|
||||
// rules stay enforcing.
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
|
||||
Generated
+2378
-1913
File diff suppressed because it is too large.
Load diff
+7
-7
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "frontend-temp",
|
||||
"private": true,
|
||||
"version": "1.0.1",
|
||||
"version": "1.2.1",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Kevin Riehl",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"release": "commit-and-tag-version",
|
||||
"release:minor": "commit-and-tag-version --release-as minor",
|
||||
@@ -18,7 +18,6 @@
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"axios": "^1.13.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -26,25 +25,26 @@
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router": "^7.13.1",
|
||||
"shadcn": "^4.0.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"commit-and-tag-version": "^12.5.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"commit-and-tag-version": "^12.5.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"shadcn": "^4.0.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable no-restricted-globals */
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return;
|
||||
|
||||
+26
-6
@@ -27,15 +27,35 @@ export async function createGameSession(
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* No payload. The server scores the chain it validated link by link and the
|
||||
* hint count it incremented; sending our own was how a chain that had never
|
||||
* been validated could be scored.
|
||||
*/
|
||||
export async function completeGameSession(
|
||||
id: string,
|
||||
chain: ChainLink[],
|
||||
hintsUsed: number,
|
||||
): Promise<ScoreBreakdown> {
|
||||
const { data } = await apiClient.post<ScoreBreakdown>(`/games/${id}/complete`, {
|
||||
chain,
|
||||
hintsUsed,
|
||||
});
|
||||
const { data } = await apiClient.post<ScoreBreakdown>(
|
||||
`/games/${id}/complete`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface HintResponse {
|
||||
hintsUsed: number;
|
||||
hintsRemaining: number;
|
||||
actors: {
|
||||
id: number;
|
||||
name: string;
|
||||
profilePath: string | null;
|
||||
popularity: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
/** Takes a hint. The server records it, which is what makes the penalty real. */
|
||||
export async function useHint(id: string): Promise<HintResponse> {
|
||||
const { data } = await apiClient.post<HintResponse>(`/games/${id}/hint`, {});
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
+11
-15
@@ -80,15 +80,15 @@ export async function getUserMatchHistory(page = 1, limit = 20) {
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Sends the session to score from; the server will not accept a score. */
|
||||
export async function submitCreatorScore(
|
||||
matchId: string,
|
||||
score: object,
|
||||
chainLength: number,
|
||||
sessionId: string,
|
||||
): Promise<VersusMatch> {
|
||||
const { data } = await apiClient.post<VersusMatch>(`/versus/${matchId}/creator-score`, {
|
||||
score,
|
||||
chainLength,
|
||||
});
|
||||
const { data } = await apiClient.post<VersusMatch>(
|
||||
`/versus/${matchId}/creator-score`,
|
||||
{ sessionId },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -114,15 +114,11 @@ export async function startAsyncAttempt(
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function submitAsyncAttempt(
|
||||
matchId: string,
|
||||
score: object,
|
||||
chainLength: number,
|
||||
) {
|
||||
const { data } = await apiClient.post(`/versus/${matchId}/async-attempt/submit`, {
|
||||
score,
|
||||
chainLength,
|
||||
});
|
||||
export async function submitAsyncAttempt(matchId: string, sessionId: string) {
|
||||
const { data } = await apiClient.post(
|
||||
`/versus/${matchId}/async-attempt/submit`,
|
||||
{ sessionId },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ export default function GameNightCompletionModal() {
|
||||
const finishedPlayers = useGameNightStore((s) => s.finishedPlayers);
|
||||
const forfeitedPlayers = useGameNightStore((s) => s.forfeitedPlayers);
|
||||
const roomId = useGameNightStore((s) => s.roomId);
|
||||
const leaveRoom = useGameNightStore((s) => s.leaveRoom);
|
||||
const reset = useGameNightStore((s) => s.reset);
|
||||
|
||||
const isOpen = status === 'completed' || lobbyState === 'finished';
|
||||
|
||||
@@ -34,6 +36,14 @@ export default function GameNightCompletionModal() {
|
||||
}
|
||||
};
|
||||
|
||||
// Players who stall or drop must not strand everyone else in a modal whose
|
||||
// only control appears once the room reaches 'finished'.
|
||||
const handleLeave = () => {
|
||||
leaveRoom();
|
||||
reset();
|
||||
navigate('/game-night');
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
@@ -82,6 +92,15 @@ export default function GameNightCompletionModal() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={handleLeave}
|
||||
>
|
||||
Leave game
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -8,8 +8,31 @@ const CONFETTI_COLORS = [
|
||||
|
||||
const CONFETTI_COUNT = 20;
|
||||
|
||||
interface ConfettiPiece {
|
||||
color: string;
|
||||
left: number;
|
||||
delay: number;
|
||||
duration: number;
|
||||
size: number;
|
||||
round: boolean;
|
||||
}
|
||||
|
||||
function makeConfetti(): ConfettiPiece[] {
|
||||
return Array.from({ length: CONFETTI_COUNT }, () => ({
|
||||
color: CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
|
||||
left: Math.random() * 100,
|
||||
delay: Math.random() * 1,
|
||||
duration: 2 + Math.random() * 1.5,
|
||||
size: 6 + Math.random() * 6,
|
||||
round: Math.random() > 0.5,
|
||||
}));
|
||||
}
|
||||
|
||||
export default function CelebrationOverlay() {
|
||||
const [visible, setVisible] = useState(true);
|
||||
// Rolled once per mount. Randomising in the render body re-rolled every piece
|
||||
// on each re-render, so confetti visibly jumped mid-flight.
|
||||
const [pieces] = useState(makeConfetti);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setVisible(false), 3000);
|
||||
@@ -20,31 +43,22 @@ export default function CelebrationOverlay() {
|
||||
|
||||
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`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{pieces.map((piece, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute animate-confetti"
|
||||
style={{
|
||||
left: `${piece.left}%`,
|
||||
top: '-10px',
|
||||
width: `${piece.size}px`,
|
||||
height: `${piece.size}px`,
|
||||
backgroundColor: piece.color,
|
||||
borderRadius: piece.round ? '50%' : '2px',
|
||||
animationDelay: `${piece.delay}s`,
|
||||
animationDuration: `${piece.duration}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -36,25 +36,39 @@ export default function GameCompletionModal() {
|
||||
const validationError = useGameStore((s) => s.validationError);
|
||||
const resetGame = useGameStore((s) => s.resetGame);
|
||||
const navigate = useNavigate();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [prevStatus, setPrevStatus] = useState(status);
|
||||
const celebrated = useRef(false);
|
||||
|
||||
// Derived from status rather than mirrored into state by an effect, with
|
||||
// dismissal reset as soon as the game leaves the completed state.
|
||||
if (status !== prevStatus) {
|
||||
setPrevStatus(status);
|
||||
if (status !== 'completed') setDismissed(false);
|
||||
}
|
||||
const showModal = status === 'completed' && !dismissed;
|
||||
|
||||
const gameMode: GameMode = isDailyChallenge && dailyChallengeDate
|
||||
? { type: 'daily', date: dailyChallengeDate }
|
||||
: { type: 'freeplay' };
|
||||
|
||||
// Side effects only — fired once per completion, guarded by a ref so a
|
||||
// re-render or dependency change cannot replay the sound or the write.
|
||||
useEffect(() => {
|
||||
if (status === 'completed') {
|
||||
setShowModal(true);
|
||||
playSound('completion');
|
||||
|
||||
// Mark daily challenge as completed in localStorage (fallback for anonymous users)
|
||||
if (isDailyChallenge && dailyChallengeDate) {
|
||||
storage.setDailyCompleted(dailyChallengeDate, dailyChallengeDifficulty ?? undefined);
|
||||
}
|
||||
} else {
|
||||
setShowModal(false);
|
||||
if (status !== 'completed') {
|
||||
celebrated.current = false;
|
||||
return;
|
||||
}
|
||||
}, [status]);
|
||||
if (celebrated.current) return;
|
||||
celebrated.current = true;
|
||||
|
||||
playSound('completion');
|
||||
|
||||
// Mark daily challenge as completed in localStorage (fallback for anonymous users)
|
||||
if (isDailyChallenge && dailyChallengeDate) {
|
||||
storage.setDailyCompleted(dailyChallengeDate, dailyChallengeDifficulty ?? undefined);
|
||||
}
|
||||
}, [status, isDailyChallenge, dailyChallengeDate, dailyChallengeDifficulty]);
|
||||
|
||||
const handlePlayAgain = () => {
|
||||
resetGame();
|
||||
@@ -62,13 +76,13 @@ export default function GameCompletionModal() {
|
||||
};
|
||||
|
||||
const handleViewChain = () => {
|
||||
setShowModal(false);
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{status === 'completed' && <CelebrationOverlay />}
|
||||
<Dialog open={showModal} onOpenChange={(open) => !open && setShowModal(false)}>
|
||||
<Dialog open={showModal} onOpenChange={(open) => !open && setDismissed(true)}>
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
showCloseButton={false}
|
||||
|
||||
@@ -28,8 +28,6 @@ export default function SearchAutocomplete({
|
||||
}: 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);
|
||||
@@ -38,67 +36,81 @@ export default function SearchAutocomplete({
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listId = `search-listbox-${mode}`;
|
||||
|
||||
const results = mode === 'actor' ? personResults : movieResults;
|
||||
// A query too short to search is a derived condition. Clearing results in an
|
||||
// effect meant an extra committed render on every keystroke below the
|
||||
// threshold, and left the stored results and the displayed ones out of step.
|
||||
const isSearchable = debouncedQuery.trim().length >= 2;
|
||||
const results = isSearchable
|
||||
? mode === 'actor'
|
||||
? personResults
|
||||
: movieResults
|
||||
: [];
|
||||
|
||||
// Loading and error are derived from which search has settled, so neither is
|
||||
// set synchronously inside the effect.
|
||||
const searchKey = `${mode}:${debouncedQuery}`;
|
||||
const [settled, setSettled] = useState<{
|
||||
key: string;
|
||||
error: string | null;
|
||||
} | null>(null);
|
||||
const isLoading = isSearchable && settled?.key !== searchKey;
|
||||
const searchError = settled?.key === searchKey ? settled.error : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!debouncedQuery || debouncedQuery.length < 2) {
|
||||
setMovieResults([]);
|
||||
setPersonResults([]);
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!isSearchable) return;
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
setSearchError(null);
|
||||
|
||||
const doSearch = async () => {
|
||||
void (async () => {
|
||||
let error: string | null = null;
|
||||
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);
|
||||
}
|
||||
if (cancelled) return;
|
||||
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);
|
||||
}
|
||||
if (cancelled) return;
|
||||
setMovieResults(data.results);
|
||||
setPersonResults([]);
|
||||
setIsOpen(true);
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setSearchError('Search failed. Please try again.');
|
||||
setIsOpen(true);
|
||||
}
|
||||
if (cancelled) return;
|
||||
error = 'Search failed. Please try again.';
|
||||
setIsOpen(true);
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
if (!cancelled) setSettled({ key: searchKey, error });
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
doSearch();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [debouncedQuery, mode]);
|
||||
}, [debouncedQuery, mode, isSearchable, searchKey]);
|
||||
|
||||
// Reset when mode changes
|
||||
useEffect(() => {
|
||||
// Reset when the mode flips between actor and movie. Adjusting state during
|
||||
// render is React's documented pattern for reacting to a changed prop; doing
|
||||
// it in an effect costs an extra committed render, and remounting via a
|
||||
// `key` would drop focus in the middle of a chain.
|
||||
const [prevMode, setPrevMode] = useState(mode);
|
||||
if (mode !== prevMode) {
|
||||
setPrevMode(mode);
|
||||
setQuery('');
|
||||
setMovieResults([]);
|
||||
setPersonResults([]);
|
||||
setIsOpen(false);
|
||||
setActiveIndex(-1);
|
||||
// Auto-focus on mode change
|
||||
}
|
||||
|
||||
// Focus is a DOM side effect, so it stays in an effect.
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, [mode]);
|
||||
|
||||
|
||||
@@ -7,15 +7,21 @@ export default function ValidationFeedback() {
|
||||
const validationError = useGameStore((s) => s.validationError);
|
||||
const chain = useGameStore((s) => s.chain);
|
||||
const [showCheck, setShowCheck] = useState(false);
|
||||
const [flashedLength, setFlashedLength] = useState(0);
|
||||
|
||||
// Flash a green check when the chain grows (successful validation). Adjusting
|
||||
// state during render is React's documented pattern for reacting to a changed
|
||||
// value; doing it in an effect costs an extra committed render each link.
|
||||
if (chain.length > 1 && chain.length !== flashedLength) {
|
||||
setFlashedLength(chain.length);
|
||||
setShowCheck(true);
|
||||
}
|
||||
|
||||
// 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 (!showCheck) return;
|
||||
const timer = setTimeout(() => setShowCheck(false), 600);
|
||||
return () => clearTimeout(timer);
|
||||
}, [showCheck]);
|
||||
|
||||
if (isValidating) {
|
||||
return (
|
||||
|
||||
@@ -49,5 +49,4 @@ function Badge({
|
||||
})
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Badge, badgeVariants }
|
||||
export { Badge }
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -1,45 +1,10 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { buttonVariants } from "./button-variants"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
@@ -55,5 +20,4 @@ function Button({
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Button, buttonVariants }
|
||||
export { Button }
|
||||
@@ -1,24 +1,5 @@
|
||||
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',
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function getDifficultyButtonClass(difficulty: Difficulty, isActive: boolean): string {
|
||||
if (!isActive) return '';
|
||||
return DIFFICULTY_BUTTON_ACTIVE[difficulty];
|
||||
}
|
||||
import { DIFFICULTY_STYLES, type Difficulty } from './difficulty-styles';
|
||||
|
||||
export function DifficultyBadge({
|
||||
difficulty,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export type Difficulty = 'easy' | 'medium' | 'hard';
|
||||
|
||||
export 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];
|
||||
}
|
||||
@@ -45,6 +45,11 @@ export default function VersusCompletionModal() {
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Waiting for opponent to finish...</span>
|
||||
</div>
|
||||
{/* An opponent who never finishes must not strand this player in
|
||||
a modal with no controls. */}
|
||||
<Button variant="outline" size="sm" onClick={handleBackToVersus}>
|
||||
Back to Versus
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : matchResult ? (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { toast } from 'sonner';
|
||||
import { useCallback } from 'react';
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import { validateLink } from '@/api/games';
|
||||
@@ -16,13 +17,14 @@ import type {
|
||||
* When a backend session exists, validation is delegated to the server.
|
||||
*/
|
||||
export function useChainValidation() {
|
||||
const {
|
||||
chain,
|
||||
addActorToChain,
|
||||
addMovieToChain,
|
||||
setValidating,
|
||||
setValidationError,
|
||||
} = useGameStore();
|
||||
// Selected individually rather than destructured off the whole store: the
|
||||
// unselected form re-rendered every consumer on any state change, including
|
||||
// each keystroke-driven isValidating flip. These are stable action refs, so
|
||||
// the callbacks below now keep a stable identity too.
|
||||
const addActorToChain = useGameStore((s) => s.addActorToChain);
|
||||
const addMovieToChain = useGameStore((s) => s.addMovieToChain);
|
||||
const setValidating = useGameStore((s) => s.setValidating);
|
||||
const setValidationError = useGameStore((s) => s.setValidationError);
|
||||
|
||||
const validateAndAddActor = useCallback(
|
||||
async (person: TmdbPersonResult) => {
|
||||
@@ -35,11 +37,12 @@ export function useChainValidation() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Quick client-side duplicate check (fast feedback)
|
||||
// Duplicates are allowed by design and charged at scoring time, so this
|
||||
// is a heads-up rather than a rejection.
|
||||
if (currentChain.some((link) => link.type === 'actor' && link.id === person.id)) {
|
||||
setValidationError(`${person.name} is already in your chain.`);
|
||||
playSound('invalid');
|
||||
return;
|
||||
toast.warning(`${person.name} is already in your chain`, {
|
||||
description: 'Reusing a link costs you points.',
|
||||
});
|
||||
}
|
||||
|
||||
setValidating(true);
|
||||
@@ -86,7 +89,7 @@ export function useChainValidation() {
|
||||
setValidating(false);
|
||||
}
|
||||
},
|
||||
[chain, addActorToChain, setValidating, setValidationError],
|
||||
[addActorToChain, setValidating, setValidationError],
|
||||
);
|
||||
|
||||
const validateAndAddMovie = useCallback(
|
||||
@@ -103,16 +106,14 @@ export function useChainValidation() {
|
||||
|
||||
// Quick client-side duplicate / loop-closure checks
|
||||
const isMovieA = movie.id === movieA?.id;
|
||||
const isMovieB = movie.id === movieB?.id;
|
||||
|
||||
if (
|
||||
!isMovieA &&
|
||||
!isMovieB &&
|
||||
currentChain.some((link) => link.type === 'movie' && link.id === movie.id)
|
||||
) {
|
||||
setValidationError(`"${movie.title}" is already in your chain.`);
|
||||
playSound('invalid');
|
||||
return;
|
||||
toast.warning(`"${movie.title}" is already in your chain`, {
|
||||
description: 'Reusing a link costs you points.',
|
||||
});
|
||||
}
|
||||
|
||||
if (isMovieA && !passedMovieB) {
|
||||
@@ -169,7 +170,7 @@ export function useChainValidation() {
|
||||
setValidating(false);
|
||||
}
|
||||
},
|
||||
[chain, addMovieToChain, setValidating, setValidationError],
|
||||
[addMovieToChain, setValidating, setValidationError],
|
||||
);
|
||||
|
||||
return { validateAndAddActor, validateAndAddMovie };
|
||||
|
||||
+30
-20
@@ -1,19 +1,21 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import { getMovieCredits } from '@/api/movies';
|
||||
import { useHint as requestHint } from '@/api/games';
|
||||
import type { TmdbCastMember } from '@/types';
|
||||
|
||||
const MAX_HINTS = 3;
|
||||
const HINTS_PER_REQUEST = 3;
|
||||
|
||||
export function useHints() {
|
||||
const [hints, setHints] = useState<TmdbCastMember[]>([]);
|
||||
const [hintsForChainLength, setHintsForChainLength] = useState(-1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
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 setHintsUsed = useGameStore((s) => s.setHintsUsed);
|
||||
const sessionId = useGameStore((s) => s.sessionId);
|
||||
const status = useGameStore((s) => s.status);
|
||||
|
||||
// Can only hint when looking for an actor after a movie
|
||||
@@ -23,39 +25,47 @@ export function useHints() {
|
||||
currentSearchMode === 'actor' &&
|
||||
lastLink?.type === 'movie' &&
|
||||
hintsUsed < MAX_HINTS &&
|
||||
!!sessionId &&
|
||||
!isLoading;
|
||||
|
||||
const getHint = useCallback(async () => {
|
||||
if (!canHint || lastLink?.type !== 'movie') return;
|
||||
if (!canHint || !sessionId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
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),
|
||||
// The server picks the actors and records the hint. Doing this in the
|
||||
// browser meant the hint count - and so the hint penalty - was whatever
|
||||
// the client chose to report at completion time.
|
||||
const result = await requestHint(sessionId);
|
||||
setHints(
|
||||
result.actors.map((actor) => ({
|
||||
id: actor.id,
|
||||
name: actor.name,
|
||||
profile_path: actor.profilePath,
|
||||
popularity: actor.popularity,
|
||||
})) as TmdbCastMember[],
|
||||
);
|
||||
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();
|
||||
setHintsUsed(result.hintsUsed);
|
||||
setHintsForChainLength(chain.length);
|
||||
} catch {
|
||||
// silently fail
|
||||
setError('Could not fetch a hint. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [canHint, lastLink, chain, incrementHintsUsed]);
|
||||
}, [canHint, sessionId, setHintsUsed, chain.length]);
|
||||
|
||||
const clearHints = useCallback(() => setHints([]), []);
|
||||
|
||||
// Hints belong to the movie they were fetched for. Deriving visibility means
|
||||
// they disappear as soon as the chain moves on, without an effect that
|
||||
// clears state and forces a second render.
|
||||
const visibleHints = hintsForChainLength === chain.length ? hints : [];
|
||||
|
||||
return {
|
||||
hints,
|
||||
hints: visibleHints,
|
||||
isLoading,
|
||||
error,
|
||||
getHint,
|
||||
clearHints,
|
||||
canHint,
|
||||
|
||||
+14
-7
@@ -15,18 +15,25 @@ function formatTime(seconds: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function elapsedFrom(startTime: number | null): number {
|
||||
if (!startTime) return 0;
|
||||
return Math.max(0, Math.floor((Date.now() - startTime) / 1000));
|
||||
}
|
||||
|
||||
export function useTimer(startTime: number | null, isRunning: boolean) {
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
// Seeded lazily so the first render is already correct; the interval then
|
||||
// recomputes from Date.now() each tick, so it cannot drift.
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(() =>
|
||||
elapsedFrom(startTime),
|
||||
);
|
||||
|
||||
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);
|
||||
const interval = setInterval(
|
||||
() => setElapsedSeconds(elapsedFrom(startTime)),
|
||||
1000,
|
||||
);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [startTime, isRunning]);
|
||||
|
||||
+18
-7
@@ -399,19 +399,30 @@ function GenerateChallengeSection() {
|
||||
|
||||
function UsersSection() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [loadedPage, setLoadedPage] = useState<number | null>(null);
|
||||
// Derived rather than a flag flipped inside the effect: a page change puts
|
||||
// the list back into its loading state on the same render that requests it.
|
||||
const loading = loadedPage !== page;
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
listUsers(page, 20)
|
||||
.then((res) => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await listUsers(page, 20);
|
||||
if (cancelled) return;
|
||||
setUsers(res.data);
|
||||
setTotalPages(res.totalPages);
|
||||
})
|
||||
.catch(() => setUsers([]))
|
||||
.finally(() => setLoading(false));
|
||||
} catch {
|
||||
if (!cancelled) setUsers([]);
|
||||
} finally {
|
||||
if (!cancelled) setLoadedPage(page);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [page]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -29,16 +29,28 @@ export default function AsyncMatchLeaderboard() {
|
||||
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);
|
||||
const [loadedMatchId, setLoadedMatchId] = useState<string | null>(null);
|
||||
const loading = loadedMatchId !== (matchId ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!matchId) return;
|
||||
setLoading(true);
|
||||
getAsyncLeaderboard(matchId)
|
||||
.then(setData)
|
||||
.catch((err) => setError(err?.response?.data?.message ?? 'Failed to load'))
|
||||
.finally(() => setLoading(false));
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await getAsyncLeaderboard(matchId);
|
||||
if (!cancelled) setData(res);
|
||||
} catch (err: unknown) {
|
||||
const message = (err as { response?: { data?: { message?: string } } })
|
||||
?.response?.data?.message;
|
||||
if (!cancelled) setError(message ?? 'Failed to load');
|
||||
} finally {
|
||||
if (!cancelled) setLoadedMatchId(matchId);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [matchId]);
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
@@ -39,6 +39,7 @@ export default function AsyncVersusGame() {
|
||||
const movieB = useGameStore((s) => s.movieB);
|
||||
const isValidating = useGameStore((s) => s.isValidating);
|
||||
const score = useGameStore((s) => s.score);
|
||||
const sessionId = useGameStore((s) => s.sessionId);
|
||||
const undoLastLink = useGameStore((s) => s.undoLastLink);
|
||||
const startGameFromVersus = useGameStore((s) => s.startGameFromVersus);
|
||||
const resetGame = useGameStore((s) => s.resetGame);
|
||||
@@ -48,7 +49,9 @@ export default function AsyncVersusGame() {
|
||||
const [mode, setMode] = useState<AsyncMode>('creator');
|
||||
const [targetChainLength, setTargetChainLength] = useState<number | null>(null);
|
||||
const [showCompletion, setShowCompletion] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
// A ref, not state: this only guards re-entry, and setting state here made
|
||||
// the effect re-run itself.
|
||||
const submittingRef = useRef(false);
|
||||
const [creatorScore, setCreatorScore] = useState<{ totalScore: number } | null>(null);
|
||||
const [myRank, setMyRank] = useState<number | undefined>();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -77,25 +80,35 @@ export default function AsyncVersusGame() {
|
||||
return;
|
||||
}
|
||||
setMode('creator');
|
||||
await startGameFromVersus(
|
||||
const started = await startGameFromVersus(
|
||||
{
|
||||
movieA: { id: match.movieAId, title: match.movieATitle! },
|
||||
movieB: { id: match.movieBId, title: match.movieBTitle! },
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
if (started === 'superseded') return;
|
||||
if (started === 'failed') {
|
||||
setError('Could not start this match. Please try again.');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Challenger mode
|
||||
const result = await startAsyncAttempt(matchId!);
|
||||
setMode('challenger');
|
||||
setTargetChainLength(result.chainLength);
|
||||
await startGameFromVersus(
|
||||
const started = await startGameFromVersus(
|
||||
{
|
||||
movieA: { id: result.movieAId, title: result.movieATitle },
|
||||
movieB: { id: result.movieBId, title: result.movieBTitle },
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
if (started === 'superseded') return;
|
||||
if (started === 'failed') {
|
||||
setError('Could not start this match. Please try again.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
@@ -111,21 +124,31 @@ export default function AsyncVersusGame() {
|
||||
return () => {
|
||||
resetGame();
|
||||
};
|
||||
}, [matchId, user, authLoading, navigate]);
|
||||
}, [matchId, user, authLoading, navigate, resetGame, startGameFromVersus]);
|
||||
|
||||
// Handle game completion
|
||||
useEffect(() => {
|
||||
if (status !== 'completed' || !score || !matchId || submitting || showCompletion) return;
|
||||
// `error` participates so a failed submission is not retried on every
|
||||
// subsequent render — it previously fired ~20 identical failing requests.
|
||||
if (
|
||||
status !== 'completed' ||
|
||||
!score ||
|
||||
!matchId ||
|
||||
!sessionId ||
|
||||
submittingRef.current ||
|
||||
showCompletion ||
|
||||
error
|
||||
)
|
||||
return;
|
||||
|
||||
setSubmitting(true);
|
||||
const chainLength = chain.length;
|
||||
submittingRef.current = true;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
if (mode === 'creator') {
|
||||
await submitCreatorScore(matchId, score, chainLength);
|
||||
await submitCreatorScore(matchId, sessionId);
|
||||
} else {
|
||||
await submitAsyncAttempt(matchId, score, chainLength);
|
||||
await submitAsyncAttempt(matchId, sessionId);
|
||||
// Fetch leaderboard to get rank and creator score
|
||||
try {
|
||||
const lb = await getAsyncLeaderboard(matchId);
|
||||
@@ -143,10 +166,19 @@ export default function AsyncVersusGame() {
|
||||
} catch {
|
||||
setError('Failed to submit score');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
submittingRef.current = false;
|
||||
}
|
||||
})();
|
||||
}, [status, score, matchId, mode, submitting, showCompletion]);
|
||||
}, [
|
||||
status,
|
||||
score,
|
||||
matchId,
|
||||
mode,
|
||||
showCompletion,
|
||||
sessionId,
|
||||
user?.id,
|
||||
error,
|
||||
]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
|
||||
+8
-4
@@ -62,10 +62,6 @@ export default function Chat() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (friends.length === 0) {
|
||||
fetchFriends();
|
||||
}
|
||||
|
||||
if (friendId) {
|
||||
connect();
|
||||
openConversation(friendId);
|
||||
@@ -77,6 +73,14 @@ export default function Chat() {
|
||||
};
|
||||
}, [user, authLoading, friendId, navigate, connect, disconnect, openConversation, clearChat]);
|
||||
|
||||
// Kept separate from the socket lifecycle above: folding friends.length into
|
||||
// that effect's deps would tear down and rebuild the socket on every change
|
||||
// to the friend list.
|
||||
useEffect(() => {
|
||||
if (authLoading || !user) return;
|
||||
if (friends.length === 0) fetchFriends();
|
||||
}, [authLoading, user, friends.length, fetchFriends]);
|
||||
|
||||
// Scroll to bottom on new messages if user hasn't scrolled up
|
||||
useEffect(() => {
|
||||
if (isAtBottom.current) {
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 { getDifficultyButtonClass } from '@/components/ui/difficulty-styles';
|
||||
import { Loader2, Shuffle, Filter } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
||||
+21
-15
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -32,7 +32,9 @@ export default function GameNight() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [tab, setTab] = useState<'browse' | 'create'>('browse');
|
||||
const [openRooms, setOpenRooms] = useState<GameNightRoom[]>([]);
|
||||
const [loadingRooms, setLoadingRooms] = useState(false);
|
||||
const [refreshToken, setRefreshToken] = useState(0);
|
||||
const [settledToken, setSettledToken] = useState(-1);
|
||||
const loadingRooms = settledToken !== refreshToken;
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Create form state
|
||||
@@ -49,20 +51,24 @@ export default function GameNight() {
|
||||
const [joinPassword, setJoinPassword] = useState('');
|
||||
const [joinError, setJoinError] = useState('');
|
||||
|
||||
const fetchRooms = async () => {
|
||||
setLoadingRooms(true);
|
||||
try {
|
||||
const rooms = await getOpenRooms();
|
||||
setOpenRooms(rooms);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setLoadingRooms(false);
|
||||
};
|
||||
const refreshRooms = useCallback(() => setRefreshToken((t) => t + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRooms();
|
||||
}, []);
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const rooms = await getOpenRooms();
|
||||
if (!cancelled) setOpenRooms(rooms);
|
||||
} catch {
|
||||
// Leave the list as-is; the empty state below covers it.
|
||||
} finally {
|
||||
if (!cancelled) setSettledToken(refreshToken);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refreshToken]);
|
||||
|
||||
const handleSelectMovieA = (movie: TmdbMovieResult) => {
|
||||
setMovieA({
|
||||
@@ -194,7 +200,7 @@ export default function GameNight() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={fetchRooms}
|
||||
onClick={refreshRooms}
|
||||
disabled={loadingRooms}
|
||||
>
|
||||
<RefreshCw
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
@@ -44,10 +45,15 @@ export default function GameNightLobby() {
|
||||
movieB: MoviePair['movieB'];
|
||||
startTime: number;
|
||||
}) => {
|
||||
await startGameFromVersus(
|
||||
const result = await startGameFromVersus(
|
||||
{ movieA: data.movieA, movieB: data.movieB },
|
||||
data.startTime,
|
||||
);
|
||||
if (result === 'superseded') return;
|
||||
if (result === 'failed') {
|
||||
toast.error('Could not start the game. Please try again.');
|
||||
return;
|
||||
}
|
||||
navigate('/game-night/play');
|
||||
},
|
||||
[startGameFromVersus, navigate],
|
||||
|
||||
@@ -30,6 +30,7 @@ export default function GameNightPlay() {
|
||||
|
||||
const sendChainUpdate = useGameNightStore((s) => s.sendChainUpdate);
|
||||
const sendPlayerComplete = useGameNightStore((s) => s.sendPlayerComplete);
|
||||
const sessionId = useGameStore((s) => s.sessionId);
|
||||
const sendPlayerForfeit = useGameNightStore((s) => s.sendPlayerForfeit);
|
||||
const isSpectator = useGameNightStore((s) => s.isSpectator);
|
||||
const lobbyState = useGameNightStore((s) => s.lobbyState);
|
||||
@@ -44,11 +45,10 @@ export default function GameNightPlay() {
|
||||
|
||||
// Send completion when game completes
|
||||
useEffect(() => {
|
||||
if (status === 'completed' && score && !isSpectator) {
|
||||
const linkCount = Math.floor((chain.length - 1) / 2);
|
||||
sendPlayerComplete(score, linkCount);
|
||||
if (status === 'completed' && score && sessionId && !isSpectator) {
|
||||
sendPlayerComplete(sessionId);
|
||||
}
|
||||
}, [status, score, chain.length, sendPlayerComplete, isSpectator]);
|
||||
}, [status, score, sessionId, sendPlayerComplete, isSpectator]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'idle') {
|
||||
|
||||
@@ -20,17 +20,26 @@ export default function GameNightResults() {
|
||||
const [results, setResults] = useState<GameNightResultsType | null>(
|
||||
storeResults,
|
||||
);
|
||||
const [loading, setLoading] = useState(!storeResults);
|
||||
const [tab, setTab] = useState<RankingTab>('score');
|
||||
const [fetchSettled, setFetchSettled] = useState(false);
|
||||
const loading = !results && !fetchSettled;
|
||||
|
||||
useEffect(() => {
|
||||
if (!results && roomId) {
|
||||
setLoading(true);
|
||||
getRoomResults(roomId)
|
||||
.then(setResults)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
if (results || !roomId) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await getRoomResults(roomId);
|
||||
if (!cancelled) setResults(res);
|
||||
} catch {
|
||||
// Leave results null — the empty state below covers it.
|
||||
} finally {
|
||||
if (!cancelled) setFetchSettled(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [roomId, results]);
|
||||
|
||||
const handlePlayAgain = () => {
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
import { useNavigate, Link } from 'react-router';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { buttonVariants } from '@/components/ui/button-variants';
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import { discoverMovies } from '@/api/movies';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
|
||||
+23
-16
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -44,25 +44,32 @@ export default function Leaderboard() {
|
||||
const [period, setPeriod] = useState<Period>('daily');
|
||||
const [difficulty, setDifficulty] = useState<DifficultyFilter>('all');
|
||||
const [data, setData] = useState<LeaderboardResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [settledKey, setSettledKey] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const diffParam = difficulty === 'all' ? undefined : difficulty;
|
||||
const result = await getLeaderboard(period, page, 20, diffParam);
|
||||
setData(result);
|
||||
} catch {
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [period, page, difficulty]);
|
||||
// Derived rather than a flag set inside the effect: changing period, page or
|
||||
// difficulty puts the table back into its loading state on the same render
|
||||
// that requests it, instead of costing an extra committed render.
|
||||
const requestKey = `${period}:${page}:${difficulty}`;
|
||||
const loading = settledKey !== requestKey;
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const diffParam = difficulty === 'all' ? undefined : difficulty;
|
||||
const result = await getLeaderboard(period, page, 20, diffParam);
|
||||
if (!cancelled) setData(result);
|
||||
} catch {
|
||||
if (!cancelled) setData(null);
|
||||
} finally {
|
||||
if (!cancelled) setSettledKey(requestKey);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [period, page, difficulty, requestKey]);
|
||||
|
||||
const handlePeriodChange = (p: Period) => {
|
||||
setPeriod(p);
|
||||
|
||||
+46
-21
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { buttonVariants } from '@/components/ui/button-variants';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -22,15 +23,23 @@ function formatTime(seconds: number) {
|
||||
|
||||
export default function Profile() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
// Depend on the id, not the user object: loadUser() rebuilds that object
|
||||
// literal on every call, which would otherwise re-trigger the fetches below.
|
||||
const userId = user?.id;
|
||||
const authLoading = useAuthStore((s) => s.isLoading);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const loadUser = useAuthStore((s) => s.loadUser);
|
||||
const navigate = useNavigate();
|
||||
const [stats, setStats] = useState<UserStats | null>(null);
|
||||
const [gameHistory, setGameHistory] = useState<GameHistoryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statsMode, setStatsMode] = useState<'all' | 'daily'>('all');
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
const [historySettled, setHistorySettled] = useState(false);
|
||||
const [statsSettledMode, setStatsSettledMode] = useState<string | null>(null);
|
||||
// The page skeleton covers the first load of both requests; the dimmed-tile
|
||||
// state only applies to a later mode switch.
|
||||
const loading = !historySettled || statsSettledMode === null;
|
||||
const statsLoading =
|
||||
statsSettledMode !== null && statsSettledMode !== statsMode;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [changingPassword, setChangingPassword] = useState(false);
|
||||
@@ -40,31 +49,47 @@ export default function Profile() {
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [passwordSuccess, setPasswordSuccess] = useState('');
|
||||
|
||||
// Game history is independent of statsMode, so it is fetched once.
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!user) {
|
||||
if (!userId) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
Promise.all([
|
||||
getUserStats(statsMode).catch(() => null),
|
||||
getGameHistory(1, 10).catch(() => null),
|
||||
])
|
||||
.then(([s, h]) => {
|
||||
setStats(s);
|
||||
if (h) setGameHistory(h.data);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [user, authLoading, navigate]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const h = await getGameHistory(1, 10);
|
||||
if (!cancelled && h) setGameHistory(h.data);
|
||||
} catch {
|
||||
// Leave history empty; the empty state below covers it.
|
||||
} finally {
|
||||
if (!cancelled) setHistorySettled(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userId, authLoading, navigate]);
|
||||
|
||||
// Stats re-fetch on first load and on every mode switch.
|
||||
useEffect(() => {
|
||||
if (!user || loading) return;
|
||||
setStatsLoading(true);
|
||||
getUserStats(statsMode)
|
||||
.then((s) => setStats(s))
|
||||
.catch(() => {})
|
||||
.finally(() => setStatsLoading(false));
|
||||
}, [statsMode]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
if (authLoading || !userId) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const s = await getUserStats(statsMode);
|
||||
if (!cancelled) setStats(s);
|
||||
} catch {
|
||||
if (!cancelled) setStats(null);
|
||||
} finally {
|
||||
if (!cancelled) setStatsSettledMode(statsMode);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userId, authLoading, statsMode]);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
|
||||
+38
-23
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, 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 { DifficultyBadge } from '@/components/ui/difficulty-badge';
|
||||
import { getDifficultyButtonClass } from '@/components/ui/difficulty-styles';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
@@ -57,7 +58,8 @@ export default function Versus() {
|
||||
const navigate = useNavigate();
|
||||
const [matches, setMatches] = useState<VersusMatch[]>([]);
|
||||
const [myMatches, setMyMatches] = useState<VersusMatch[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshToken, setRefreshToken] = useState(0);
|
||||
const [settledKey, setSettledKey] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [joining, setJoining] = useState<string | null>(null);
|
||||
const [cancelling, setCancelling] = useState<string | null>(null);
|
||||
@@ -73,22 +75,15 @@ export default function Versus() {
|
||||
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 userId = user?.id;
|
||||
// One key covers both triggers: changing the filter and pressing refresh each
|
||||
// produce a new key, so `loading` flips true on the same render as the request.
|
||||
const requestKey = `${filterMode}:${refreshToken}`;
|
||||
const loading = settledKey !== requestKey;
|
||||
|
||||
// A manual refresh bumps this token; the effect below owns the actual fetch,
|
||||
// so nothing sets loading state synchronously from inside an effect.
|
||||
const refreshMatches = useCallback(() => setRefreshToken((t) => t + 1), []);
|
||||
|
||||
const handleCancel = async (matchId: string) => {
|
||||
setCancelling(matchId);
|
||||
@@ -104,12 +99,32 @@ export default function Versus() {
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!user) {
|
||||
if (!userId) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
fetchMatches();
|
||||
}, [user, authLoading, navigate, filterMode]);
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const [open, mine] = await Promise.all([
|
||||
getWaitingMatches(userId, filterMode === 'all' ? undefined : filterMode),
|
||||
getMyWaitingMatches(),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setMatches(open);
|
||||
setMyMatches(mine);
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setMatches([]);
|
||||
setMyMatches([]);
|
||||
} finally {
|
||||
if (!cancelled) setSettledKey(requestKey);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userId, authLoading, navigate, filterMode, requestKey]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setCreating(true);
|
||||
@@ -365,7 +380,7 @@ export default function Versus() {
|
||||
<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}>
|
||||
<Button variant="ghost" size="sm" onClick={refreshMatches}>
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
@@ -29,6 +29,7 @@ export default function VersusGame() {
|
||||
const { validateAndAddActor, validateAndAddMovie } = useChainValidation();
|
||||
const sendChainUpdate = useVersusStore((s) => s.sendChainUpdate);
|
||||
const sendMatchComplete = useVersusStore((s) => s.sendMatchComplete);
|
||||
const sessionId = useGameStore((s) => s.sessionId);
|
||||
|
||||
// Broadcast chain updates to opponent
|
||||
useEffect(() => {
|
||||
@@ -39,10 +40,10 @@ export default function VersusGame() {
|
||||
|
||||
// Send match completion when game completes
|
||||
useEffect(() => {
|
||||
if (status === 'completed' && score) {
|
||||
sendMatchComplete(score);
|
||||
if (status === 'completed' && score && sessionId) {
|
||||
sendMatchComplete(sessionId, score);
|
||||
}
|
||||
}, [status, score, sendMatchComplete]);
|
||||
}, [status, score, sessionId, sendMatchComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'idle') {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
@@ -32,10 +33,18 @@ export default function VersusLobby() {
|
||||
|
||||
const handleGameStart = useCallback(
|
||||
async (data: { movieA: MoviePair['movieA']; movieB: MoviePair['movieB']; startTime: number }) => {
|
||||
await startGameFromVersus(
|
||||
const result = await startGameFromVersus(
|
||||
{ movieA: data.movieA, movieB: data.movieB },
|
||||
data.startTime,
|
||||
);
|
||||
// A newer start is already in charge; saying anything here would be wrong.
|
||||
if (result === 'superseded') return;
|
||||
// Navigating on a failed start used to bounce straight back here via
|
||||
// VersusGame's idle guard, with nothing explaining why.
|
||||
if (result === 'failed') {
|
||||
toast.error('Could not start the match. Please try again.');
|
||||
return;
|
||||
}
|
||||
navigate('/versus/game');
|
||||
},
|
||||
[startGameFromVersus, navigate],
|
||||
|
||||
@@ -32,7 +32,7 @@ interface GameNightState {
|
||||
toggleReady: () => void;
|
||||
startCountdown: () => void;
|
||||
sendChainUpdate: (chainLength: number) => void;
|
||||
sendPlayerComplete: (score: ScoreBreakdown, chainLength: number) => void;
|
||||
sendPlayerComplete: (sessionId: string) => void;
|
||||
sendPlayerForfeit: () => void;
|
||||
leaveRoom: () => void;
|
||||
reset: () => void;
|
||||
@@ -219,10 +219,10 @@ export const useGameNightStore = create<GameNightState>((set, get) => ({
|
||||
socket.emit('chain-update', { roomId, chainLength });
|
||||
},
|
||||
|
||||
sendPlayerComplete: (score: ScoreBreakdown, chainLength: number) => {
|
||||
sendPlayerComplete: (sessionId: string) => {
|
||||
const { socket, roomId } = get();
|
||||
if (!socket || !roomId) return;
|
||||
socket.emit('player-complete', { roomId, score, chainLength });
|
||||
socket.emit('player-complete', { roomId, sessionId });
|
||||
},
|
||||
|
||||
sendPlayerForfeit: () => {
|
||||
|
||||
+151
-45
@@ -30,13 +30,14 @@ interface GameState {
|
||||
dailyChallengeDifficulty: string | null;
|
||||
|
||||
startGame: (pair: MoviePair, options?: { isCustom?: boolean; isDaily?: boolean; dailyDate?: string; dailyChallengeId?: string; difficulty?: string }) => Promise<void>;
|
||||
startGameFromVersus: (pair: MoviePair, startTime: number) => Promise<void>;
|
||||
startGameFromVersus: (pair: MoviePair, startTime: number) => Promise<StartResult>;
|
||||
addActorToChain: (actor: ActorChainLink) => void;
|
||||
addMovieToChain: (movie: MovieChainLink) => void;
|
||||
undoLastLink: () => void;
|
||||
setValidating: (isValidating: boolean) => void;
|
||||
setValidationError: (error: string | null) => void;
|
||||
incrementHintsUsed: () => void;
|
||||
/** Set from the server's response; the count is no longer tracked locally. */
|
||||
setHintsUsed: (count: number) => void;
|
||||
resetGame: () => void;
|
||||
}
|
||||
|
||||
@@ -59,10 +60,104 @@ const initialState = {
|
||||
dailyChallengeDifficulty: null as string | null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Outcome of starting a multiplayer game.
|
||||
* - 'started' play can begin
|
||||
* - 'superseded' a newer game replaced this one mid-start (React StrictMode
|
||||
* remounts an effect, and its cleanup resets the store) — the
|
||||
* caller must stay silent, because another start is in charge
|
||||
* - 'failed' genuinely could not start; the caller should surface it
|
||||
*/
|
||||
export type StartResult = 'started' | 'superseded' | 'failed';
|
||||
|
||||
interface SessionArgs {
|
||||
movieAId: number;
|
||||
movieATitle: string;
|
||||
movieBId: number;
|
||||
movieBTitle: string;
|
||||
movieAReleaseDate: string | null;
|
||||
movieBReleaseDate: string | null;
|
||||
dailyChallengeId?: string;
|
||||
}
|
||||
|
||||
// Every started game takes the next generation number. Anything async that was
|
||||
// kicked off for an older generation must not write back into the store — that
|
||||
// is how a slow session-creation call used to land its id in the *next* game.
|
||||
let gameGeneration = 0;
|
||||
let sessionCreation: Promise<string | null> = Promise.resolve(null);
|
||||
|
||||
async function createSession(
|
||||
generation: number,
|
||||
args: SessionArgs,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const { createGameSession } = await import('@/api/games');
|
||||
const session = await createGameSession(
|
||||
args.movieAId,
|
||||
args.movieATitle,
|
||||
args.movieBId,
|
||||
args.movieBTitle,
|
||||
args.dailyChallengeId,
|
||||
args.movieAReleaseDate,
|
||||
args.movieBReleaseDate,
|
||||
);
|
||||
if (generation !== gameGeneration) return null;
|
||||
useGameStore.setState({ sessionId: session.id });
|
||||
return session.id;
|
||||
} catch (err) {
|
||||
console.error('[GameStore] Failed to create game session:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolves the final score once the loop closes. Split out of addMovieToChain
|
||||
// so it can await a session that may still be in flight: a fast player can
|
||||
// close the loop before creation returns, which previously left score null
|
||||
// forever with no error path.
|
||||
async function finalizeGame(generation: number): Promise<void> {
|
||||
const stillCurrent = () => generation === gameGeneration;
|
||||
try {
|
||||
const sessionId =
|
||||
useGameStore.getState().sessionId ?? (await sessionCreation);
|
||||
if (!stillCurrent()) return;
|
||||
|
||||
if (!sessionId) {
|
||||
useGameStore.setState({
|
||||
validationError:
|
||||
'Your loop is complete, but the server never registered this game, so it could not be scored.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { completeGameSession } = await import('@/api/games');
|
||||
const backendScore = await completeGameSession(sessionId);
|
||||
if (!stillCurrent()) return;
|
||||
|
||||
// Achievements are a completion side effect, not part of the score. Storing
|
||||
// them on `score` meant versus/game-night posted them straight back to the
|
||||
// API, where forbidNonWhitelisted rejected the whole submission with a 400.
|
||||
const { newAchievements, ...breakdown } = backendScore;
|
||||
useGameStore.setState({ score: breakdown });
|
||||
|
||||
if (newAchievements?.length) {
|
||||
for (const achievement of newAchievements) {
|
||||
toast.success(`Achievement Unlocked: ${achievement.name}`, {
|
||||
description: achievement.description,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!stillCurrent()) return;
|
||||
useGameStore.setState({ validationError: 'Failed to calculate score.' });
|
||||
}
|
||||
}
|
||||
|
||||
export const useGameStore = create<GameState>((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
startGame: async (pair: MoviePair, options?: { isCustom?: boolean; isDaily?: boolean; dailyDate?: string; dailyChallengeId?: string; difficulty?: string }) => {
|
||||
const generation = ++gameGeneration;
|
||||
// Reset if already playing
|
||||
set({ ...initialState, status: 'playing', isValidating: true, isCustomGame: options?.isCustom ?? false, isDailyChallenge: options?.isDaily ?? false, dailyChallengeDate: options?.dailyDate ?? null, dailyChallengeDifficulty: options?.difficulty ?? null });
|
||||
|
||||
@@ -99,22 +194,17 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
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,
|
||||
options?.dailyChallengeId,
|
||||
detailsA.release_date ?? null,
|
||||
detailsB.release_date ?? null,
|
||||
)
|
||||
.then((session) => set({ sessionId: session.id }))
|
||||
.catch((err) => {
|
||||
// Backend unavailable — game continues without persistence
|
||||
console.error('[GameStore] Failed to create game session:', err);
|
||||
});
|
||||
// Single-player deliberately keeps session creation off the critical path
|
||||
// so play starts instantly and still works when the backend is down. The
|
||||
// promise is retained so completion can await it instead of racing it.
|
||||
sessionCreation = createSession(generation, {
|
||||
movieAId: pair.movieA.id,
|
||||
movieATitle: pair.movieA.title,
|
||||
movieBId: pair.movieB.id,
|
||||
movieBTitle: pair.movieB.title,
|
||||
dailyChallengeId: options?.dailyChallengeId,
|
||||
movieAReleaseDate: detailsA.release_date ?? null,
|
||||
movieBReleaseDate: detailsB.release_date ?? null,
|
||||
});
|
||||
} catch {
|
||||
set({
|
||||
@@ -126,6 +216,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
},
|
||||
|
||||
startGameFromVersus: async (pair: MoviePair, startTime: number) => {
|
||||
const generation = ++gameGeneration;
|
||||
set({ ...initialState, status: 'playing', isValidating: true });
|
||||
|
||||
try {
|
||||
@@ -152,20 +243,50 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
popularity: detailsB.popularity,
|
||||
};
|
||||
|
||||
if (generation !== gameGeneration) return 'superseded';
|
||||
|
||||
// Unlike single-player, multiplayer cannot degrade to an unpersisted game:
|
||||
// no session means no score, and no score means the match never resolves
|
||||
// for either player. So this is awaited, and failing here is fatal.
|
||||
sessionCreation = createSession(generation, {
|
||||
movieAId: pair.movieA.id,
|
||||
movieATitle: pair.movieA.title,
|
||||
movieBId: pair.movieB.id,
|
||||
movieBTitle: pair.movieB.title,
|
||||
movieAReleaseDate: detailsA.release_date ?? null,
|
||||
movieBReleaseDate: detailsB.release_date ?? null,
|
||||
});
|
||||
const sessionId = await sessionCreation;
|
||||
if (generation !== gameGeneration) return 'superseded';
|
||||
|
||||
if (!sessionId) {
|
||||
set({
|
||||
status: 'idle',
|
||||
isValidating: false,
|
||||
validationError:
|
||||
'Could not start the match — the server did not register this game.',
|
||||
});
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
set({
|
||||
movieA: movieALink,
|
||||
movieB: movieBLink,
|
||||
chain: [movieALink],
|
||||
currentSearchMode: 'actor',
|
||||
startTime,
|
||||
sessionId,
|
||||
isValidating: false,
|
||||
});
|
||||
return 'started';
|
||||
} catch {
|
||||
if (generation !== gameGeneration) return 'superseded';
|
||||
set({
|
||||
status: 'idle',
|
||||
isValidating: false,
|
||||
validationError: 'Failed to load movie details. Please try again.',
|
||||
});
|
||||
return 'failed';
|
||||
}
|
||||
},
|
||||
|
||||
@@ -181,7 +302,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
},
|
||||
|
||||
addMovieToChain: (movie: MovieChainLink) => {
|
||||
const { status, chain, movieA, movieB, passedMovieB, hintsUsed } = get();
|
||||
const { status, chain, movieA, movieB, passedMovieB } = get();
|
||||
if (status !== 'playing') return;
|
||||
|
||||
const newChain = [...chain, movie];
|
||||
@@ -206,29 +327,10 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
validationError: null,
|
||||
});
|
||||
|
||||
// Await backend score calculation
|
||||
const { sessionId } = get();
|
||||
if (sessionId) {
|
||||
import('@/api/games').then(({ completeGameSession }) => {
|
||||
completeGameSession(sessionId, newChain, hintsUsed)
|
||||
.then((backendScore) => {
|
||||
set({ score: backendScore });
|
||||
|
||||
// Show toast for each newly earned achievement
|
||||
if (backendScore.newAchievements?.length) {
|
||||
for (const achievement of backendScore.newAchievements) {
|
||||
toast.success(`Achievement Unlocked: ${achievement.name}`, {
|
||||
description: achievement.description,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
set({ validationError: 'Failed to calculate score.' });
|
||||
});
|
||||
});
|
||||
}
|
||||
// Resolve the score out of band. finalizeGame awaits an in-flight session
|
||||
// and always lands on either a score or a validationError — never on the
|
||||
// silent null that left completion modals with nothing to render.
|
||||
void finalizeGame(gameGeneration);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -280,8 +382,12 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
setValidationError: (error: string | null) =>
|
||||
set({ validationError: error }),
|
||||
|
||||
incrementHintsUsed: () =>
|
||||
set((state) => ({ hintsUsed: state.hintsUsed + 1 })),
|
||||
setHintsUsed: (count: number) => set({ hintsUsed: count }),
|
||||
|
||||
resetGame: () => set(initialState),
|
||||
resetGame: () => {
|
||||
// Retire the generation so any in-flight session creation or scoring call
|
||||
// for the abandoned game cannot write back into the fresh state.
|
||||
gameGeneration++;
|
||||
set(initialState);
|
||||
},
|
||||
}));
|
||||
@@ -39,7 +39,7 @@ interface VersusState {
|
||||
joinLobby: (matchId: string) => void;
|
||||
startCountdown: () => void;
|
||||
sendChainUpdate: (chainLength: number) => void;
|
||||
sendMatchComplete: (score: ScoreBreakdown) => void;
|
||||
sendMatchComplete: (sessionId: string, score: ScoreBreakdown) => void;
|
||||
leaveLobby: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
@@ -185,11 +185,14 @@ export const useVersusStore = create<VersusState>((set, get) => ({
|
||||
socket.emit('chain-update', { matchId, chainLength });
|
||||
},
|
||||
|
||||
sendMatchComplete: (score: ScoreBreakdown) => {
|
||||
sendMatchComplete: (sessionId: string, score: ScoreBreakdown) => {
|
||||
const { socket, matchId } = get();
|
||||
if (!socket || !matchId) return;
|
||||
// The score is kept locally only so this client can render its own result
|
||||
// immediately; the server scores from the session and ignores anything we
|
||||
// would otherwise send.
|
||||
set({ myScore: score });
|
||||
socket.emit('match-complete', { matchId, score });
|
||||
socket.emit('match-complete', { matchId, sessionId });
|
||||
},
|
||||
|
||||
leaveLobby: () => {
|
||||
|
||||
@@ -11,6 +11,12 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// Bind-mounted source on Docker Desktop (Windows/macOS) does not deliver
|
||||
// inotify events, so Vite never sees host edits and silently serves stale
|
||||
// modules. Polling is opt-in via the compose file so native dev is unaffected.
|
||||
watch: process.env.VITE_USE_POLLING
|
||||
? { usePolling: true, interval: 300 }
|
||||
: undefined,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
|
||||
Reference in new issue
Block a user