TehRiehlDeal ef7b8aa878
frontend-ci / secrets-scan (push) Successful in 4s
frontend-ci / sast (push) Successful in 7s
frontend-ci / fs-scan (push) Successful in 14s
frontend-ci / typecheck (push) Successful in 24s
frontend-ci / lint (push) Successful in 26s
frontend-ci / test (push) Successful in 32s
frontend-ci / build (push) Successful in 38s
frontend-ci / push (push) Skipped
frontend-ci / lint (pull_request) Successful in 24s
frontend-ci / test (pull_request) Successful in 31s
frontend-ci / secrets-scan (pull_request) Successful in 5s
frontend-ci / sast (pull_request) Successful in 8s
frontend-ci / fs-scan (pull_request) Successful in 15s
frontend-ci / typecheck (pull_request) Successful in 14s
frontend-ci / build (pull_request) Successful in 38s
frontend-ci / push (pull_request) Skipped
feat(icons): render the app icons from the Movie Loop mark
Safari, the iOS home screen, Android install and push notifications
still showed the old film-reel artwork beside the new theme colour. The
PNGs are now rendered from the SVG mark by `npm run icons` (Inkscape,
no image library in node_modules): the bare mark for the 32px favicon,
the mark on the stage colour for app icons, and a maskable variant with
the extra margin Android's adaptive crop needs.
2026-09-12 22:52:39 -07:00
2026-03-08 00:11:16 -08:00
2026-05-05 17:15:50 -07:00
2026-03-11 11:36:21 -07:00
2026-03-08 00:11:16 -08:00
2026-03-08 00:11:16 -08:00

movieloop-frontend

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 as you type; the server scores the result.

React 19 · TypeScript (strict) · Vite 7 · Tailwind CSS v4 + shadcn/Base UI · React Router 7 · Zustand · Axios · socket.io-client.

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.

What's in the app

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)

Every route is code-split with React.lazy; vendor libraries are chunked separately so a deploy invalidates only the app chunk.

Running it

With Docker (the normal way)

From the parent folder:

docker compose up -d          # postgres, redis, rabbitmq, backend, and this app on http://localhost:5173

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.

Natively

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

npm run lint          # eslint --max-warnings 0, including jsx-a11y
npx tsc -b
npm test              # vitest + testing-library, jsdom
npm run test:cov      # what CI runs: fails below 90% lines/branches/functions/statements
npm run build         # tsc -b && vite build -> dist/
npm run preview       # serve dist/ locally
npm run icons         # re-render public/*.png from the SVG mark (needs Inkscape)

Coverage thresholds live in vite.config.ts; every source file is above them individually. The suite mocks the axios client and fails loudly on any request it did not route, and mocks the socket factory with a controllable fake, so no test can reach a real backend. Pages are rendered with real Zustand stores seeded via setState, so the stores' selectors and actions are covered by the page tests as well as their own. One toolchain note: vi.useFakeTimers() combined with @testing-library/user-event deadlocks under this vitest/jsdom combination — tests that need both use real timers and waitFor.

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 + LoopReturn (the serpentine chain), MovieMarker, SearchAutocomplete (ARIA combobox), GameHeader, completion modal…
│   ├── game-night/, versus/
│   ├── layout/       PageLayout, PageHeader, LogoMark, 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, use-image-fallback
├── lib/              config, error, format, movie-pair(s), example-loop (the Home hero), motion, 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, favicon.svg (the mark), and the PNG icons rendered from it by scripts/make-icons.mjs
└── sounds/

Design

The look is one system, defined in src/index.css and nowhere else.

  • Two typefaces, two jobs. Bricolage Grotesque (font-display) carries the personality: the wordmark, page titles, the puzzle statement, the score. Geist (font-sans) does everything that has to be read quickly. Both are self-hosted through @fontsource-variable. The type scale is declared as Tailwind --text-* tokens; 12px (text-xs) is the floor and text-6xl the ceiling - the larger steps are unset on purpose.
  • Gold is the primary in both themes. Light is the matinee, dark is house lights down, and buttons, focus rings and the mark keep their colour when the theme toggles. text-primary-text is the gold to use as text: a bronze on light that clears 4.5:1, the primary itself on dark.
  • Three-strip Technicolor for the game. The blue record is Movie A (movie-a), the magenta record is Movie B (movie-b), and the yellow record is the loop: connectors turn gold when it closes. Green (success) is only for non-game states - online, ready - and never means "correct". Identity lives on the poster (MovieMarker chip + ring), never on a whole row.
  • Structure encodes state. Difficulty is monochrome with ordinal pips, page titles come from one PageHeader, sections are separated by a rule above them, and lists are hairline rows rather than cards.
  • One orchestrated moment. When a loop closes, the connectors light top to bottom, LoopReturn draws the line back to Movie A, the closing row glows, and only then does the score arrive. Everything else moves only in answer to the player.

To add a colour, add a token to both theme blocks and a --color-* alias in @theme inline; do not reach for a stock Tailwind palette colour in a component.

The mark lives once as public/favicon.svg. The PNGs Safari, iOS, Android and push notifications need are rendered from it and from scripts/icons/app-icon*.svg (the mark on the stage colour, inside the maskable safe zone) by npm run icons, which drives Inkscape - on PATH, at INKSCAPE, or in its default install folder. Edit the SVGs, run it, commit the PNGs.

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, the game objective and countdowns are announced through live regions, there is a skip link, and prefers-reduced-motion collapses every animation - including the loop-closing sequence, in which case the score modal opens at once instead of waiting for it.

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. The CSP allows index.html's inline theme script by hash; npm run build computes that hash from the built file (scripts/csp-inline-script-hashes.mjs) and the image carries it, so editing the script needs no manual step. 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.

S
Description
No description provided
Readme MIT
4.1 MiB
0 Stars 1 Watchers 0 Forks
Languages
TypeScript 97.7%
CSS 0.8%
JavaScript 0.7%
Shell 0.4%
Dockerfile 0.2%
Other 0.2%