Public Access
218 lines
13 KiB
Markdown
218 lines
13 KiB
Markdown
# To Be Played – Frontend
|
||
|
||
Web app of **To Be Played**, a Steam backlog tracker: import your Steam library, decide what to play, and track what
|
||
you finished. The UI is in German and English, with light and dark mode.
|
||
|
||

|
||
|
||
| Game detail | Public profile | Mobile |
|
||
|---|---|---|
|
||
|  |  |  |
|
||
|
||
To Be Played is split into three repositories:
|
||
|
||
| Repository | Role |
|
||
|---|---|
|
||
| ToBePlayed-Auth | Users, login, settings, signs JWTs |
|
||
| ToBePlayed-Core | Steam library, backlog, wishlist, stats; verifies JWTs |
|
||
| **ToBePlayed-Frontend** (this one) | React single-page app |
|
||
|
||
## Features
|
||
|
||
- **Dashboard** with an edit mode ("Customize"): the user orders, hides and resizes the tiles (small = half width
|
||
and less content, large = double width and more), and can add the stat groups of "Your stats" as tiles. Stored
|
||
in Auth, so it follows the user to the phone; "Reset" goes back to the default. Tiles: most played games, games in progress, "Pile of Shame" with backlog hours, its trend of the last 90
|
||
days ("this month: +N added, -N off the pile") and a random suggestion,
|
||
Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked
|
||
total, perfect games, average completion and the 5 games closest to 100 %.
|
||
- **What to play next** (`/play-next`): three backlog picks for a time budget (up to 5 / 15 / 40 h or any) and a mood
|
||
(story, co-op, chill, action, scary from IGDB; speedrun from speedrun.com), high priority first, reroll without
|
||
repeats, and "Start playing" in one click.
|
||
- **Your stats** (`/stats`): totals and statuses; the pile in hours, in weeks at your pace of the last 90 days and in
|
||
years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods;
|
||
trips to the moon, the longest gaming day, games started but under one hour; finished games per year.
|
||
- **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
|
||
in library, rating or priority, paging. Each row shows the unlocked achievements.
|
||
- **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit,
|
||
a platform and optional hours. Works without a Steam ID. Games already in ToBePlayed come first. Check several
|
||
hits to add them at once through the import review.
|
||
- **Import** (`/import`): a list (one title per line) or a CSV (title, platform, hours or minutes, status). Core
|
||
matches the lines with IGDB and saves them in the background; the page shows the steps (queued with the imports
|
||
before yours, searching on IGDB, your review, adding to the library, time to beat and moods, done) with the
|
||
progress, and "Try again" for an import that stopped. The review shows the best match, other candidates and
|
||
"search again", with the platform and status per line. Only sure matches are checked. Earlier imports can be
|
||
undone.
|
||
- **Platform everywhere**: each game shows its platform ("Steam" for Steam games). The favorite platform from the
|
||
settings is preselected when a game is on it; otherwise a game on one platform gets that one, and a game on
|
||
several waits for the user's pick.
|
||
- **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %),
|
||
a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a
|
||
game still in the backlog, and one to set the status to 100 % when
|
||
every achievement is unlocked.
|
||
- **Confetti**: opening a finished game plays a small confetti burst, a finished game with every achievement a big
|
||
one; setting the status to finished plays it at once. Logo and accent colors, a canvas of our own (no library),
|
||
nothing with "reduce motion".
|
||
- **Finished?**: Core marks a game as finished by itself when the ending achievement is unlocked and the status was
|
||
never set by hand. Otherwise the game page asks: "You unlocked the ending, finished?" or "You played longer than
|
||
most players need, finished?", with "Mark as finished" and "Not yet".
|
||
- **Hide games**: a Steam game (e.g. a playtest or a tool) can be hidden on its detail page. It leaves the lists,
|
||
the dashboard, the stats and the public profile; the library's "Hidden" filter shows it again for unhiding.
|
||
- **Wishlist** with store price and discount.
|
||
- **Steam account** linked in the settings with "Sign in through Steam": the user signs in on Steam and Auth gets
|
||
the proved Steam ID, never a typed-in one. A Steam ID typed in before shows as "not verified" with a button to
|
||
verify it; unlinking is possible.
|
||
- **Public profile** at `/u/<display name>`, opt-in in the settings, with perfect games and average completion.
|
||
The owner arranges it with the same edit mode on the profile page itself (the tiles, incl. the stat groups of
|
||
"Your stats"), and sets its look in the settings: accent, corners, banner and background from own game art
|
||
("Profile art" on a game's page). Visitors see the owner's look on that page only; light or dark mode stays theirs.
|
||
- **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art.
|
||
Saved in Auth, so it follows you to every device.
|
||
- **Language**: kept in the browser and saved in Auth; after the login Auth's language wins when one is saved. Dark
|
||
mode stays per device.
|
||
- **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported
|
||
to Core once per page load (`POST /api/library/broken-images`, only with a session). Core repairs it at night;
|
||
a repaired cover comes back as `imageUrl`.
|
||
- German and English, dark mode, mobile layout, session-expired message instead of a silent logout.
|
||
- **Admin** (PLATFORM_ADMIN only, `/admin/users`): search the users, block, unblock and delete them (the email is
|
||
typed to confirm a delete). "View as" in the menu switches the own session to User or Auditor and back; a badge
|
||
shows the lower role. Auth checks every right on the server.
|
||
- **Dashboard hints**: news from the admin at the bottom of the dashboard, one at a time. A "Dismiss" hint closes
|
||
after 30 s or with X and comes back in the next browser session unless "Do not show again" is ticked; an
|
||
"Accept" hint stays until "OK". Admins write, preview and end hints on `/admin/hints` (Auth stores them).
|
||
- **Correct game data** (AUDITOR or higher): "Correct data" on the game detail page changes name, header image,
|
||
speedrun link, moods, endless flag and genres for every user; each field goes back to the source on its own.
|
||
"Merge duplicate" joins two entries of the same game (e.g. the Steam game and the same game added by hand) for
|
||
every user.
|
||
- **Delete my account** in the settings: a dialog asks for the own email and the password. Auth blocks the
|
||
account at once and removes it after 30 days; the page ends the session and goes to the landing page.
|
||
- **Build version** in the settings: commit and build time of Frontend, Auth and Core, each hash links to its
|
||
commit. CI passes them as Docker build args; a local build shows `dev`.
|
||
- **Installable** as an app on phone and desktop (web app manifest, no service worker, so no offline mode).
|
||
- **Session**: the tokens are HttpOnly cookies that no script can read. `apiFetch` renews an expired access token
|
||
once and sends the request again; parallel requests and other tabs share one refresh.
|
||
|
||
## Tech stack
|
||
|
||
- React 19, React Router 7, Vite 8
|
||
- Tailwind CSS 4, Radix UI primitives (shadcn/ui style components), Lucide icons
|
||
- react-hook-form + zod for forms, i18next for translations (`public/i18n`)
|
||
- Vitest + React Testing Library + jsdom
|
||
- Production image: nginx (Alpine), SPA fallback, long-term caching for hashed assets, Content Security Policy
|
||
and security headers
|
||
- ESLint with the React hooks rules (`npm run lint`)
|
||
- CI: Forgejo Actions (`npm ci`, `npm run lint`, `npm test`, `npm run build`, Docker image)
|
||
|
||
## Architecture
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
Browser["Browser"] --> Proxy["Traefik (TLS)"]
|
||
Proxy --> Frontend["ToBePlayed-Frontend<br/>nginx, static files"]
|
||
Proxy --> Auth["ToBePlayed-Auth"]
|
||
Proxy --> Core["ToBePlayed-Core"]
|
||
Core -->|"JWKS"| Auth
|
||
Auth --> Db[("PostgreSQL")]
|
||
Core --> Db
|
||
Core --> Steam["Steam Web API"]
|
||
Core --> Igdb["IGDB API"]
|
||
Core -->|"OAuth token"| Twitch["Twitch"]
|
||
Core --> Speedrun["speedrun.com API"]
|
||
Browser -.->|"game art"| Images["Steam and IGDB image hosts"]
|
||
Browser -.->|"Sign in through Steam"| SteamLogin["steamcommunity.com (OpenID)"]
|
||
Auth -->|"check_authentication"| SteamLogin
|
||
```
|
||
|
||
- nginx serves the built app. It has no backend of its own: the browser calls Auth and Core directly
|
||
(`VITE_API_URL`, `VITE_CORE_API_URL`). The Content Security Policy allows only these two hosts and the image hosts.
|
||
- Auth and Core each have their own database. Core verifies Auth's tokens with Auth's public keys (JWKS).
|
||
- Calls for the logged-in user go through `src/utils/api.js`. `clientFetch` sends the HttpOnly cookies and the
|
||
`X-TBP-Client` header. `apiFetch` answers a `401` with one `POST /api/auth/refresh` and sends the request again.
|
||
Parallel requests and other tabs share this refresh (`navigator.locks`). When the second answer is still a
|
||
`401`, `apiFetch` ends the session (`expireSession`); the pages only stop. `request()` wraps `apiFetch` as
|
||
`{ok, status, data}` and never throws. Public pages (landing page, public profile) and the public
|
||
`GET /api/version` in the settings use plain `fetch`.
|
||
- `src/utils/auth.js` is the session store: `markSession`, `clearSession` (logout) and `expireSession` (a `401`)
|
||
tell every `useSession()`, also on a login or logout in another tab. The user (`CurrentUserProvider`) and the
|
||
look (`PreferencesProvider`) follow it.
|
||
- The pages that need a login sit under the `RequireSession` layout route in `App.jsx`. Without a session it opens
|
||
the login page: after a `401` with the "session expired" message and back to the same page, after a logout
|
||
without a way back. A public page stays open when the session ends there.
|
||
- `CurrentUserProvider` holds `GET /api/users/me` for the whole app (`useCurrentUserContext`, `useCurrentUser`).
|
||
Dashboard and settings load it again when they open.
|
||
- Tests render through `src/test/renderWithProviders.jsx` (a data router like `main.jsx`) and answer requests
|
||
with `src/test/fetchRoutes.js`, which fails a test on any request without a route.
|
||
- `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game
|
||
detail page) to Core.
|
||
- `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again.
|
||
- `PUT /api/users/settings` replaces dark mode, language, display name and public profile at once, so every save
|
||
goes through `saveSettings` in `src/utils/settings.js`: one after the other, each on a fresh `GET /api/users/me`.
|
||
- Pages keep a load error as its i18n key and translate it when rendering, so a language switch loads nothing
|
||
again.
|
||
|
||
## Project structure
|
||
|
||
```text
|
||
src/
|
||
├── pages/ # One component per route (Dashboard, GameLibrary, GameDetail, ...)
|
||
├── components/ # Shared components; ui/ holds the base components
|
||
├── hooks/ # Data hooks (library sync, Steam profile, appearance, session expiry)
|
||
├── context/ # PreferencesContext: theme, language, appearance
|
||
├── utils/ # Validation schemas and helpers
|
||
└── test/ # Test setup (i18n mock)
|
||
```
|
||
|
||
Tests sit next to the code they test (`Component.test.jsx`).
|
||
|
||
## Run locally
|
||
|
||
### Local development without keys (profile `local`)
|
||
|
||
The whole stack runs on your machine with no API keys and no calls to Steam, Twitch/IGDB or speedrun.com. Needs
|
||
Java 25, Node 24 and Docker. Clone `ToBePlayed-Auth`, `ToBePlayed-Core` and `ToBePlayed-Frontend` next to each
|
||
other, then start them in this order (Core also starts without Auth and finds it later):
|
||
|
||
1. **Auth**: in `ToBePlayed-Auth`, `./mvnw spring-boot:run -Dspring-boot.run.profiles=local`
|
||
(IDE: active profile `local`). Port 8080.
|
||
2. **Core**: in `ToBePlayed-Core`, `./mvnw spring-boot:run -Dspring-boot.run.profiles=local`. Port 8090.
|
||
3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173`
|
||
(`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development
|
||
values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`.
|
||
4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 8
|
||
games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers.
|
||
|
||
The dev user is `PLATFORM_ADMIN`, so user, auditor and admin pages can all be tried ("View as" in the menu
|
||
switches down). What the profile does in Auth and Core: their readmes, "Run locally".
|
||
|
||
### Against other services
|
||
|
||
Needs Node 24 and running Auth and Core services.
|
||
|
||
```bash
|
||
npm install
|
||
npm run dev
|
||
```
|
||
|
||
| Variable | Purpose |
|
||
|---|---|
|
||
| `VITE_API_URL` | Base URL of the Auth service |
|
||
| `VITE_CORE_API_URL` | Base URL of the Core service |
|
||
|
||
Values for development and production live in `.env.development` and `.env.production`. They end up in the
|
||
browser bundle, so they must never hold a secret.
|
||
|
||
## Tests and build
|
||
|
||
```bash
|
||
npm run lint
|
||
npm test
|
||
npm run build
|
||
```
|
||
|
||
## Roadmap
|
||
|
||
See the roadmap in the ToBePlayed-Core readme.
|
||
|
||
## License
|
||
|
||
All rights reserved. The source is public for reading only.
|