feat: repo hygiene - push opt-in, paged admin jobs, shared game screen (2.2.0)
WP-17. Minor: push notifications become usable and nothing a player could reach was removed. The admin console's jobs need backend 3.0.0. Push (M35): the whole browser half - the service worker's handlers, the subscribe/unsubscribe calls, the server's VAPID key - had shipped with nothing calling it, so no browser ever held a subscription. The toggle lives in Profile because the permission prompt has to come from a click. The old isPushSubscribed awaited serviceWorker.ready, which never settles with no worker registered; it uses getRegistration() now. Admin: each job pages and the button becomes Continue with the server's cursor; the recalculate card also stopped printing "undefined leaderboard entries" for a field the server had not sent for several releases. The three copy-pasted job cards are one component. Consolidation: the four game screens share use-game-screen and one search panel; the review page's static chain is ChainDisplay in a static mode (21 rendered states verified byte-identical); formatTime and error extraction exist once; Endless and quick play share a franchise-aware picker instead of two divergent sort(() => Math.random() - 0.5) copies. The dead api/index barrel and the fourteen functions only it referenced are gone. noUncheckedIndexedAccess is on. Whiplash's TMDB id is corrected and every preset title is checked against TMDB.
This commit is contained in:
1 parent
c0643de4b4
commit
5a7e8cebc3
64 files changed
+1787
-1089
No files matched your search
@@ -9,6 +9,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.2.0] - 2026-09-11
|
||||
|
||||
Repository hygiene (WP-17: L2, L4, M9, M35, the frontend half of the audit's
|
||||
section 3 prune list). Minor: push notifications become usable, and nothing a
|
||||
player could reach was removed. The admin console's maintenance jobs need
|
||||
backend 3.0.0.
|
||||
|
||||
### Added
|
||||
|
||||
- **Push notifications can be turned on.** Profile → Notifications has the
|
||||
toggle. The whole browser half of the push stack - the service worker's
|
||||
`push` and `notificationclick` handlers, the subscribe/unsubscribe calls,
|
||||
the server's VAPID key - had shipped for months with nothing calling it, so
|
||||
no browser ever held a subscription (M35). The prompt is asked from a click,
|
||||
as browsers require, and a refusal or a server without VAPID keys is
|
||||
explained in place rather than failing silently. If a subscription cannot be
|
||||
registered with the server, the browser-side one is rolled back so the two
|
||||
never disagree.
|
||||
- The admin console's "Backfill Game Owners" card, for the job that re-attaches
|
||||
legacy games to their players. It reports how many were ambiguous - more
|
||||
than one player matched, so the server made no guess - alongside the count
|
||||
it re-attached.
|
||||
- Tests for the push library, the subscription hook, the notifications
|
||||
section and the pair picker (45 tests; 105 in total).
|
||||
|
||||
### Changed
|
||||
|
||||
- Admin maintenance jobs continue where they left off. Each one processes a
|
||||
page and, when more remains, the button becomes **Continue** and passes the
|
||||
server's cursor back. The three near-identical job cards are one component.
|
||||
The recalculate card also stopped printing "undefined leaderboard entries":
|
||||
its type declared a field the server had not sent for several releases.
|
||||
- Endless mode and quick play pick pairs through one franchise-aware picker.
|
||||
Endless had its own copy with no franchise check, so it could serve *The
|
||||
Fellowship of the Ring* against *The Two Towers*; both copies also shuffled
|
||||
with `sort(() => Math.random() - 0.5)`, which is neither uniform nor a valid
|
||||
comparator.
|
||||
- The four game screens share one hook for the store wiring and label
|
||||
derivation they had each copied, and one search panel; the review page's
|
||||
read-only chain display is the live `ChainDisplay` in a static mode rather
|
||||
than a second component that had drifted from it. Verified byte-identical
|
||||
DOM across 21 rendered states before and after.
|
||||
- `formatTime` and error-message extraction each exist once (`lib/format`,
|
||||
`lib/error`) instead of five and three times.
|
||||
- `noUncheckedIndexedAccess` is on. Indexing now types as possibly undefined
|
||||
and the code handles it, with no non-null assertions added. Along the way,
|
||||
taking an initial with `[0]` became `.charAt(0)`, which also stops an empty
|
||||
display name from throwing.
|
||||
- The package is named `movie-loop-frontend`; it had kept the scaffold's
|
||||
`frontend-temp`.
|
||||
- CI refuses to release a version whose `v<version>` git tag already exists on
|
||||
origin, before it builds anything, for the same reason the backend does.
|
||||
- The stock Vite README is replaced with one that describes this app.
|
||||
|
||||
### Fixed
|
||||
|
||||
- *Whiplash* in the preset pairs is TMDB id 244786; the table had 274870, a
|
||||
different film. Every preset title is now checked against what TMDB serves
|
||||
for its id.
|
||||
- Checking whether push is subscribed no longer awaits
|
||||
`navigator.serviceWorker.ready`, which never settles when no worker is
|
||||
registered - the check hung on any page load before registration.
|
||||
- `getErrorMessage` returns its fallback for a server error carrying an empty
|
||||
message array, rather than `undefined`.
|
||||
|
||||
### Removed
|
||||
|
||||
- The dead `src/api/index.ts` barrel and the fourteen API functions it alone
|
||||
referenced, three orphaned types, the Vite scaffold SVGs, the unused
|
||||
`scroll-area` primitive and four unused dialog/sheet exports, and the
|
||||
`_onGameStart` escape hatch that two stores read but nothing ever set.
|
||||
Nothing a user could reach.
|
||||
|
||||
## [2.1.0] - 2026-09-11
|
||||
|
||||
Accessibility (WP-16: audit section 2.8).
|
||||
|
||||
@@ -1,73 +1,124 @@
|
||||
# React + TypeScript + Vite
|
||||
# movieloop-frontend
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
The web client for **"You Know Who Else Was In That Movie?"** — a trivia-puzzle game in the spirit of Six Degrees of Kevin Bacon. You are given two movies and build a chain of actor→movie links that loops back to where you started. Every link is checked against [TMDB](https://www.themoviedb.org/) as you type; the server scores the result.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
React 19 · TypeScript (strict) · Vite 7 · Tailwind CSS v4 + shadcn/Base UI · React Router 7 · Zustand · Axios · socket.io-client.
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
The API lives in a sibling repository, `movieloop-backend`. The two are orchestrated by the compose files in the parent folder, which is deliberately not itself a git repository.
|
||||
|
||||
## React Compiler
|
||||
## What's in the app
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
| Route | What it is |
|
||||
|---|---|
|
||||
| `/`, `/game` | Quick play — a random pair, chain-building, scoring |
|
||||
| `/daily` | The three daily challenges (easy / medium / hard), same puzzles for everyone, ranked on the daily leaderboard |
|
||||
| `/endless`, `/custom` | Endless mode with genre/decade filters; pick-your-own-pair |
|
||||
| `/versus`, `/versus/lobby/:matchId`, `/versus/game` | Live head-to-head with a shared puzzle and opponent progress |
|
||||
| `/versus/async-game/:matchId`, `/versus/async/:matchId/leaderboard` | Challenge a friend; they play the same pair later |
|
||||
| `/game-night`, `/game-night/lobby/:roomId`, `/game-night/play`, `/game-night/:roomId/results` | Host a room for a table of players |
|
||||
| `/leaderboard`, `/achievements`, `/profile`, `/game/:id/review` | Ranks, badges, account settings, replay a finished chain |
|
||||
| `/friends`, `/friends/add`, `/friends/chat/:friendId` | Friends, requests, direct messages |
|
||||
| `/admin` | Admin console (admins only) |
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
Every route is code-split with `React.lazy`; vendor libraries are chunked separately so a deploy invalidates only the app chunk.
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
## Running it
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
### With Docker (the normal way)
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
From the parent folder:
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```sh
|
||||
docker compose up -d # postgres, redis, rabbitmq, backend, and this app on http://localhost:5173
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
The dev container runs Vite with file polling on (`VITE_USE_POLLING`), because a Docker Desktop bind mount on Windows delivers no file-change events.
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
### Natively
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```sh
|
||||
npm ci
|
||||
npm run dev # http://localhost:5173, proxies /api and /socket.io to localhost:3000
|
||||
```
|
||||
|
||||
The backend must be running on port 3000 (see its README). No `.env` is needed for development.
|
||||
|
||||
### Configuration
|
||||
|
||||
The built app reads its API base at runtime, not at build time, so one image serves every environment.
|
||||
|
||||
| Where | Name | Meaning |
|
||||
|---|---|---|
|
||||
| Container env | `API_URL` | API base, e.g. `https://api.example.com/api/v1` or a same-origin path. Rendered into `/config.js` and into the CSP's `connect-src` when the container starts. Defaults to `/api/v1`. |
|
||||
| Build-time (dev only) | `VITE_API_URL` | Fallback used by `npm run dev` / `npm run build` when no `/config.js` is served. |
|
||||
| Dev container | `VITE_USE_POLLING` | Turns on Vite's polling watcher. |
|
||||
|
||||
`src/lib/config.ts` rejects a malformed `API_URL` (anything that is not an http(s) URL or a leading-slash path) and falls back, loudly, because the socket layer sends the auth token to whatever this resolves to.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
npm run lint # eslint --max-warnings 0, including jsx-a11y
|
||||
npx tsc -b
|
||||
npm test # vitest + testing-library, jsdom
|
||||
npm run test:cov # coverage report; thresholds are 90% across the board
|
||||
npm run build # tsc -b && vite build -> dist/
|
||||
npm run preview # serve dist/ locally
|
||||
```
|
||||
|
||||
Coverage thresholds live in `vite.config.ts`. The suite mocks the axios client and fails loudly on any request it did not route, so no test can reach a real backend.
|
||||
|
||||
`noUncheckedIndexedAccess` is on: indexing an array or record gives you `T | undefined`, and the code handles it rather than asserting it away. There are no `any`, no `eslint-disable` and no `@ts-ignore` in `src/`; the lint gate is configured to keep it that way.
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ one module per backend resource, all through api/client.ts (axios)
|
||||
├── components/
|
||||
│ ├── game/ ChainDisplay, SearchAutocomplete (ARIA combobox), GameHeader, completion modals…
|
||||
│ ├── game-night/, versus/
|
||||
│ ├── layout/ PageLayout, nav, NotificationBell
|
||||
│ ├── profile/ PushNotificationsSection
|
||||
│ └── ui/ shadcn primitives (Base UI)
|
||||
├── hooks/ use-game-screen (the shared game-page shell), use-chain-validation, use-timer, use-hints, use-push-subscription
|
||||
├── lib/ config, error, format, movie-pair(s), push, sounds, create-socket, storage
|
||||
├── pages/ one file per route
|
||||
├── stores/ Zustand: auth, game, versus, game-night, chat, friends, notifications
|
||||
├── types/
|
||||
└── App.tsx routes (lazy) + Suspense
|
||||
public/
|
||||
├── sw.js service worker: push display + notification click
|
||||
├── manifest.json, icon-192.png, icon-512.png, apple-touch-icon.png, favicon-32.png
|
||||
└── sounds/
|
||||
```
|
||||
|
||||
### How a game works, client side
|
||||
|
||||
`game-store` owns the session: it creates one on the server, sends each link to `POST /games/:id/validate-link`, and asks the server to score on completion. The store never computes a score. When the backend cannot be reached, anonymous play falls back to checking TMDB credits client-side through the API proxy — but such a game has no session and is not scored or ranked.
|
||||
|
||||
Multiplayer pages share `use-game-screen`, which wires the store, the timer, hints and the search panel; the mode-specific pages add the socket handling.
|
||||
|
||||
### Sockets
|
||||
|
||||
Four namespaces (`/notifications`, `/chat`, `/versus`, `/game-night`), created by `lib/create-socket.ts` with the JWT in `auth.token`. Each store owns exactly one socket, and `connect()` replaces (and disconnects) a stale instance rather than stacking a second one. Signing out disconnects all four and resets every store that holds another user's data.
|
||||
|
||||
### Push notifications
|
||||
|
||||
Opt-in from **Profile → Notifications**. The service worker is registered at sign-in; enabling push asks the browser for permission and registers the subscription with the server. The server sends push only when its VAPID keys are configured — otherwise the toggle explains that push is not available here.
|
||||
|
||||
### Accessibility
|
||||
|
||||
`eslint-plugin-jsx-a11y` runs at error level. Every form control has a label, every icon-only button has a name, dialogs are real dialogs (Base UI), validation results and countdowns are announced through live regions, there is a skip link, and `prefers-reduced-motion` collapses every animation.
|
||||
|
||||
## Production image
|
||||
|
||||
`Dockerfile` has three stages: `dev` (Vite), `build`, and `production` — `nginxinc/nginx-unprivileged` serving `dist/` on **port 8080** as uid 101. `docker/40-render-config.sh` runs at container start to write `/config.js` and the security-header snippet (CSP with the API origin in `connect-src`, HSTS, frame and MIME protections) from `API_URL`. `nginx.conf` repeats the headers in every `location`, since nginx's `add_header` is not inherited.
|
||||
|
||||
## Releasing
|
||||
|
||||
Versions follow semver and every behavioural change updates `CHANGELOG.md` in the same commit. On a push to `main`, CI lints, typechecks, tests, scans (gitleaks, semgrep, Trivy fs + image), builds the image, refuses to proceed if `v<version>` already exists as a git tag or as a Harbor image tag, pushes to Harbor, signs with cosign, and pushes the git tag back. Bump `package.json` before merging or the run fails on purpose.
|
||||
|
||||
## License
|
||||
|
||||
Private. All rights reserved.
|
||||
Generated
+70
-4
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend-temp",
|
||||
"version": "2.1.0",
|
||||
"name": "movie-loop-frontend",
|
||||
"version": "2.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend-temp",
|
||||
"version": "2.1.0",
|
||||
"name": "movie-loop-frontend",
|
||||
"version": "2.2.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
@@ -2625,6 +2625,72 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend-temp",
|
||||
"name": "movie-loop-frontend",
|
||||
"private": true,
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Kevin Riehl",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -13,11 +13,6 @@ export interface Achievement {
|
||||
unlockedAt: string | null;
|
||||
}
|
||||
|
||||
export async function getAllAchievements(): Promise<Achievement[]> {
|
||||
const { data } = await apiClient.get<Achievement[]>('/achievements');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getUserAchievements(
|
||||
signal?: AbortSignal,
|
||||
): Promise<Achievement[]> {
|
||||
|
||||
+40
-11
@@ -28,11 +28,18 @@ export interface AdminUsersResponse {
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface RecalculateResult {
|
||||
/**
|
||||
* The maintenance jobs process one page per request. `hasMore` says whether
|
||||
* to call again, and `nextCursor` is what to call again with.
|
||||
*/
|
||||
export interface BatchPage {
|
||||
hasMore: boolean;
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface RecalculateResult extends BatchPage {
|
||||
updated: number;
|
||||
skipped: number;
|
||||
leaderboardUpdated: number;
|
||||
backfilled: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
@@ -57,9 +64,32 @@ export async function listUsers(
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function recalculateScores(): Promise<RecalculateResult> {
|
||||
export async function recalculateScores(
|
||||
cursor?: string,
|
||||
): Promise<RecalculateResult> {
|
||||
const { data } = await apiClient.post<RecalculateResult>(
|
||||
'/admin/recalculate-scores',
|
||||
undefined,
|
||||
{ params: cursor ? { cursor } : undefined },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface BackfillGameOwnersResult extends BatchPage {
|
||||
backfilled: number;
|
||||
/** Left alone: more than one player matched, so no guess was made. */
|
||||
ambiguous: number;
|
||||
unmatched: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export async function backfillGameOwners(
|
||||
cursor?: string,
|
||||
): Promise<BackfillGameOwnersResult> {
|
||||
const { data } = await apiClient.post<BackfillGameOwnersResult>(
|
||||
'/admin/backfill-user-ids',
|
||||
undefined,
|
||||
{ params: cursor ? { cursor } : undefined },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
@@ -75,11 +105,6 @@ export async function generateChallenge(
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteChallenge(id: string) {
|
||||
const { data } = await apiClient.delete(`/admin/daily-challenges/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface GeneratedChallenge {
|
||||
id: string;
|
||||
difficulty: string;
|
||||
@@ -98,14 +123,18 @@ export async function generateAllChallenges(
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface RetroactiveAchievementsResult {
|
||||
export interface RetroactiveAchievementsResult extends BatchPage {
|
||||
totalAwarded: number;
|
||||
usersAffected: number;
|
||||
}
|
||||
|
||||
export async function retroactiveAchievements(): Promise<RetroactiveAchievementsResult> {
|
||||
export async function retroactiveAchievements(
|
||||
cursor?: string,
|
||||
): Promise<RetroactiveAchievementsResult> {
|
||||
const { data } = await apiClient.post<RetroactiveAchievementsResult>(
|
||||
'/admin/retroactive-achievements',
|
||||
undefined,
|
||||
{ params: cursor ? { cursor } : undefined },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -33,8 +33,3 @@ export async function getMessages(
|
||||
const { data } = await apiClient.get(`/chat/${friendId}/messages`, { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteMessage(messageId: string) {
|
||||
const { data } = await apiClient.delete(`/chat/messages/${messageId}`);
|
||||
return data;
|
||||
}
|
||||
@@ -13,21 +13,6 @@ export interface DailyChallenge {
|
||||
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 getTodaysChallenges(
|
||||
signal?: AbortSignal,
|
||||
): Promise<DailyChallenge[]> {
|
||||
@@ -37,23 +22,3 @@ export async function getTodaysChallenges(
|
||||
);
|
||||
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;
|
||||
}
|
||||
@@ -76,11 +76,6 @@ export async function removeFriend(friendshipId: string) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function blockUser(userId: string) {
|
||||
const { data } = await apiClient.post('/friends/block', { userId });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createFriendChallenge(
|
||||
friendId: string,
|
||||
difficulty: string,
|
||||
|
||||
@@ -82,11 +82,6 @@ export async function getOpenRooms(
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getMyRooms(): Promise<GameNightRoom[]> {
|
||||
const { data } = await apiClient.get<GameNightRoom[]>('/game-night/me');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function joinRoom(
|
||||
roomId: string,
|
||||
password?: string,
|
||||
@@ -98,17 +93,6 @@ export async function joinRoom(
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function leaveRoom(roomId: string): Promise<void> {
|
||||
await apiClient.post(`/game-night/${roomId}/leave`);
|
||||
}
|
||||
|
||||
export async function getRoom(roomId: string): Promise<GameNightRoom> {
|
||||
const { data } = await apiClient.get<GameNightRoom>(
|
||||
`/game-night/${roomId}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getRoomResults(
|
||||
roomId: string,
|
||||
signal?: AbortSignal,
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
export { searchMovies, getMovieDetails, getMovieCredits, discoverMovies } from './movies';
|
||||
export {
|
||||
searchPersons,
|
||||
getPersonDetails,
|
||||
getPersonMovieCredits,
|
||||
} from './persons';
|
||||
export { createGameSession, completeGameSession, getGameHistory, getGameSession } from './games';
|
||||
export { register, login, getProfile } from './auth';
|
||||
export {
|
||||
getTodaysChallenge,
|
||||
getTodaysChallenges,
|
||||
getChallengeHistory,
|
||||
getChallengeByDate,
|
||||
} from './daily-challenges';
|
||||
export {
|
||||
getLeaderboard,
|
||||
getUserStats,
|
||||
getUserStreak,
|
||||
checkDailyCompletion,
|
||||
} from './leaderboards';
|
||||
export {
|
||||
getFriends,
|
||||
getPendingRequests,
|
||||
getSentRequests,
|
||||
getPendingCount,
|
||||
searchUsers,
|
||||
sendFriendRequest,
|
||||
acceptRequest,
|
||||
rejectRequest,
|
||||
removeFriend,
|
||||
blockUser,
|
||||
createFriendChallenge,
|
||||
} from './friends';
|
||||
export {
|
||||
getNotifications,
|
||||
getUnreadCount,
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead,
|
||||
deleteNotification,
|
||||
deleteAllReadNotifications,
|
||||
} from './notifications';
|
||||
export {
|
||||
getConversations,
|
||||
getMessages,
|
||||
deleteMessage,
|
||||
} from './chat';
|
||||
export {
|
||||
createMatch,
|
||||
getWaitingMatches,
|
||||
joinMatch,
|
||||
cancelMatch,
|
||||
getMyWaitingMatches,
|
||||
getMatch,
|
||||
getUserMatchHistory,
|
||||
submitCreatorScore,
|
||||
startAsyncAttempt,
|
||||
submitAsyncAttempt,
|
||||
getAsyncLeaderboard,
|
||||
} from './versus';
|
||||
export {
|
||||
createRoom,
|
||||
getOpenRooms,
|
||||
getMyRooms,
|
||||
joinRoom,
|
||||
leaveRoom,
|
||||
getRoom,
|
||||
getRoomResults,
|
||||
} from './game-night';
|
||||
export { updateProfile, changePassword } from './users';
|
||||
export { getAllAchievements, getUserAchievements } from './achievements';
|
||||
export { getVapidKey, subscribeToPush, unsubscribeFromPush } from './push';
|
||||
export {
|
||||
getPlatformStats,
|
||||
listUsers,
|
||||
recalculateScores,
|
||||
generateChallenge,
|
||||
deleteChallenge,
|
||||
generateAllChallenges,
|
||||
retroactiveAchievements,
|
||||
backfillUsernames,
|
||||
} from './admin';
|
||||
@@ -34,11 +34,6 @@ export interface UserStats {
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface StreakInfo {
|
||||
currentStreak: number;
|
||||
longestStreak: number;
|
||||
}
|
||||
|
||||
export async function getLeaderboard(
|
||||
period: 'daily' | 'weekly' | 'all-time' = 'daily',
|
||||
page = 1,
|
||||
@@ -79,8 +74,3 @@ export async function checkDailyCompletion(
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getUserStreak(): Promise<StreakInfo> {
|
||||
const { data } = await apiClient.get<StreakInfo>('/leaderboards/me/streak');
|
||||
return data;
|
||||
}
|
||||
@@ -42,7 +42,3 @@ export async function markAllNotificationsRead(): Promise<void> {
|
||||
export async function deleteNotification(id: string): Promise<void> {
|
||||
await apiClient.delete(`/notifications/${id}`);
|
||||
}
|
||||
|
||||
export async function deleteAllReadNotifications(): Promise<void> {
|
||||
await apiClient.delete('/notifications/read');
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import apiClient from './client';
|
||||
import type {
|
||||
TmdbSearchPersonResponse,
|
||||
TmdbPersonDetailsResponse,
|
||||
TmdbPersonMovieCreditsResponse,
|
||||
} from '@/types';
|
||||
|
||||
@@ -16,15 +15,6 @@ export async function searchPersons(
|
||||
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> {
|
||||
|
||||
@@ -86,13 +86,6 @@ export async function getMatch(
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getUserMatchHistory(page = 1, limit = 20) {
|
||||
const { data } = await apiClient.get('/versus/me/history', {
|
||||
params: { page, limit },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Sends the session to score from; the server will not accept a score. */
|
||||
export async function submitCreatorScore(
|
||||
matchId: string,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -17,9 +17,14 @@ interface ConfettiPiece {
|
||||
round: boolean;
|
||||
}
|
||||
|
||||
/** Always in range (index < CONFETTI_COLORS.length); the fallback only satisfies the type. */
|
||||
function randomConfettiColor(): string {
|
||||
return CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)] ?? '#f44336';
|
||||
}
|
||||
|
||||
function makeConfetti(): ConfettiPiece[] {
|
||||
return Array.from({ length: CONFETTI_COUNT }, () => ({
|
||||
color: CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
|
||||
color: randomConfettiColor(),
|
||||
left: Math.random() * 100,
|
||||
delay: Math.random() * 1,
|
||||
duration: 2 + Math.random() * 1.5,
|
||||
|
||||
@@ -4,20 +4,83 @@ import ActorCard from './ActorCard';
|
||||
import SerpentineConnector from './SerpentineConnector';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import type { ActorChainLink, MovieChainLink } from '@/types';
|
||||
import type {
|
||||
ActorChainLink,
|
||||
ChainLink,
|
||||
GameStatus,
|
||||
MovieChainLink,
|
||||
} from '@/types';
|
||||
|
||||
interface ChainPair {
|
||||
actor: ActorChainLink;
|
||||
movie?: MovieChainLink;
|
||||
}
|
||||
|
||||
export default function ChainDisplay() {
|
||||
/**
|
||||
* Without props the display is live: it follows the game store, animates the
|
||||
* newest row and shows a pending slot while the next link is validated. Given
|
||||
* a `chain`, it is a still of that finished chain (e.g. a game review) and
|
||||
* reads nothing from the store.
|
||||
*/
|
||||
type ChainDisplayProps =
|
||||
| { chain?: undefined; movieAId?: undefined; movieBId?: undefined }
|
||||
| { chain: ChainLink[]; movieAId: number; movieBId: number };
|
||||
|
||||
export default function ChainDisplay(props: ChainDisplayProps) {
|
||||
if (props.chain !== undefined) {
|
||||
// A chain handed in as a prop is a finished game: the loop is complete
|
||||
// and nothing is being validated.
|
||||
return (
|
||||
<ChainRows
|
||||
chain={props.chain}
|
||||
movieAId={props.movieAId}
|
||||
movieBId={props.movieBId}
|
||||
status="completed"
|
||||
isValidating={false}
|
||||
live={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <LiveChainDisplay />;
|
||||
}
|
||||
|
||||
function LiveChainDisplay() {
|
||||
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);
|
||||
|
||||
return (
|
||||
<ChainRows
|
||||
chain={chain}
|
||||
movieAId={movieA?.id}
|
||||
movieBId={movieB?.id}
|
||||
status={status}
|
||||
isValidating={isValidating}
|
||||
live
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChainRowsProps {
|
||||
chain: ChainLink[];
|
||||
movieAId: number | undefined;
|
||||
movieBId: number | undefined;
|
||||
status: GameStatus;
|
||||
isValidating: boolean;
|
||||
/** Store-driven: animate the newest row and show the pending movie slot. */
|
||||
live: boolean;
|
||||
}
|
||||
|
||||
function ChainRows({
|
||||
chain,
|
||||
movieAId,
|
||||
movieBId,
|
||||
status,
|
||||
isValidating,
|
||||
live,
|
||||
}: ChainRowsProps) {
|
||||
// chain[0] = MovieA (standalone starting point)
|
||||
// chain[1..2] = pair 0 (Actor, Movie)
|
||||
// chain[3..4] = pair 1 (Actor, Movie)
|
||||
@@ -55,9 +118,9 @@ export default function ChainDisplay() {
|
||||
// Determine movie target markers
|
||||
let movieTarget: 'A' | 'B' | undefined;
|
||||
if (pair.movie) {
|
||||
if (pair.movie.id === movieB?.id) movieTarget = 'B';
|
||||
if (pair.movie.id === movieBId) movieTarget = 'B';
|
||||
else if (
|
||||
pair.movie.id === movieA?.id &&
|
||||
pair.movie.id === movieAId &&
|
||||
isLastPair &&
|
||||
status === 'completed'
|
||||
)
|
||||
@@ -94,9 +157,11 @@ export default function ChainDisplay() {
|
||||
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',
|
||||
live && [
|
||||
isLastPair && !isComplete && 'animate-slide-in',
|
||||
isLastPair && isComplete && status !== 'completed' && 'animate-slide-in',
|
||||
isLastPair && status === 'completed' && 'animate-glow-pulse',
|
||||
],
|
||||
)}
|
||||
>
|
||||
{isOdd ? (
|
||||
@@ -105,7 +170,7 @@ export default function ChainDisplay() {
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center">
|
||||
<ActorCard actor={pair.actor} compact />
|
||||
</div>
|
||||
<Dot muted={!isComplete} />
|
||||
<Dot muted={live && !isComplete} />
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center">
|
||||
{pair.movie ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -113,7 +178,7 @@ export default function ChainDisplay() {
|
||||
{movieTarget && <TargetBadge target={movieTarget} />}
|
||||
</div>
|
||||
) : (
|
||||
<PendingSlot loading={isValidating && status === 'playing'} />
|
||||
live && <PendingSlot loading={isValidating && status === 'playing'} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
@@ -127,10 +192,10 @@ export default function ChainDisplay() {
|
||||
<MovieCard movie={pair.movie} compact />
|
||||
</div>
|
||||
) : (
|
||||
<PendingSlot loading={isValidating && status === 'playing'} />
|
||||
live && <PendingSlot loading={isValidating && status === 'playing'} />
|
||||
)}
|
||||
</div>
|
||||
<Dot muted={!isComplete} />
|
||||
<Dot muted={live && !isComplete} />
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center">
|
||||
<ActorCard actor={pair.actor} compact />
|
||||
</div>
|
||||
|
||||
@@ -14,15 +14,10 @@ import type { GameMode } from './ShareableResult';
|
||||
import CelebrationOverlay from './CelebrationOverlay';
|
||||
import { playSound } from '@/lib/sounds';
|
||||
import { storage } from '@/lib/storage';
|
||||
import { formatTime } from '@/lib/format';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
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);
|
||||
@@ -111,7 +106,7 @@ export default function GameCompletionModal() {
|
||||
<>
|
||||
<ScoreDisplay score={score} />
|
||||
<div className="mt-3 flex justify-center gap-4 text-sm text-muted-foreground">
|
||||
<span>Completed in {formatElapsed(score.elapsedSeconds)}</span>
|
||||
<span>Completed in {formatTime(score.elapsedSeconds)}</span>
|
||||
{hintsUsed > 0 && (
|
||||
<span>{hintsUsed} hint{hintsUsed > 1 ? 's' : ''} used</span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import SearchAutocomplete from './SearchAutocomplete';
|
||||
import ValidationFeedback from './ValidationFeedback';
|
||||
import HintButton from './HintButton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Undo2 } from 'lucide-react';
|
||||
import type { ReadyGameScreen } from '@/hooks/use-game-screen';
|
||||
|
||||
interface GameSearchPanelProps {
|
||||
screen: ReadyGameScreen;
|
||||
/** Further actions beside Undo, e.g. a forfeit button. */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "build the next link" panel shared by every game screen: the contextual
|
||||
* prompt, hint and undo controls, the search box, validation feedback and the
|
||||
* close-the-loop nudge. Callers decide when it shows (playing, not spectating).
|
||||
*/
|
||||
export default function GameSearchPanel({ screen, children }: GameSearchPanelProps) {
|
||||
const {
|
||||
chain,
|
||||
currentSearchMode,
|
||||
isValidating,
|
||||
undoLastLink,
|
||||
validateAndAddActor,
|
||||
validateAndAddMovie,
|
||||
searchLabel,
|
||||
searchPlaceholder,
|
||||
showLoopHint,
|
||||
movieA,
|
||||
} = screen;
|
||||
|
||||
return (
|
||||
<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>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchAutocomplete
|
||||
mode={currentSearchMode}
|
||||
onSelectActor={validateAndAddActor}
|
||||
onSelectMovie={validateAndAddMovie}
|
||||
disabled={isValidating}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
|
||||
<ValidationFeedback />
|
||||
|
||||
{showLoopHint && (
|
||||
<p className="text-sm text-green-600">
|
||||
Select "{movieA.title}" to close the loop!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -162,12 +162,12 @@ export default function SearchAutocomplete({
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && activeIndex < results.length) {
|
||||
if (mode === 'actor') {
|
||||
handleSelectPerson(personResults[activeIndex]);
|
||||
} else {
|
||||
handleSelectMovie(movieResults[activeIndex]);
|
||||
}
|
||||
if (mode === 'actor') {
|
||||
const person = personResults[activeIndex];
|
||||
if (person) handleSelectPerson(person);
|
||||
} else {
|
||||
const movie = movieResults[activeIndex];
|
||||
if (movie) handleSelectMovie(movie);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import MovieCard from './MovieCard';
|
||||
import ActorCard from './ActorCard';
|
||||
import SerpentineConnector from './SerpentineConnector';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ChainLink, ActorChainLink, MovieChainLink } from '@/types';
|
||||
|
||||
interface StaticChainDisplayProps {
|
||||
chain: ChainLink[];
|
||||
movieAId: number;
|
||||
movieBId: number;
|
||||
}
|
||||
|
||||
interface ChainPair {
|
||||
actor: ActorChainLink;
|
||||
movie?: MovieChainLink;
|
||||
}
|
||||
|
||||
export default function StaticChainDisplay({
|
||||
chain,
|
||||
movieAId,
|
||||
movieBId,
|
||||
}: StaticChainDisplayProps) {
|
||||
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;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{startMovie && (
|
||||
<MovieCard movie={startMovie} isTarget="A" />
|
||||
)}
|
||||
|
||||
{pairs.map((pair, pairIndex) => {
|
||||
const isOdd = pairIndex % 2 === 0;
|
||||
const isLastPair = pairIndex === pairs.length - 1;
|
||||
|
||||
let movieTarget: 'A' | 'B' | undefined;
|
||||
if (pair.movie) {
|
||||
if (pair.movie.id === movieBId) movieTarget = 'B';
|
||||
else if (pair.movie.id === movieAId && isLastPair)
|
||||
movieTarget = 'A';
|
||||
}
|
||||
|
||||
const lineX = isOdd ? 7.5 : 92.5;
|
||||
const cardEdge = isOdd ? 15 : 85;
|
||||
const armLeft = Math.min(lineX, cardEdge);
|
||||
const armRight = 100 - Math.max(lineX, cardEdge);
|
||||
|
||||
return (
|
||||
<div key={pairIndex}>
|
||||
<SerpentineConnector lineX={lineX} />
|
||||
|
||||
<div className={cn('relative flex', isOdd ? 'justify-end' : 'justify-start')}>
|
||||
<div
|
||||
className="absolute top-0 h-1/2 w-0 border-l-2 border-muted-foreground/30"
|
||||
style={{ left: `${lineX}%` }}
|
||||
/>
|
||||
<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 overflow-hidden 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',
|
||||
)}
|
||||
>
|
||||
{isOdd ? (
|
||||
<>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center">
|
||||
<ActorCard actor={pair.actor} compact />
|
||||
</div>
|
||||
<Dot />
|
||||
<div className="flex min-w-0 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>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex min-w-0 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>
|
||||
)}
|
||||
</div>
|
||||
<Dot />
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center">
|
||||
<ActorCard actor={pair.actor} compact />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<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() {
|
||||
return (
|
||||
<div className="mx-2 h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/40" />
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -105,33 +105,35 @@ describe('NotificationBell', () => {
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const rows = within(dialog).getAllByRole('listitem');
|
||||
expect(rows).toHaveLength(2);
|
||||
const [row0, row1] = rows;
|
||||
if (!row0 || !row1) throw new Error('expected two rows');
|
||||
|
||||
// The row action is a button, and it is not wrapping the other buttons.
|
||||
// The name is the row's full content (title, body, time) - what a sighted
|
||||
// user sees - prefixed by the visually hidden unread marker. Anchored, so
|
||||
// it cannot match the "Mark ... as read" / "Dismiss ..." siblings.
|
||||
const rowButton = within(rows[0]).getByRole('button', {
|
||||
const rowButton = within(row0).getByRole('button', {
|
||||
name: /^Unread:\s*Alice challenged you/,
|
||||
});
|
||||
expect(rowButton.querySelector('button')).toBeNull();
|
||||
|
||||
// Per-row controls have real names, not just a tooltip.
|
||||
expect(
|
||||
within(rows[0]).getByRole('button', {
|
||||
within(row0).getByRole('button', {
|
||||
name: 'Mark "Alice challenged you" as read',
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(rows[0]).getByRole('button', {
|
||||
within(row0).getByRole('button', {
|
||||
name: 'Dismiss "Alice challenged you"',
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
// A read notification has no mark-read control and no "Unread" prefix.
|
||||
expect(
|
||||
within(rows[1]).queryByRole('button', { name: /Mark .* as read/ }),
|
||||
within(row1).queryByRole('button', { name: /Mark .* as read/ }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(rows[1]).getByRole('button', { name: /^Bob accepted/ }),
|
||||
within(row1).getByRole('button', { name: /^Bob accepted/ }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { PushSubscriptionState } from '@/hooks/use-push-subscription';
|
||||
|
||||
/**
|
||||
* The Profile page's push toggle: the view over usePushSubscription. The hook
|
||||
* has its own tests; these cover what each hook state looks like to a user.
|
||||
*/
|
||||
|
||||
const enable = vi.fn();
|
||||
const disable = vi.fn();
|
||||
let state: PushSubscriptionState;
|
||||
|
||||
vi.mock('@/hooks/use-push-subscription', () => ({
|
||||
usePushSubscription: () => state,
|
||||
}));
|
||||
|
||||
const { default: PushNotificationsSection } = await import(
|
||||
'./PushNotificationsSection'
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state = {
|
||||
support: 'supported',
|
||||
subscribed: false,
|
||||
busy: false,
|
||||
error: null,
|
||||
enable,
|
||||
disable,
|
||||
};
|
||||
});
|
||||
|
||||
describe('PushNotificationsSection', () => {
|
||||
it('offers to turn push on, and calls enable', async () => {
|
||||
render(<PushNotificationsSection />);
|
||||
const button = screen.getByRole('button', {
|
||||
name: 'Turn on push notifications',
|
||||
});
|
||||
expect(button).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
await userEvent.click(button);
|
||||
|
||||
expect(enable).toHaveBeenCalledTimes(1);
|
||||
expect(disable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('offers to turn push off when subscribed, and calls disable', async () => {
|
||||
state.subscribed = true;
|
||||
render(<PushNotificationsSection />);
|
||||
const button = screen.getByRole('button', {
|
||||
name: 'Turn off push notifications',
|
||||
});
|
||||
expect(button).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
await userEvent.click(button);
|
||||
|
||||
expect(disable).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('is disabled while the initial check is running', () => {
|
||||
state.subscribed = null;
|
||||
render(<PushNotificationsSection />);
|
||||
expect(screen.getByRole('button', { name: 'Checking…' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('is disabled while an action is in flight', () => {
|
||||
state.busy = true;
|
||||
render(<PushNotificationsSection />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Turn on push notifications' }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('explains a blocked permission and disables the toggle', () => {
|
||||
state.support = 'denied';
|
||||
render(<PushNotificationsSection />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Turn on push notifications' }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(/Notifications are blocked for this site/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still lets a subscribed user turn push off after blocking it in the browser', () => {
|
||||
state.support = 'denied';
|
||||
state.subscribed = true;
|
||||
render(<PushNotificationsSection />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Turn off push notifications' }),
|
||||
).toBeEnabled();
|
||||
expect(screen.queryByText(/blocked for this site/)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the hook’s error', () => {
|
||||
state.error = 'Push notifications are not configured on this server.';
|
||||
render(<PushNotificationsSection />);
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'Push notifications are not configured on this server.',
|
||||
);
|
||||
});
|
||||
|
||||
it('says so, with no toggle, when the browser cannot do push', () => {
|
||||
state.support = 'unsupported';
|
||||
render(<PushNotificationsSection />);
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
expect(
|
||||
screen.getByText('This browser does not support push notifications.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { usePushSubscription } from '@/hooks/use-push-subscription';
|
||||
import { Bell, BellOff, Loader2 } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* The opt-in for Web Push. Lives here rather than firing at sign-in because
|
||||
* the browser only shows the permission prompt in response to a click, and
|
||||
* asking on the first visit is how sites train people to say no.
|
||||
*/
|
||||
export default function PushNotificationsSection() {
|
||||
const push = usePushSubscription();
|
||||
|
||||
if (push.support === 'unsupported') {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Notifications</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This browser does not support push notifications.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const checking = push.subscribed === null;
|
||||
const blocked = push.support === 'denied';
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Notifications</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Get a push notification when a friend challenges you or a versus
|
||||
match finishes, even when the game is not open.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={checking || push.busy || (blocked && !push.subscribed)}
|
||||
onClick={() => void (push.subscribed ? push.disable() : push.enable())}
|
||||
aria-pressed={push.subscribed === true}
|
||||
>
|
||||
{push.busy || checking ? (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
) : push.subscribed ? (
|
||||
<BellOff className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Bell className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
{checking
|
||||
? 'Checking…'
|
||||
: push.subscribed
|
||||
? 'Turn off push notifications'
|
||||
: 'Turn on push notifications'}
|
||||
</Button>
|
||||
{blocked && !push.subscribed && !push.error && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Notifications are blocked for this site. Allow them in your browser
|
||||
settings to turn push on.
|
||||
</p>
|
||||
)}
|
||||
{push.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{push.error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,10 +19,6 @@ 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
|
||||
@@ -145,7 +141,6 @@ function DialogDescription({
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"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 }
|
||||
@@ -15,10 +15,6 @@ function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
@@ -90,16 +86,6 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
@@ -110,26 +96,10 @@ function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: SheetPrimitive.Description.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -204,7 +204,7 @@ describe('validateAndAddActor', () => {
|
||||
await act(() =>
|
||||
h.validateAndAddActor({ ...person(7), profile_path: '/x.jpg' }),
|
||||
);
|
||||
expect(validateLink.mock.calls[0][1]).toMatchObject({
|
||||
expect(validateLink.mock.calls[0]?.[1]).toMatchObject({
|
||||
profilePath: '/x.jpg',
|
||||
});
|
||||
});
|
||||
@@ -377,7 +377,7 @@ describe('validateAndAddMovie', () => {
|
||||
);
|
||||
// The schema accepts null and normalises it, but the hook does not lean on
|
||||
// that: it sends undefined, which axios drops from the JSON body.
|
||||
const body = validateLink.mock.calls[0][1] as { posterPath?: unknown };
|
||||
const body = validateLink.mock.calls[0]?.[1] as { posterPath?: unknown };
|
||||
expect(body.posterPath).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { validateLink } from '@/api/games';
|
||||
import { getMovieCredits } from '@/api/movies';
|
||||
import { getPersonMovieCredits } from '@/api/persons';
|
||||
import { playSound } from '@/lib/sounds';
|
||||
import { getErrorMessage } from '@/lib/error';
|
||||
import type {
|
||||
ActorChainLink,
|
||||
MovieChainLink,
|
||||
@@ -82,7 +83,7 @@ export function useChainValidation() {
|
||||
|
||||
playSound('valid');
|
||||
} catch (error: unknown) {
|
||||
const message = extractErrorMessage(error, 'Failed to validate. Please try again.');
|
||||
const message = getErrorMessage(error, 'Failed to validate. Please try again.');
|
||||
setValidationError(message);
|
||||
playSound('invalid');
|
||||
} finally {
|
||||
@@ -163,7 +164,7 @@ export function useChainValidation() {
|
||||
|
||||
playSound('valid');
|
||||
} catch (error: unknown) {
|
||||
const message = extractErrorMessage(error, 'Failed to validate. Please try again.');
|
||||
const message = getErrorMessage(error, 'Failed to validate. Please try again.');
|
||||
setValidationError(message);
|
||||
playSound('invalid');
|
||||
} finally {
|
||||
@@ -175,20 +176,3 @@ export function useChainValidation() {
|
||||
|
||||
return { validateAndAddActor, validateAndAddMovie };
|
||||
}
|
||||
|
||||
/** Extract a human-readable error message from an Axios error or generic error. */
|
||||
function extractErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const resp = (error as { response?: { data?: { message?: string | string[] } } })
|
||||
.response;
|
||||
if (resp?.data?.message) {
|
||||
return typeof resp.data.message === 'string'
|
||||
? resp.data.message
|
||||
: resp.data.message[0];
|
||||
}
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Network Error') {
|
||||
return 'Network error \u2014 check your connection and try again.';
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import { useChainValidation } from '@/hooks/use-chain-validation';
|
||||
import type {
|
||||
ChainLink,
|
||||
GameStatus,
|
||||
MovieChainLink,
|
||||
SearchMode,
|
||||
TmdbMovieResult,
|
||||
TmdbPersonResult,
|
||||
} from '@/types';
|
||||
|
||||
/**
|
||||
* Everything the four game screens (solo, versus, game night, async versus)
|
||||
* derive identically from the game store: the store fields they all read, the
|
||||
* validation callbacks, and the copy that depends on where the chain stands.
|
||||
*
|
||||
* Each screen still owns what makes it that screen - its idle redirect, its
|
||||
* multiplayer broadcasts, its layout - and only reads shared state from here.
|
||||
*/
|
||||
interface GameScreenState {
|
||||
status: GameStatus;
|
||||
chain: ChainLink[];
|
||||
currentSearchMode: SearchMode;
|
||||
passedMovieB: boolean;
|
||||
isValidating: boolean;
|
||||
undoLastLink: () => void;
|
||||
validateAndAddActor: (person: TmdbPersonResult) => Promise<void>;
|
||||
validateAndAddMovie: (movie: TmdbMovieResult) => Promise<void>;
|
||||
/** Prompt for the next link, e.g. `Who was in "Speed"?`; '' when the chain is mid-transition. */
|
||||
searchLabel: string;
|
||||
searchPlaceholder: string;
|
||||
/** Movie B is behind the player and the next movie can close the loop. */
|
||||
showLoopHint: boolean;
|
||||
}
|
||||
|
||||
export type GameScreen =
|
||||
/** Nothing to show: no game is in progress. Screens render null. */
|
||||
| (GameScreenState & { ready: false })
|
||||
/** A game is in progress or complete, with both target movies known. */
|
||||
| (GameScreenState & { ready: true; movieA: MovieChainLink; movieB: MovieChainLink });
|
||||
|
||||
export type ReadyGameScreen = Extract<GameScreen, { ready: true }>;
|
||||
|
||||
export function useGameScreen(): GameScreen {
|
||||
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 { validateAndAddActor, validateAndAddMovie } = useChainValidation();
|
||||
|
||||
// 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 !== null;
|
||||
|
||||
const state: GameScreenState = {
|
||||
status,
|
||||
chain,
|
||||
currentSearchMode,
|
||||
passedMovieB,
|
||||
isValidating,
|
||||
undoLastLink,
|
||||
validateAndAddActor,
|
||||
validateAndAddMovie,
|
||||
searchLabel,
|
||||
searchPlaceholder,
|
||||
showLoopHint,
|
||||
};
|
||||
|
||||
if (status === 'idle' || !movieA || !movieB) {
|
||||
return { ...state, ready: false };
|
||||
}
|
||||
return { ...state, ready: true, movieA, movieB };
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* The state behind the Profile page's push toggle: an initial "is it on?"
|
||||
* check, then enable/disable that report success or a reason for failure.
|
||||
*/
|
||||
|
||||
const isPushSubscribed = vi.fn();
|
||||
const subscribeToPush = vi.fn();
|
||||
const unsubscribeFromPush = vi.fn();
|
||||
const pushSupport = vi.fn();
|
||||
|
||||
vi.mock('@/lib/push', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/push')>('@/lib/push');
|
||||
return {
|
||||
PushError: actual.PushError,
|
||||
isPushSubscribed: () => isPushSubscribed(),
|
||||
subscribeToPush: () => subscribeToPush(),
|
||||
unsubscribeFromPush: () => unsubscribeFromPush(),
|
||||
pushSupport: () => pushSupport(),
|
||||
};
|
||||
});
|
||||
|
||||
const { PushError } = await import('@/lib/push');
|
||||
const { usePushSubscription } = await import('./use-push-subscription');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pushSupport.mockReturnValue('supported');
|
||||
isPushSubscribed.mockResolvedValue(false);
|
||||
subscribeToPush.mockResolvedValue(undefined);
|
||||
unsubscribeFromPush.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('usePushSubscription', () => {
|
||||
it('starts unknown, then reports the browser’s current subscription', async () => {
|
||||
isPushSubscribed.mockResolvedValue(true);
|
||||
const { result } = renderHook(() => usePushSubscription());
|
||||
|
||||
expect(result.current.subscribed).toBeNull();
|
||||
await waitFor(() => expect(result.current.subscribed).toBe(true));
|
||||
expect(result.current.support).toBe('supported');
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('does not probe an unsupported browser and reports not subscribed', async () => {
|
||||
pushSupport.mockReturnValue('unsupported');
|
||||
const { result } = renderHook(() => usePushSubscription());
|
||||
|
||||
await waitFor(() => expect(result.current.subscribed).toBe(false));
|
||||
expect(isPushSubscribed).not.toHaveBeenCalled();
|
||||
expect(result.current.support).toBe('unsupported');
|
||||
});
|
||||
|
||||
it('ignores a probe that settles after unmount', async () => {
|
||||
let settle!: (value: boolean) => void;
|
||||
isPushSubscribed.mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
settle = resolve;
|
||||
}),
|
||||
);
|
||||
const { result, unmount } = renderHook(() => usePushSubscription());
|
||||
expect(result.current.subscribed).toBeNull();
|
||||
|
||||
unmount();
|
||||
// Settling now must not write to an unmounted component; React would
|
||||
// warn, and the test's error handler would surface it.
|
||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
await act(async () => {
|
||||
settle(true);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(errors).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats a failed probe as not subscribed rather than staying unknown', async () => {
|
||||
isPushSubscribed.mockRejectedValue(new Error('boom'));
|
||||
const { result } = renderHook(() => usePushSubscription());
|
||||
await waitFor(() => expect(result.current.subscribed).toBe(false));
|
||||
});
|
||||
|
||||
it('enable subscribes and flips the state, clearing any earlier error', async () => {
|
||||
const { result } = renderHook(() => usePushSubscription());
|
||||
await waitFor(() => expect(result.current.subscribed).toBe(false));
|
||||
|
||||
await act(() => result.current.enable());
|
||||
|
||||
expect(subscribeToPush).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.subscribed).toBe(true);
|
||||
expect(result.current.busy).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('disable unsubscribes and flips the state back', async () => {
|
||||
isPushSubscribed.mockResolvedValue(true);
|
||||
const { result } = renderHook(() => usePushSubscription());
|
||||
await waitFor(() => expect(result.current.subscribed).toBe(true));
|
||||
|
||||
await act(() => result.current.disable());
|
||||
|
||||
expect(unsubscribeFromPush).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.subscribed).toBe(false);
|
||||
});
|
||||
|
||||
it('surfaces a PushError’s own message and leaves the state unchanged', async () => {
|
||||
subscribeToPush.mockRejectedValue(
|
||||
new PushError('Push notifications are not configured on this server.', 'no-key'),
|
||||
);
|
||||
const { result } = renderHook(() => usePushSubscription());
|
||||
await waitFor(() => expect(result.current.subscribed).toBe(false));
|
||||
|
||||
await act(() => result.current.enable());
|
||||
|
||||
expect(result.current.error).toBe(
|
||||
'Push notifications are not configured on this server.',
|
||||
);
|
||||
expect(result.current.subscribed).toBe(false);
|
||||
expect(result.current.support).toBe('supported');
|
||||
});
|
||||
|
||||
it('a denied permission also moves support to "denied" so the UI can explain itself', async () => {
|
||||
subscribeToPush.mockRejectedValue(new PushError('Blocked.', 'denied'));
|
||||
const { result } = renderHook(() => usePushSubscription());
|
||||
await waitFor(() => expect(result.current.subscribed).toBe(false));
|
||||
|
||||
await act(() => result.current.enable());
|
||||
|
||||
expect(result.current.support).toBe('denied');
|
||||
expect(result.current.error).toBe('Blocked.');
|
||||
});
|
||||
|
||||
it('maps a server error through getErrorMessage, with a push-specific fallback', async () => {
|
||||
subscribeToPush.mockRejectedValue({
|
||||
response: { status: 500, data: { message: 'Internal server error' } },
|
||||
});
|
||||
const { result } = renderHook(() => usePushSubscription());
|
||||
await waitFor(() => expect(result.current.subscribed).toBe(false));
|
||||
await act(() => result.current.enable());
|
||||
expect(result.current.error).toBe('Internal server error');
|
||||
|
||||
unsubscribeFromPush.mockRejectedValue(new Error('something odd'));
|
||||
await act(() => result.current.disable());
|
||||
expect(result.current.error).toBe('Could not update push notifications.');
|
||||
});
|
||||
|
||||
it('reports busy while an action is in flight', async () => {
|
||||
let finish!: () => void;
|
||||
subscribeToPush.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => usePushSubscription());
|
||||
await waitFor(() => expect(result.current.subscribed).toBe(false));
|
||||
|
||||
let pending: Promise<void>;
|
||||
act(() => {
|
||||
pending = result.current.enable();
|
||||
});
|
||||
expect(result.current.busy).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
finish();
|
||||
await pending;
|
||||
});
|
||||
expect(result.current.busy).toBe(false);
|
||||
expect(result.current.subscribed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getErrorMessage } from '@/lib/error';
|
||||
import {
|
||||
isPushSubscribed,
|
||||
PushError,
|
||||
pushSupport,
|
||||
subscribeToPush,
|
||||
unsubscribeFromPush,
|
||||
type PushSupport,
|
||||
} from '@/lib/push';
|
||||
|
||||
export interface PushSubscriptionState {
|
||||
/** Whether this browser can do push at all, and whether it is blocked. */
|
||||
support: PushSupport;
|
||||
/** `null` until the initial check has completed. */
|
||||
subscribed: boolean | null;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
enable: () => Promise<void>;
|
||||
disable: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The state behind a push-notifications toggle. `enable` must be called from
|
||||
* a user gesture: the browser will not show the permission prompt otherwise.
|
||||
*/
|
||||
export function usePushSubscription(): PushSubscriptionState {
|
||||
const [support, setSupport] = useState<PushSupport>(() => pushSupport());
|
||||
// An unsupported browser is known not-subscribed up front; otherwise the
|
||||
// answer comes from the probe below.
|
||||
const [subscribed, setSubscribed] = useState<boolean | null>(() =>
|
||||
support === 'unsupported' ? false : null,
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (support === 'unsupported') return;
|
||||
let cancelled = false;
|
||||
isPushSubscribed()
|
||||
.then((value) => {
|
||||
if (!cancelled) setSubscribed(value);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSubscribed(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [support]);
|
||||
|
||||
const run = useCallback(
|
||||
async (action: () => Promise<void>, endState: boolean) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await action();
|
||||
setSubscribed(endState);
|
||||
} catch (err) {
|
||||
if (err instanceof PushError) {
|
||||
setError(err.message);
|
||||
if (err.reason === 'denied') setSupport('denied');
|
||||
} else {
|
||||
setError(
|
||||
getErrorMessage(err, 'Could not update push notifications.'),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const enable = useCallback(() => run(subscribeToPush, true), [run]);
|
||||
const disable = useCallback(() => run(unsubscribeFromPush, false), [run]);
|
||||
|
||||
return { support, subscribed, busy, error, enable, disable };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { formatTime } from '@/lib/format';
|
||||
|
||||
export type BonusTier = 'high' | 'medium' | 'low' | 'none';
|
||||
|
||||
@@ -9,12 +10,6 @@ function getBonusTier(seconds: number): BonusTier {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
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));
|
||||
|
||||
+19
-2
@@ -1,14 +1,31 @@
|
||||
interface AxiosLikeError {
|
||||
response?: {
|
||||
data?: { message?: string };
|
||||
data?: { message?: string | string[] };
|
||||
status?: number;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a human-readable error message from an Axios error or generic
|
||||
* error. A server validation message may arrive as a `string[]`, in which
|
||||
* case the first entry is used. A bare `Network Error` (axios's message when
|
||||
* the request never reached the server) is mapped to a friendlier sentence
|
||||
* rather than surfaced verbatim.
|
||||
*/
|
||||
export function getErrorMessage(error: unknown, fallback = 'Unknown error'): string {
|
||||
const e = error as AxiosLikeError;
|
||||
return e?.response?.data?.message ?? e?.message ?? fallback;
|
||||
const responseMessage = e?.response?.data?.message;
|
||||
if (responseMessage) {
|
||||
if (Array.isArray(responseMessage)) {
|
||||
return responseMessage[0] ?? fallback;
|
||||
}
|
||||
return responseMessage;
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Network Error') {
|
||||
return 'Network error — check your connection and try again.';
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function getErrorStatus(error: unknown): number | undefined {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Shared display formatting.
|
||||
*
|
||||
* `formatTime` existed as five byte-identical private copies (use-timer,
|
||||
* GameReview, Leaderboard, Profile, and GameCompletionModal under the name
|
||||
* formatElapsed). One definition, so a change to how a duration reads happens
|
||||
* once.
|
||||
*/
|
||||
|
||||
/** Seconds as `m:ss`, e.g. 125 -> "2:05". */
|
||||
export function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { pickDistinctPair, areSameFranchise } from './movie-pair';
|
||||
|
||||
interface Movie {
|
||||
id: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const movie = (id: number, title = `Movie ${id}`): Movie => ({ id, title });
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('pickDistinctPair', () => {
|
||||
it('returns null when fewer than two movies are given', () => {
|
||||
expect(pickDistinctPair<Movie>([])).toBeNull();
|
||||
expect(pickDistinctPair([movie(1)])).toBeNull();
|
||||
});
|
||||
|
||||
it('never returns the same movie twice', () => {
|
||||
const movies = [movie(1), movie(2), movie(3), movie(4), movie(5)];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const pair = pickDistinctPair(movies);
|
||||
expect(pair).not.toBeNull();
|
||||
const [a, b] = pair!;
|
||||
expect(a.id).not.toBe(b.id);
|
||||
}
|
||||
});
|
||||
|
||||
it('never pairs two movies from the same franchise, even when a naive pick would collide', () => {
|
||||
// MCU ids 299536 and 299534 are tagged as the same franchise. A naive
|
||||
// "shuffle and take the first two" pick has a good chance of colliding on
|
||||
// a small list like this one; pickDistinctPair must not.
|
||||
const movies = [
|
||||
movie(299536, 'Avengers: Endgame'),
|
||||
movie(299534, 'Avengers: Infinity War'),
|
||||
movie(603, 'The Matrix'),
|
||||
];
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const pair = pickDistinctPair(movies);
|
||||
expect(pair).not.toBeNull();
|
||||
const [a, b] = pair!;
|
||||
expect(areSameFranchise(a.id, b.id)).toBe(false);
|
||||
expect(a.id === 603 || b.id === 603).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null when every possible pair collides on franchise', () => {
|
||||
const movies = [
|
||||
movie(299536, 'Avengers: Endgame'),
|
||||
movie(299534, 'Avengers: Infinity War'),
|
||||
movie(24428, 'The Avengers'),
|
||||
];
|
||||
|
||||
expect(pickDistinctPair(movies)).toBeNull();
|
||||
});
|
||||
|
||||
it('still returns a valid pair when Math.random is spied to a fixed sequence', () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0);
|
||||
|
||||
const movies = [movie(1), movie(2), movie(3), movie(4)];
|
||||
const pair = pickDistinctPair(movies);
|
||||
|
||||
expect(pair).not.toBeNull();
|
||||
const [a, b] = pair!;
|
||||
expect(a.id).not.toBe(b.id);
|
||||
expect(movies.map((m) => m.id)).toContain(a.id);
|
||||
expect(movies.map((m) => m.id)).toContain(b.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Shared "pick two distinct, unrelated movies" logic.
|
||||
*
|
||||
* Home and Endless each reimplemented this independently and diverged: Home
|
||||
* checked for same-franchise collisions but shuffled with
|
||||
* `.sort(() => Math.random() - 0.5)` (neither uniform nor a valid comparator),
|
||||
* while Endless's unfiltered path skipped the franchise check entirely, so it
|
||||
* could hand out two films from the same series. One implementation, so both
|
||||
* modes get a uniform shuffle and the same franchise guard.
|
||||
*/
|
||||
|
||||
/** Known franchise groups — movies in the same group won't be paired together. */
|
||||
export 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',
|
||||
};
|
||||
|
||||
export function areSameFranchise(idA: number, idB: number): boolean {
|
||||
const tagA = FRANCHISE_TAGS[idA];
|
||||
const tagB = FRANCHISE_TAGS[idB];
|
||||
return !!(tagA && tagB && tagA === tagB);
|
||||
}
|
||||
|
||||
/** Fisher-Yates shuffle — uniform, unlike `.sort(() => Math.random() - 0.5)`. */
|
||||
function shuffle<T>(items: T[]): T[] {
|
||||
const shuffled = [...items];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
const a = shuffled[i];
|
||||
const b = shuffled[j];
|
||||
if (a === undefined || b === undefined) continue; // unreachable: i, j are always valid indices
|
||||
shuffled[i] = b;
|
||||
shuffled[j] = a;
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick two distinct movies that are not from the same franchise.
|
||||
*
|
||||
* Shuffles uniformly, then scans shuffled pairs in order for the first that
|
||||
* clears the franchise check — so any valid pairing present in `movies` will
|
||||
* be found, not just one relative to a fixed first pick. Returns `null` when
|
||||
* fewer than two movies are given, or when every possible pair collides on
|
||||
* franchise (e.g. the list is entirely one series).
|
||||
*/
|
||||
export function pickDistinctPair<T extends { id: number; title: string }>(
|
||||
movies: T[],
|
||||
): [T, T] | null {
|
||||
if (movies.length < 2) return null;
|
||||
|
||||
const shuffled = shuffle(movies);
|
||||
|
||||
for (let i = 0; i < shuffled.length; i++) {
|
||||
const a = shuffled[i];
|
||||
if (!a) continue; // unreachable: i is always a valid index
|
||||
|
||||
for (let j = i + 1; j < shuffled.length; j++) {
|
||||
const b = shuffled[j];
|
||||
if (!b) continue; // unreachable: j is always a valid index
|
||||
|
||||
if (!areSameFranchise(a.id, b.id)) {
|
||||
return [a, b];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -174,7 +174,7 @@ const PRESET_PAIRS: TaggedPair[] = [
|
||||
decade: '2000s',
|
||||
},
|
||||
{
|
||||
movieA: { id: 274870, title: 'Whiplash' },
|
||||
movieA: { id: 244786, title: 'Whiplash' },
|
||||
movieB: { id: 313369, title: 'La La Land' },
|
||||
description: 'Damien Chazelle music',
|
||||
genres: ['Drama'],
|
||||
@@ -226,7 +226,9 @@ const PRESET_PAIRS: TaggedPair[] = [
|
||||
|
||||
export function getRandomPair(): PresetMoviePair {
|
||||
const index = Math.floor(Math.random() * PRESET_PAIRS.length);
|
||||
return PRESET_PAIRS[index];
|
||||
const pair = PRESET_PAIRS[index];
|
||||
if (!pair) throw new Error('PRESET_PAIRS is empty');
|
||||
return pair;
|
||||
}
|
||||
|
||||
export function getFilteredPair(
|
||||
@@ -247,7 +249,9 @@ export function getFilteredPair(
|
||||
}
|
||||
|
||||
const index = Math.floor(Math.random() * filtered.length);
|
||||
return filtered[index];
|
||||
const pair = filtered[index];
|
||||
if (!pair) throw new Error('no filtered pairs available');
|
||||
return pair;
|
||||
}
|
||||
|
||||
export { PRESET_PAIRS };
|
||||
@@ -0,0 +1,305 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Browser-side Web Push. The important behaviours are the ones the old
|
||||
* implementation got wrong: it awaited `serviceWorker.ready`, which never
|
||||
* settles with no worker registered, so "is push on?" could hang forever; and
|
||||
* it swallowed every failure into `false`, so the UI could not tell a blocked
|
||||
* permission from a server outage.
|
||||
*
|
||||
* Requests are intercepted at the axios client, as the other tests do.
|
||||
*/
|
||||
|
||||
const apiGet = vi.fn();
|
||||
const apiPost = vi.fn();
|
||||
const apiDelete = vi.fn();
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
default: {
|
||||
get: (...a: unknown[]) => apiGet(...a),
|
||||
post: (...a: unknown[]) => apiPost(...a),
|
||||
delete: (...a: unknown[]) => apiDelete(...a),
|
||||
},
|
||||
}));
|
||||
|
||||
const push = await import('./push');
|
||||
|
||||
// A minimal PushManager / registration double. `subscription` is what
|
||||
// getSubscription() resolves to; `subscribe` and `unsubscribe` are spies.
|
||||
function fakeRegistration(subscription: FakeSubscription | null) {
|
||||
const subscribe = vi.fn<(...args: unknown[]) => Promise<unknown>>();
|
||||
return {
|
||||
registration: {
|
||||
pushManager: {
|
||||
getSubscription: vi.fn().mockResolvedValue(subscription),
|
||||
subscribe,
|
||||
},
|
||||
} as unknown as ServiceWorkerRegistration,
|
||||
subscribe,
|
||||
};
|
||||
}
|
||||
|
||||
interface FakeSubscription {
|
||||
endpoint: string;
|
||||
toJSON: () => unknown;
|
||||
unsubscribe: ReturnType<typeof vi.fn<() => Promise<boolean>>>;
|
||||
}
|
||||
|
||||
function fakeSubscription(endpoint = 'https://push.example/abc'): FakeSubscription {
|
||||
const unsubscribe = vi.fn<() => Promise<boolean>>();
|
||||
unsubscribe.mockResolvedValue(true);
|
||||
return {
|
||||
endpoint,
|
||||
toJSON: () => ({ endpoint, keys: { p256dh: 'p', auth: 'a' } }),
|
||||
unsubscribe,
|
||||
};
|
||||
}
|
||||
|
||||
type AsyncMock = ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<unknown>>>;
|
||||
|
||||
let getRegistration: AsyncMock;
|
||||
let register: AsyncMock;
|
||||
let requestPermission: AsyncMock;
|
||||
let permission: NotificationPermission;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getRegistration = vi.fn<(...args: unknown[]) => Promise<unknown>>();
|
||||
getRegistration.mockResolvedValue(undefined);
|
||||
register = vi.fn<(...args: unknown[]) => Promise<unknown>>();
|
||||
requestPermission = vi.fn<(...args: unknown[]) => Promise<unknown>>();
|
||||
requestPermission.mockResolvedValue('granted');
|
||||
permission = 'default';
|
||||
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: { getRegistration, register },
|
||||
});
|
||||
vi.stubGlobal('PushManager', function PushManager() {});
|
||||
vi.stubGlobal('Notification', {
|
||||
get permission() {
|
||||
return permission;
|
||||
},
|
||||
requestPermission: (...a: unknown[]) => requestPermission(...a),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
Reflect.deleteProperty(navigator, 'serviceWorker');
|
||||
});
|
||||
|
||||
describe('pushSupport', () => {
|
||||
it('is supported when the three APIs exist and permission is not denied', () => {
|
||||
expect(push.pushSupport()).toBe('supported');
|
||||
});
|
||||
|
||||
it('reports denied when the user has blocked notifications', () => {
|
||||
permission = 'denied';
|
||||
expect(push.pushSupport()).toBe('denied');
|
||||
});
|
||||
|
||||
it('is unsupported without a service worker', () => {
|
||||
Reflect.deleteProperty(navigator, 'serviceWorker');
|
||||
expect(push.pushSupport()).toBe('unsupported');
|
||||
});
|
||||
|
||||
it('is unsupported without PushManager', () => {
|
||||
vi.stubGlobal('PushManager', undefined);
|
||||
// stubGlobal(undefined) still defines the key; the check is `in window`,
|
||||
// so remove it outright.
|
||||
Reflect.deleteProperty(window, 'PushManager');
|
||||
expect(push.pushSupport()).toBe('unsupported');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPushSubscribed', () => {
|
||||
it('resolves false, rather than hanging, when no worker is registered', async () => {
|
||||
await expect(push.isPushSubscribed()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('reflects the registration’s current subscription', async () => {
|
||||
getRegistration.mockResolvedValue(
|
||||
fakeRegistration(fakeSubscription()).registration,
|
||||
);
|
||||
await expect(push.isPushSubscribed()).resolves.toBe(true);
|
||||
|
||||
getRegistration.mockResolvedValue(fakeRegistration(null).registration);
|
||||
await expect(push.isPushSubscribed()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('is false without a service worker API at all', async () => {
|
||||
Reflect.deleteProperty(navigator, 'serviceWorker');
|
||||
await expect(push.isPushSubscribed()).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribeToPush', () => {
|
||||
it('registers a worker if none exists, subscribes with the server key, and posts the subscription', async () => {
|
||||
const sub = fakeSubscription();
|
||||
const { registration, subscribe } = fakeRegistration(null);
|
||||
subscribe.mockResolvedValue(sub);
|
||||
register.mockResolvedValue(registration);
|
||||
apiGet.mockResolvedValue({ data: { key: 'BAbc_-12' } });
|
||||
apiPost.mockResolvedValue({ data: { id: 's1' } });
|
||||
|
||||
await push.subscribeToPush();
|
||||
|
||||
expect(register).toHaveBeenCalledWith('/sw.js');
|
||||
expect(apiGet).toHaveBeenCalledWith('/notifications/push/vapid-key');
|
||||
expect(requestPermission).toHaveBeenCalledTimes(1);
|
||||
const options = subscribe.mock.calls[0]?.[0] as PushSubscriptionOptionsInit;
|
||||
expect(options.userVisibleOnly).toBe(true);
|
||||
// base64url decoded: 'BAbc_-12' -> 6 bytes
|
||||
expect(new Uint8Array(options.applicationServerKey as ArrayBuffer)).toHaveLength(6);
|
||||
expect(apiPost).toHaveBeenCalledWith(
|
||||
'/notifications/push/subscribe',
|
||||
sub.toJSON(),
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses an existing registration instead of registering again', async () => {
|
||||
const { registration, subscribe } = fakeRegistration(null);
|
||||
subscribe.mockResolvedValue(fakeSubscription());
|
||||
getRegistration.mockResolvedValue(registration);
|
||||
apiGet.mockResolvedValue({ data: { key: 'BAbc' } });
|
||||
apiPost.mockResolvedValue({ data: {} });
|
||||
|
||||
await push.subscribeToPush();
|
||||
|
||||
expect(register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws "unsupported" without touching the network when the browser cannot do push', async () => {
|
||||
Reflect.deleteProperty(navigator, 'serviceWorker');
|
||||
await expect(push.subscribeToPush()).rejects.toMatchObject({
|
||||
name: 'PushError',
|
||||
reason: 'unsupported',
|
||||
});
|
||||
expect(apiGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws "no-key" when the server has no VAPID key configured', async () => {
|
||||
getRegistration.mockResolvedValue(fakeRegistration(null).registration);
|
||||
apiGet.mockResolvedValue({ data: { key: null } });
|
||||
|
||||
await expect(push.subscribeToPush()).rejects.toMatchObject({
|
||||
reason: 'no-key',
|
||||
});
|
||||
expect(requestPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws "denied" when the user refuses the permission prompt, and does not subscribe', async () => {
|
||||
const { registration, subscribe } = fakeRegistration(null);
|
||||
getRegistration.mockResolvedValue(registration);
|
||||
apiGet.mockResolvedValue({ data: { key: 'BAbc' } });
|
||||
requestPermission.mockResolvedValue('denied');
|
||||
|
||||
await expect(push.subscribeToPush()).rejects.toMatchObject({
|
||||
reason: 'denied',
|
||||
});
|
||||
expect(subscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws "failed" when the browser rejects the subscription', async () => {
|
||||
const { registration, subscribe } = fakeRegistration(null);
|
||||
subscribe.mockRejectedValue(new Error('AbortError'));
|
||||
getRegistration.mockResolvedValue(registration);
|
||||
apiGet.mockResolvedValue({ data: { key: 'BAbc' } });
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
await expect(push.subscribeToPush()).rejects.toMatchObject({
|
||||
reason: 'failed',
|
||||
});
|
||||
expect(apiPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rolls back the browser subscription when the server rejects it, and rethrows the server error', async () => {
|
||||
const sub = fakeSubscription();
|
||||
const { registration, subscribe } = fakeRegistration(null);
|
||||
subscribe.mockResolvedValue(sub);
|
||||
getRegistration.mockResolvedValue(registration);
|
||||
apiGet.mockResolvedValue({ data: { key: 'BAbc' } });
|
||||
const serverError = new Error('Request failed with status code 500');
|
||||
apiPost.mockRejectedValue(serverError);
|
||||
|
||||
await expect(push.subscribeToPush()).rejects.toBe(serverError);
|
||||
expect(sub.unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still reports the server error when the rollback itself fails', async () => {
|
||||
const sub = fakeSubscription();
|
||||
sub.unsubscribe.mockRejectedValue(new Error('gone'));
|
||||
const { registration, subscribe } = fakeRegistration(null);
|
||||
subscribe.mockResolvedValue(sub);
|
||||
getRegistration.mockResolvedValue(registration);
|
||||
apiGet.mockResolvedValue({ data: { key: 'BAbc' } });
|
||||
const serverError = new Error('Request failed with status code 503');
|
||||
apiPost.mockRejectedValue(serverError);
|
||||
|
||||
await expect(push.subscribeToPush()).rejects.toBe(serverError);
|
||||
});
|
||||
|
||||
it('throws "failed" when the worker cannot be registered', async () => {
|
||||
register.mockRejectedValue(new Error('SecurityError'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
await expect(push.subscribeToPush()).rejects.toMatchObject({
|
||||
reason: 'failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsubscribeFromPush', () => {
|
||||
it('tells the server first, then drops the browser subscription', async () => {
|
||||
const sub = fakeSubscription('https://push.example/xyz');
|
||||
getRegistration.mockResolvedValue(fakeRegistration(sub).registration);
|
||||
apiDelete.mockResolvedValue({ data: { success: true } });
|
||||
|
||||
await push.unsubscribeFromPush();
|
||||
|
||||
expect(apiDelete).toHaveBeenCalledWith('/notifications/push/unsubscribe', {
|
||||
data: { endpoint: 'https://push.example/xyz' },
|
||||
});
|
||||
expect(sub.unsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(apiDelete.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
sub.unsubscribe.mock.invocationCallOrder[0] ?? Infinity,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the browser subscription if the server call fails', async () => {
|
||||
const sub = fakeSubscription();
|
||||
getRegistration.mockResolvedValue(fakeRegistration(sub).registration);
|
||||
apiDelete.mockRejectedValue(new Error('offline'));
|
||||
|
||||
await expect(push.unsubscribeFromPush()).rejects.toThrow('offline');
|
||||
expect(sub.unsubscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op with no registration or no subscription', async () => {
|
||||
await push.unsubscribeFromPush();
|
||||
getRegistration.mockResolvedValue(fakeRegistration(null).registration);
|
||||
await push.unsubscribeFromPush();
|
||||
expect(apiDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerServiceWorker', () => {
|
||||
it('returns the registration', async () => {
|
||||
const { registration } = fakeRegistration(null);
|
||||
register.mockResolvedValue(registration);
|
||||
await expect(push.registerServiceWorker()).resolves.toBe(registration);
|
||||
});
|
||||
|
||||
it('returns null and logs when registration fails', async () => {
|
||||
register.mockRejectedValue(new Error('nope'));
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
await expect(push.registerServiceWorker()).resolves.toBeNull();
|
||||
expect(error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null without a service worker API', async () => {
|
||||
Reflect.deleteProperty(navigator, 'serviceWorker');
|
||||
await expect(push.registerServiceWorker()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
+99
-18
@@ -1,5 +1,32 @@
|
||||
import * as pushApi from '@/api/push';
|
||||
|
||||
/**
|
||||
* Web Push, browser side.
|
||||
*
|
||||
* The service worker is registered at sign-in (auth-store) so it is in place
|
||||
* whenever the user reaches a screen that can enable push. Subscribing is a
|
||||
* separate, explicit step: the browser only shows the permission prompt in
|
||||
* response to a user gesture, so it is driven from a button in Profile.
|
||||
*
|
||||
* Nothing here awaits `navigator.serviceWorker.ready`: that promise never
|
||||
* settles when no worker is registered, which turned "is push on?" into a
|
||||
* hang on any page load where registration had not happened yet.
|
||||
*/
|
||||
|
||||
export type PushSupport = 'supported' | 'unsupported' | 'denied';
|
||||
|
||||
export function pushSupport(): PushSupport {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
!('serviceWorker' in navigator) ||
|
||||
!('PushManager' in window) ||
|
||||
!('Notification' in window)
|
||||
) {
|
||||
return 'unsupported';
|
||||
}
|
||||
return Notification.permission === 'denied' ? 'denied' : 'supported';
|
||||
}
|
||||
|
||||
export async function registerServiceWorker(): Promise<ServiceWorkerRegistration | null> {
|
||||
if (!('serviceWorker' in navigator)) return null;
|
||||
|
||||
@@ -12,43 +39,97 @@ export async function registerServiceWorker(): Promise<ServiceWorkerRegistration
|
||||
}
|
||||
}
|
||||
|
||||
export async function subscribeToPush(): Promise<boolean> {
|
||||
const registration = await registerServiceWorker();
|
||||
if (!registration) return false;
|
||||
async function currentRegistration(): Promise<ServiceWorkerRegistration | null> {
|
||||
if (!('serviceWorker' in navigator)) return null;
|
||||
return (await navigator.serviceWorker.getRegistration()) ?? null;
|
||||
}
|
||||
|
||||
export type PushErrorReason = 'unsupported' | 'denied' | 'no-key' | 'failed';
|
||||
|
||||
export class PushError extends Error {
|
||||
readonly reason: PushErrorReason;
|
||||
|
||||
constructor(message: string, reason: PushErrorReason) {
|
||||
super(message);
|
||||
this.name = 'PushError';
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for permission if needed, subscribes this browser, and registers the
|
||||
* subscription with the server. Throws a `PushError` naming why it could not,
|
||||
* so the caller can tell "you blocked notifications for this site" from "the
|
||||
* server is down".
|
||||
*/
|
||||
export async function subscribeToPush(): Promise<void> {
|
||||
if (pushSupport() === 'unsupported') {
|
||||
throw new PushError(
|
||||
'This browser does not support push notifications.',
|
||||
'unsupported',
|
||||
);
|
||||
}
|
||||
|
||||
const registration =
|
||||
(await currentRegistration()) ?? (await registerServiceWorker());
|
||||
if (!registration) {
|
||||
throw new PushError('Could not register the service worker.', 'failed');
|
||||
}
|
||||
|
||||
const vapidKey = await pushApi.getVapidKey();
|
||||
if (!vapidKey) return false;
|
||||
if (!vapidKey) {
|
||||
throw new PushError(
|
||||
'Push notifications are not configured on this server.',
|
||||
'no-key',
|
||||
);
|
||||
}
|
||||
|
||||
// `subscribe` prompts for permission itself, but asking first gives a
|
||||
// clean "denied" instead of a generic failure when the user says no.
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
throw new PushError(
|
||||
'Notifications are blocked for this site. Allow them in your browser settings to turn push on.',
|
||||
'denied',
|
||||
);
|
||||
}
|
||||
|
||||
let subscription: PushSubscription;
|
||||
try {
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidKey).buffer as ArrayBuffer,
|
||||
});
|
||||
|
||||
await pushApi.subscribeToPush(subscription);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Push subscription failed:', err);
|
||||
return false;
|
||||
throw new PushError('The browser refused the push subscription.', 'failed');
|
||||
}
|
||||
|
||||
try {
|
||||
await pushApi.subscribeToPush(subscription);
|
||||
} catch (err) {
|
||||
// Do not leave a browser subscription the server knows nothing about.
|
||||
await subscription.unsubscribe().catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes this browser's subscription on the server and locally. */
|
||||
export async function unsubscribeFromPush(): Promise<void> {
|
||||
const registration = await navigator.serviceWorker?.ready;
|
||||
const registration = await currentRegistration();
|
||||
if (!registration) return;
|
||||
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (subscription) {
|
||||
await pushApi.unsubscribeFromPush(subscription.endpoint);
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
if (!subscription) return;
|
||||
|
||||
await pushApi.unsubscribeFromPush(subscription.endpoint);
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
|
||||
export async function isPushSubscribed(): Promise<boolean> {
|
||||
if (!('serviceWorker' in navigator)) return false;
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
return !!subscription;
|
||||
const registration = await currentRegistration();
|
||||
if (!registration) return false;
|
||||
return (await registration.pushManager.getSubscription()) !== null;
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useFriendsStore } from '@/stores/friends-store';
|
||||
import { getErrorMessage } from '@/lib/error';
|
||||
import { toast } from 'sonner';
|
||||
import { ArrowLeft, Search, UserPlus, Clock, Check, Loader2 } from 'lucide-react';
|
||||
|
||||
@@ -44,8 +45,7 @@ export default function AddFriend() {
|
||||
// Re-run search to update statuses
|
||||
searchUsers(query);
|
||||
} catch (err: unknown) {
|
||||
const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Failed to send request';
|
||||
toast.error(message);
|
||||
toast.error(getErrorMessage(err, 'Failed to send request'));
|
||||
} finally {
|
||||
setSendingTo(null);
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export default function AddFriend() {
|
||||
<CardContent className="flex items-center justify-between py-3 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-sm font-medium">
|
||||
{(result.displayName || result.username)[0].toUpperCase()}
|
||||
{(result.displayName || result.username).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{result.displayName || result.username}</p>
|
||||
|
||||
+105
-122
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -12,13 +12,16 @@ import {
|
||||
getPlatformStats,
|
||||
listUsers,
|
||||
recalculateScores,
|
||||
backfillGameOwners,
|
||||
generateChallenge,
|
||||
generateAllChallenges,
|
||||
retroactiveAchievements,
|
||||
backfillUsernames,
|
||||
type BatchPage,
|
||||
type PlatformStats,
|
||||
type AdminUser,
|
||||
type RecalculateResult,
|
||||
type BackfillGameOwnersResult,
|
||||
type RetroactiveAchievementsResult,
|
||||
type BackfillUsernamesResult,
|
||||
} from '@/api/admin';
|
||||
@@ -30,6 +33,8 @@ import {
|
||||
CalendarPlus,
|
||||
Trophy,
|
||||
Loader2,
|
||||
UserCheck,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function Admin() {
|
||||
@@ -65,6 +70,8 @@ export default function Admin() {
|
||||
<Separator />
|
||||
<RecalculateSection />
|
||||
<Separator />
|
||||
<BackfillGameOwnersSection />
|
||||
<Separator />
|
||||
<AchievementsSection />
|
||||
<Separator />
|
||||
<BackfillUsernamesSection />
|
||||
@@ -131,20 +138,46 @@ function StatBox({ label, value }: { label: string; value: string | number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RecalculateSection() {
|
||||
/**
|
||||
* One maintenance job: a button, a result, an error. The paged jobs return
|
||||
* `hasMore`, and the button then continues from `nextCursor` instead of
|
||||
* starting over - before WP-17 every run re-processed the same first page.
|
||||
*/
|
||||
function pageOf(result: object): Partial<BatchPage> {
|
||||
return 'hasMore' in result ? (result as BatchPage) : {};
|
||||
}
|
||||
|
||||
function BatchJobSection<T extends object>({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
run,
|
||||
failureMessage,
|
||||
renderResult,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
action: string;
|
||||
run: (cursor?: string) => Promise<T>;
|
||||
failureMessage: string;
|
||||
renderResult: (result: T) => ReactNode;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<RecalculateResult | null>(null);
|
||||
const [result, setResult] = useState<T | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleRecalculate = async () => {
|
||||
const page = result ? pageOf(result) : {};
|
||||
const cursor = page.hasMore ? (page.nextCursor ?? undefined) : undefined;
|
||||
|
||||
const handleRun = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await recalculateScores();
|
||||
setResult(res);
|
||||
setResult(await run(cursor));
|
||||
} catch {
|
||||
setError('Failed to recalculate scores.');
|
||||
setError(failureMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -153,29 +186,24 @@ function RecalculateSection() {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-lg font-semibold">
|
||||
<RefreshCw className="h-5 w-5" />
|
||||
Recalculate Scores
|
||||
<Icon className="h-5 w-5" aria-hidden="true" />
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
Recalculates scores for all completed games using the current scoring
|
||||
formula.
|
||||
</p>
|
||||
<Button onClick={handleRecalculate} disabled={loading}>
|
||||
<p className="mb-3 text-sm text-muted-foreground">{description}</p>
|
||||
<Button onClick={handleRun} disabled={loading}>
|
||||
{loading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
<Icon className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
Recalculate All Scores
|
||||
{cursor ? 'Continue' : action}
|
||||
</Button>
|
||||
{result && (
|
||||
<Alert className="mt-3">
|
||||
<AlertDescription>
|
||||
Updated {result.updated} games and {result.leaderboardUpdated}{' '}
|
||||
leaderboard entries, skipped {result.skipped} ({result.total}{' '}
|
||||
total).
|
||||
{result.backfilled > 0 &&
|
||||
` Backfilled userId on ${result.backfilled} legacy games.`}
|
||||
{renderResult(result)}
|
||||
{page.hasMore &&
|
||||
' More remains - press Continue to process the next page.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -188,112 +216,67 @@ function RecalculateSection() {
|
||||
);
|
||||
}
|
||||
|
||||
function RecalculateSection() {
|
||||
return (
|
||||
<BatchJobSection<RecalculateResult>
|
||||
icon={RefreshCw}
|
||||
title="Recalculate Scores"
|
||||
description="Recalculates stored scores for completed games using the current scoring formula, 500 games at a time."
|
||||
action="Recalculate Scores"
|
||||
run={recalculateScores}
|
||||
failureMessage="Failed to recalculate scores."
|
||||
renderResult={(r) =>
|
||||
`Updated ${r.updated} games, skipped ${r.skipped} (${r.total} in this page).`
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BackfillGameOwnersSection() {
|
||||
return (
|
||||
<BatchJobSection<BackfillGameOwnersResult>
|
||||
icon={UserCheck}
|
||||
title="Backfill Game Owners"
|
||||
description="Re-attaches legacy games that lost their player by matching them to a leaderboard entry with the same day, time and hint count. A game that matches more than one player is left alone."
|
||||
action="Backfill Game Owners"
|
||||
run={backfillGameOwners}
|
||||
failureMessage="Failed to backfill game owners."
|
||||
renderResult={(r) =>
|
||||
`Re-attached ${r.backfilled} games; ${r.ambiguous} ambiguous, ${r.unmatched} unmatched (${r.total} in this page).`
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AchievementsSection() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<RetroactiveAchievementsResult | null>(
|
||||
null,
|
||||
);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleRetroactive = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await retroactiveAchievements();
|
||||
setResult(res);
|
||||
} catch {
|
||||
setError('Failed to process retroactive achievements.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-lg font-semibold">
|
||||
<Trophy className="h-5 w-5" />
|
||||
Retroactive Achievements
|
||||
</h2>
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
Evaluate all existing games and award any achievements that should have
|
||||
been earned.
|
||||
</p>
|
||||
<Button onClick={handleRetroactive} disabled={loading}>
|
||||
{loading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trophy className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Award Retroactive Achievements
|
||||
</Button>
|
||||
{result && (
|
||||
<Alert className="mt-3">
|
||||
<AlertDescription>
|
||||
Awarded {result.totalAwarded} achievements across{' '}
|
||||
{result.usersAffected} users.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mt-3">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
<BatchJobSection<RetroactiveAchievementsResult>
|
||||
icon={Trophy}
|
||||
title="Retroactive Achievements"
|
||||
description="Evaluates existing games, streaks and versus wins and awards any achievements that should have been earned."
|
||||
action="Award Retroactive Achievements"
|
||||
run={retroactiveAchievements}
|
||||
failureMessage="Failed to process retroactive achievements."
|
||||
renderResult={(r) =>
|
||||
`Awarded ${r.totalAwarded} achievements across ${r.usersAffected} users.`
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BackfillUsernamesSection() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<BackfillUsernamesResult | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleBackfill = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await backfillUsernames();
|
||||
setResult(res);
|
||||
} catch {
|
||||
setError('Failed to backfill usernames.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-lg font-semibold">
|
||||
<Users className="h-5 w-5" />
|
||||
Backfill Normalized Usernames
|
||||
</h2>
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
Populates the normalized username column for existing users so they can
|
||||
be found via friend search.
|
||||
</p>
|
||||
<Button onClick={handleBackfill} disabled={loading}>
|
||||
{loading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Backfill Usernames
|
||||
</Button>
|
||||
{result && (
|
||||
<Alert className="mt-3">
|
||||
<AlertDescription>
|
||||
Backfilled {result.backfilled} user{result.backfilled !== 1 ? 's' : ''}.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mt-3">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
<BatchJobSection<BackfillUsernamesResult>
|
||||
icon={Users}
|
||||
title="Backfill Normalized Usernames"
|
||||
description="Populates the normalized username column for existing users so they can be found via friend search."
|
||||
action="Backfill Usernames"
|
||||
run={() => backfillUsernames()}
|
||||
failureMessage="Failed to backfill usernames."
|
||||
renderResult={(r) =>
|
||||
`Backfilled ${r.backfilled} user${r.backfilled !== 1 ? 's' : ''}.`
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Trophy, Crown, Clock, ArrowLeft } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { rawToLinkCount } from '@/types';
|
||||
import { releaseYear } from '@/lib/tmdb';
|
||||
import { getErrorMessage } from '@/lib/error';
|
||||
|
||||
function timeRemaining(expiresAt: string | null) {
|
||||
if (!expiresAt) return null;
|
||||
@@ -42,9 +43,7 @@ export default function AsyncMatchLeaderboard() {
|
||||
const res = await getAsyncLeaderboard(matchId, controller.signal);
|
||||
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');
|
||||
if (!cancelled) setError(getErrorMessage(err, 'Failed to load'));
|
||||
} finally {
|
||||
if (!cancelled) setLoadedMatchId(matchId);
|
||||
}
|
||||
|
||||
@@ -2,18 +2,16 @@ import { useEffect, useState, useRef } 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 { useGameScreen } from '@/hooks/use-game-screen';
|
||||
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 GameSearchPanel from '@/components/game/GameSearchPanel';
|
||||
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 { Target, Trophy } from 'lucide-react';
|
||||
import {
|
||||
getMatch,
|
||||
submitCreatorScore,
|
||||
@@ -36,19 +34,12 @@ export default function AsyncVersusGame() {
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const authLoading = useAuthStore((s) => s.isLoading);
|
||||
|
||||
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 screen = useGameScreen();
|
||||
const { status, chain } = screen;
|
||||
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);
|
||||
const { validateAndAddActor, validateAndAddMovie } = useChainValidation();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [mode, setMode] = useState<AsyncMode>('creator');
|
||||
@@ -217,24 +208,10 @@ export default function AsyncVersusGame() {
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'idle' || !movieA || !movieB) {
|
||||
if (!screen.ready) {
|
||||
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">
|
||||
@@ -265,45 +242,7 @@ export default function AsyncVersusGame() {
|
||||
|
||||
<ChainDisplay />
|
||||
|
||||
{status === 'playing' && (
|
||||
<div className="space-y-3">
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">{searchLabel}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<HintButton />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={undoLastLink}
|
||||
disabled={chain.length <= 1 || isValidating}
|
||||
>
|
||||
<Undo2 className="mr-1 h-3.5 w-3.5" />
|
||||
Undo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchAutocomplete
|
||||
mode={currentSearchMode}
|
||||
onSelectActor={validateAndAddActor}
|
||||
onSelectMovie={validateAndAddMovie}
|
||||
disabled={isValidating}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
|
||||
<ValidationFeedback />
|
||||
|
||||
{showLoopHint && (
|
||||
<p className="text-sm text-green-600">
|
||||
Select "{movieA.title}" to close the loop!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{status === 'playing' && <GameSearchPanel screen={screen} />}
|
||||
</div>
|
||||
|
||||
<AsyncCompletionModal
|
||||
|
||||
+1
-1
@@ -164,7 +164,7 @@ export default function Chat() {
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-medium">
|
||||
{(friend?.displayName || friend?.username || '?')[0].toUpperCase()}
|
||||
{(friend?.displayName || friend?.username || '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{friend?.displayName || friend?.username || 'Unknown'}</p>
|
||||
|
||||
@@ -96,6 +96,8 @@ export default function DailyChallenge() {
|
||||
});
|
||||
};
|
||||
|
||||
const firstChallenge = challenges[0];
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex flex-col items-center gap-8 py-12">
|
||||
@@ -104,9 +106,9 @@ export default function DailyChallenge() {
|
||||
<Calendar className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-2xl font-bold">Daily Challenges</h1>
|
||||
</div>
|
||||
{challenges.length > 0 && (
|
||||
{firstChallenge && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatDate(challenges[0].date)}
|
||||
{formatDate(firstChallenge.date)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +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 { pickDistinctPair } from '@/lib/movie-pair';
|
||||
import { Loader2, Shuffle, Filter } from 'lucide-react';
|
||||
|
||||
export default function Endless() {
|
||||
@@ -25,11 +26,12 @@ export default function Endless() {
|
||||
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);
|
||||
const distinctPair = pickDistinctPair(movies);
|
||||
if (!distinctPair) throw new Error('Not enough movies');
|
||||
const [movieA, movieB] = distinctPair;
|
||||
pair = {
|
||||
movieA: { id: shuffled[0].id, title: shuffled[0].title },
|
||||
movieB: { id: shuffled[1].id, title: shuffled[1].title },
|
||||
movieA: { id: movieA.id, title: movieA.title },
|
||||
movieB: { id: movieB.id, title: movieB.title },
|
||||
};
|
||||
}
|
||||
await startGame(pair);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useFriendsStore } from '@/stores/friends-store';
|
||||
import { useNotificationStore } from '@/stores/notification-store';
|
||||
import { createFriendChallenge } from '@/api/friends';
|
||||
import { getErrorMessage } from '@/lib/error';
|
||||
import { toast } from 'sonner';
|
||||
import { Users, UserPlus, Swords, MessageCircle, X, Check, Loader2 } from 'lucide-react';
|
||||
|
||||
@@ -103,8 +104,7 @@ export default function Friends() {
|
||||
navigate(`/versus/lobby/${match.id}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Failed to send challenge';
|
||||
toast.error(message);
|
||||
toast.error(getErrorMessage(err, 'Failed to send challenge'));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
@@ -175,7 +175,7 @@ export default function Friends() {
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-sm font-medium">
|
||||
{(friend.displayName || friend.username)[0].toUpperCase()}
|
||||
{(friend.displayName || friend.username).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{onlineFriendIds.has(friend.id) && (
|
||||
<>
|
||||
@@ -240,7 +240,7 @@ export default function Friends() {
|
||||
<CardContent className="flex items-center justify-between py-3 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-sm font-medium">
|
||||
{(from.displayName || from.username)[0].toUpperCase()}
|
||||
{(from.displayName || from.username).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{from.displayName || from.username}</p>
|
||||
|
||||
+6
-75
@@ -1,29 +1,17 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import { useChainValidation } from '@/hooks/use-chain-validation';
|
||||
import { useGameScreen } from '@/hooks/use-game-screen';
|
||||
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 GameSearchPanel from '@/components/game/GameSearchPanel';
|
||||
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 screen = useGameScreen();
|
||||
const { status } = screen;
|
||||
const navigate = useNavigate();
|
||||
const { validateAndAddActor, validateAndAddMovie } = useChainValidation();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'idle') {
|
||||
@@ -31,29 +19,10 @@ export default function Game() {
|
||||
}
|
||||
}, [status, navigate]);
|
||||
|
||||
if (status === 'idle' || !movieA || !movieB) {
|
||||
if (!screen.ready) {
|
||||
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">
|
||||
@@ -63,45 +32,7 @@ export default function Game() {
|
||||
|
||||
<ChainDisplay />
|
||||
|
||||
{status === 'playing' && (
|
||||
<div className="space-y-3">
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">{searchLabel}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<HintButton />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={undoLastLink}
|
||||
disabled={chain.length <= 1 || isValidating}
|
||||
>
|
||||
<Undo2 className="mr-1 h-3.5 w-3.5" />
|
||||
Undo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchAutocomplete
|
||||
mode={currentSearchMode}
|
||||
onSelectActor={validateAndAddActor}
|
||||
onSelectMovie={validateAndAddMovie}
|
||||
disabled={isValidating}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
|
||||
<ValidationFeedback />
|
||||
|
||||
{showLoopHint && (
|
||||
<p className="text-sm text-green-600">
|
||||
Select "{movieA.title}" to close the loop!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{status === 'playing' && <GameSearchPanel screen={screen} />}
|
||||
</div>
|
||||
|
||||
<GameCompletionModal />
|
||||
|
||||
+16
-74
@@ -2,31 +2,22 @@ import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useGameStore } from '@/stores/game-store';
|
||||
import { useGameNightStore } from '@/stores/game-night-store';
|
||||
import { useChainValidation } from '@/hooks/use-chain-validation';
|
||||
import { useGameScreen } from '@/hooks/use-game-screen';
|
||||
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 GameSearchPanel from '@/components/game/GameSearchPanel';
|
||||
import PlayersProgress from '@/components/game-night/PlayersProgress';
|
||||
import GameNightCompletionModal from '@/components/game-night/GameNightCompletionModal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Flag, Undo2 } from 'lucide-react';
|
||||
import { Flag } from 'lucide-react';
|
||||
|
||||
export default function GameNightPlay() {
|
||||
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 screen = useGameScreen();
|
||||
const { status, chain } = screen;
|
||||
const score = useGameStore((s) => s.score);
|
||||
const undoLastLink = useGameStore((s) => s.undoLastLink);
|
||||
const navigate = useNavigate();
|
||||
const { validateAndAddActor, validateAndAddMovie } = useChainValidation();
|
||||
|
||||
const sendChainUpdate = useGameNightStore((s) => s.sendChainUpdate);
|
||||
const sendPlayerComplete = useGameNightStore((s) => s.sendPlayerComplete);
|
||||
@@ -63,24 +54,10 @@ export default function GameNightPlay() {
|
||||
}
|
||||
}, [lobbyState, roomId, navigate]);
|
||||
|
||||
if (status === 'idle' || !movieA || !movieB) {
|
||||
if (!screen.ready) {
|
||||
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="flex gap-6">
|
||||
@@ -93,51 +70,16 @@ export default function GameNightPlay() {
|
||||
<ChainDisplay />
|
||||
|
||||
{status === 'playing' && !isSpectator && (
|
||||
<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>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={sendPlayerForfeit}
|
||||
>
|
||||
<Flag className="mr-1 h-3.5 w-3.5" />
|
||||
I Give Up
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchAutocomplete
|
||||
mode={currentSearchMode}
|
||||
onSelectActor={validateAndAddActor}
|
||||
onSelectMovie={validateAndAddMovie}
|
||||
disabled={isValidating}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
|
||||
<ValidationFeedback />
|
||||
|
||||
{showLoopHint && (
|
||||
<p className="text-sm text-green-600">
|
||||
Select "{movieA.title}" to close the loop!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<GameSearchPanel screen={screen}>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={sendPlayerForfeit}
|
||||
>
|
||||
<Flag className="mr-1 h-3.5 w-3.5" />
|
||||
I Give Up
|
||||
</Button>
|
||||
</GameSearchPanel>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, Link } from 'react-router';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import StaticChainDisplay from '@/components/game/StaticChainDisplay';
|
||||
import ChainDisplay from '@/components/game/ChainDisplay';
|
||||
import ScoreDisplay from '@/components/game/ScoreDisplay';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { getGameSession, type GameHistoryEntry } from '@/api/games';
|
||||
import { ArrowLeft, Clock, Lightbulb, Calendar } from 'lucide-react';
|
||||
import { releaseYear } from '@/lib/tmdb';
|
||||
import { formatTime } from '@/lib/format';
|
||||
import { isAbortError } from '@/lib/abort';
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function GameReview() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [game, setGame] = useState<GameHistoryEntry | null>(null);
|
||||
@@ -97,7 +92,7 @@ export default function GameReview() {
|
||||
{Array.isArray(game.chain) && game.chain.length > 0 && (
|
||||
<div>
|
||||
<h2 className="mb-3 text-lg font-semibold">Chain</h2>
|
||||
<StaticChainDisplay
|
||||
<ChainDisplay
|
||||
chain={game.chain}
|
||||
movieAId={game.movieAId}
|
||||
movieBId={game.movieBId}
|
||||
|
||||
+4
-46
@@ -7,39 +7,7 @@ import PageLayout from '@/components/layout/PageLayout';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Loader2, Calendar, Swords, Shuffle, Crosshair, Users } 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);
|
||||
}
|
||||
import { pickDistinctPair } from '@/lib/movie-pair';
|
||||
|
||||
async function getRandomTmdbPair(): Promise<MoviePair> {
|
||||
// Pick a random page from the first 20 pages of popular movies
|
||||
@@ -47,21 +15,11 @@ async function getRandomTmdbPair(): Promise<MoviePair> {
|
||||
const data = await discoverMovies(page);
|
||||
const movies = data.results.filter((m) => m.title && m.release_date);
|
||||
|
||||
if (movies.length < 2) {
|
||||
const pair = pickDistinctPair(movies);
|
||||
if (!pair) {
|
||||
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);
|
||||
const 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;
|
||||
}
|
||||
}
|
||||
const [movieA, movieB] = pair;
|
||||
|
||||
return {
|
||||
movieA: { id: movieA.id, title: movieA.title },
|
||||
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
import { formatTime } from '@/lib/format';
|
||||
|
||||
type Period = 'daily' | 'weekly' | 'all-time';
|
||||
type DifficultyFilter = 'all' | 'easy' | 'medium' | 'hard';
|
||||
@@ -23,12 +24,6 @@ const DIFFICULTIES: { value: DifficultyFilter; label: string }[] = [
|
||||
{ value: 'hard', label: 'Hard' },
|
||||
];
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function GameModeBadge({ mode }: { mode: string }) {
|
||||
switch (mode) {
|
||||
case 'daily':
|
||||
|
||||
@@ -14,12 +14,8 @@ import { getErrorMessage } from '@/lib/error';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { User, Flame, Trophy, BarChart3, LogOut, Pencil, Check, X, KeyRound, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
import { formatTime } from '@/lib/format';
|
||||
import PushNotificationsSection from '@/components/profile/PushNotificationsSection';
|
||||
|
||||
export default function Profile() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
@@ -274,6 +270,11 @@ export default function Profile() {
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Notifications */}
|
||||
<PushNotificationsSection />
|
||||
|
||||
<Separator />
|
||||
|
||||
{loading && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { rawToLinkCount } from '@/types';
|
||||
import { getErrorMessage } from '@/lib/error';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type Difficulty = 'easy' | 'medium' | 'hard';
|
||||
@@ -183,8 +184,7 @@ export default function Versus() {
|
||||
navigate(`/versus/lobby/${matchId}`);
|
||||
} catch (err: unknown) {
|
||||
if (pw) {
|
||||
const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
setJoinError(message ?? 'Incorrect password');
|
||||
setJoinError(getErrorMessage(err, 'Incorrect password'));
|
||||
}
|
||||
} finally {
|
||||
setJoining(null);
|
||||
@@ -198,9 +198,9 @@ export default function Versus() {
|
||||
setPasswordPromptMatch(null);
|
||||
navigate(`/versus/async-game/${matchId}`);
|
||||
} catch (err: unknown) {
|
||||
const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
const message = getErrorMessage(err, '');
|
||||
if (pw) {
|
||||
setJoinError(message ?? 'Incorrect password');
|
||||
setJoinError(message || 'Incorrect password');
|
||||
} else if (message === 'You have already attempted this match') {
|
||||
navigate(`/versus/async/${matchId}/leaderboard`);
|
||||
}
|
||||
|
||||
@@ -2,31 +2,20 @@ 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 { useGameScreen } from '@/hooks/use-game-screen';
|
||||
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 GameSearchPanel from '@/components/game/GameSearchPanel';
|
||||
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 screen = useGameScreen();
|
||||
const { status, chain } = screen;
|
||||
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);
|
||||
const sessionId = useGameStore((s) => s.sessionId);
|
||||
@@ -51,24 +40,10 @@ export default function VersusGame() {
|
||||
}
|
||||
}, [status, navigate]);
|
||||
|
||||
if (status === 'idle' || !movieA || !movieB) {
|
||||
if (!screen.ready) {
|
||||
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">
|
||||
@@ -80,45 +55,7 @@ export default function VersusGame() {
|
||||
|
||||
<ChainDisplay />
|
||||
|
||||
{status === 'playing' && (
|
||||
<div className="space-y-3">
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">{searchLabel}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<HintButton />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={undoLastLink}
|
||||
disabled={chain.length <= 1 || isValidating}
|
||||
>
|
||||
<Undo2 className="mr-1 h-3.5 w-3.5" />
|
||||
Undo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchAutocomplete
|
||||
mode={currentSearchMode}
|
||||
onSelectActor={validateAndAddActor}
|
||||
onSelectMovie={validateAndAddMovie}
|
||||
disabled={isValidating}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
|
||||
<ValidationFeedback />
|
||||
|
||||
{showLoopHint && (
|
||||
<p className="text-sm text-green-600">
|
||||
Select "{movieA.title}" to close the loop!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{status === 'playing' && <GameSearchPanel screen={screen} />}
|
||||
</div>
|
||||
|
||||
<VersusCompletionModal />
|
||||
|
||||
@@ -97,10 +97,6 @@ export const useGameNightStore = create<GameNightState>((set, get) => ({
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('player-joined', () => {
|
||||
// Player joins are handled via room-state updates
|
||||
});
|
||||
|
||||
socket.on('player-left', (data: { userId: string }) => {
|
||||
set((state) => ({
|
||||
players: state.players.filter((p) => p.userId !== data.userId),
|
||||
@@ -124,11 +120,8 @@ export const useGameNightStore = create<GameNightState>((set, get) => ({
|
||||
set({ lobbyState: 'countdown', countdownValue: data.value });
|
||||
});
|
||||
|
||||
socket.on('game-start', (data: unknown) => {
|
||||
socket.on('game-start', () => {
|
||||
set({ lobbyState: 'playing' });
|
||||
const handler = (get() as { _onGameStart?: (d: unknown) => void })
|
||||
._onGameStart;
|
||||
if (handler) handler(data);
|
||||
});
|
||||
|
||||
socket.on(
|
||||
|
||||
@@ -279,7 +279,7 @@ describe('movies without a release date', () => {
|
||||
getMovieDetails.mockImplementation((id: number) => Promise.resolve(undated(id)));
|
||||
const result = await useGameStore.getState().startGameFromVersus(PAIR, 1);
|
||||
expect(result).toBe('started');
|
||||
expect(createGameSession.mock.calls[0][0]).not.toHaveProperty('movieAReleaseDate');
|
||||
expect(createGameSession.mock.calls[0]?.[0]).not.toHaveProperty('movieAReleaseDate');
|
||||
});
|
||||
|
||||
it('are tolerated by a single-player start', async () => {
|
||||
@@ -287,7 +287,7 @@ describe('movies without a release date', () => {
|
||||
await useGameStore.getState().startGame(PAIR);
|
||||
await flush();
|
||||
expect(useGameStore.getState().status).toBe('playing');
|
||||
expect(createGameSession.mock.calls[0][0]).not.toHaveProperty('movieBReleaseDate');
|
||||
expect(createGameSession.mock.calls[0]?.[0]).not.toHaveProperty('movieBReleaseDate');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ function reconcileChain(serverChain: ChainLink[]): void {
|
||||
|
||||
const merged = [movieA, ...serverChain];
|
||||
const lastLink = merged[merged.length - 1];
|
||||
if (!lastLink) return; // unreachable: merged always has at least movieA
|
||||
useGameStore.setState({
|
||||
chain: merged,
|
||||
currentSearchMode: lastLink.type === 'movie' ? 'actor' : 'movie',
|
||||
@@ -384,6 +385,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
||||
|
||||
const newChain = chain.slice(0, -1);
|
||||
const lastLink = newChain[newChain.length - 1];
|
||||
if (!lastLink) return; // unreachable: chain.length > 1 guarantees newChain is non-empty
|
||||
|
||||
// Recompute passedMovieB
|
||||
const hasPassedMovieB = newChain.some(
|
||||
|
||||
@@ -108,13 +108,8 @@ export const useVersusStore = create<VersusState>((set, get) => ({
|
||||
set({ lobbyState: 'countdown', countdownValue: data.value });
|
||||
});
|
||||
|
||||
socket.on('game-start', (data: unknown) => {
|
||||
socket.on('game-start', () => {
|
||||
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 { _onGameStart?: (d: unknown) => void })
|
||||
._onGameStart;
|
||||
if (handler) handler(data);
|
||||
});
|
||||
|
||||
socket.on('opponent-progress', (data: { chainLength: number }) => {
|
||||
|
||||
@@ -81,17 +81,3 @@ export interface TmdbMovieDetailsResponse extends TmdbMovieResult {
|
||||
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;
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
|
||||
Reference in new issue
Block a user