Public Access
Digest notes only showed counts. Now a digest note opens to list its
games and items, grouped by kind. The list loads once, from
GET /api/inbox/{id}/details. Achievements show 5 per game first.
418 lines
32 KiB
Markdown
418 lines
32 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 and games in progress (one call, `GET /api/library/dashboard-games`), "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 %. The card counts PlayStation trophies too
|
||
(platinum = perfect) and also shows for a user with only a PlayStation link.
|
||
- **Coming games**: a game that is not released yet (and nobody played) shows a "Coming <date>" badge in the library, on
|
||
the details page, in the stat game lists and on the public profile. The pile counts only the games Core sends in
|
||
`pileGames`, so coming games are not on it; Core never picks them.
|
||
- **What to play next** (`/play-next`): seven rows of up to 3 backlog games each: up next (your own backlog order),
|
||
next in series (the next part of a series you played), because you played (a similar game for each of your 3 most
|
||
played), same studio (a developer you played), from people you follow (a backlog game someone you follow finished or played 5 h or more), longest on your list, and mood picks. A time
|
||
budget (up to 5 / 15 / 40 h or any) filters all rows; a mood (story, co-op, chill, action, scary from IGDB; speedrun
|
||
from speedrun.com) only the mood row. Each row has its own reroll without repeats, and "Start playing" in one click.
|
||
On a phone each row scrolls sideways.
|
||
- **Backlog order** (`/backlog`, third library tab): all backlog games in your own order. Move a game by drag (pointer
|
||
events, no library, so it works with touch), by the up and down buttons, or "move to top / bottom" in the menu. Each
|
||
move saves the whole list. The "Up next" dashboard tile shows the first games; the picker's "Up next" row shows the first games.
|
||
- **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; games per platform.
|
||
- **Click a number** (stats page, dashboard, public profile, landing page, Pile Points): a number, bar, row or year
|
||
opens the games behind it in a dialog (a bottom sheet on phones), 40 per page with "Load more". In your own
|
||
views the status counts and the pile link to the library with the filter instead. Not clickable: sums (playtime),
|
||
averages, rates and the moon trips. The list always uses the same rules as the number.
|
||
- **Pile Points** (`/points`): points for playing games off your pile (start, finish by time to beat, 100 %, month and
|
||
streak bonuses; Core checks proof of play), spent in a small shop on an avatar border, a badge, a premium accent, a
|
||
banner frame, an effect for 100 % games and an animated page or banner background (CSS and SVG in `effects.css`, two canvas engines in `EffectCanvas`; layers follow the mouse and the phone tilt, on iPhone after the switch in the settings),
|
||
shown on the dashboard and the public profile. Twelve more items are unlock-only: they show locked with a progress bar
|
||
until a goal is reached (games finished, 100 % games, weeks in a row, months with a smaller pile, months with more
|
||
play time). Skins (`skins.css`, Pastel, Retro CRT, Cyberpunk bought, Steam unlocked) are full themes made of CSS
|
||
variables; a skin wins over accent and corners, while badges, borders, frames and effects stay on top. The balance is in the header. Every effect stands still (canvas: one static frame, no loop) with the
|
||
system setting "reduce motion" or the "Reduce animations" switch in the settings. After every status save (game page,
|
||
Play Next "Start", Questions, add game) `usePointsCheck` asks Core `GET /api/rewards/games/{appid}`, reloads the wallet
|
||
when the balance moved and shows a toast ("+N points", "Already paid on ...", "No points yet: reason"). The game page
|
||
shows the paid rows and the reason per missing kind (`GamePointsLine`); `pointsNotice` in `utils/rewards.js` picks the
|
||
toast, the reason codes come from Core and `POINTS.REASONS` only maps them to texts. A finish without proof pays 10
|
||
early points (toast "+10 points; the rest comes with proof", game line "The rest of the finish points: play once on
|
||
Steam after finishing"), and the rest once Steam play proves it. A "Finished?" card on the Questions page shows
|
||
"Up to N points" from `GET /api/rewards/expected`; when that call fails the card shows no line.
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
Save["Status save"] --> Check["usePointsCheck"]
|
||
Check -->|"GET /api/rewards/games/{appid}"| Core["Core: settle + paid rows + reasons"]
|
||
Check --> Toast["Toast"]
|
||
Check -->|balance moved| Wallet["reloadWallet: header"]
|
||
Core --> Line["GamePointsLine on the game page"]
|
||
Questions["Questions page"] -->|"GET /api/rewards/expected"| CoreExpected["Core /api/rewards/expected"]
|
||
```
|
||
- **Landing page**: next to the community numbers and the top games, three blocks: games per platform, top genres and
|
||
finished games per year of all users.
|
||
- **Library**: status filters, source filter (Steam, PlayStation, manual; a linked set matches on each of its sources), "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. A game you own on several
|
||
platforms is one row (Core's counted edition) with a small badge per platform next to the title (`PlatformIcons`:
|
||
ST, PS or the platform name, from `platforms[]`; lucide has no brand icons).
|
||
- **Editions**: the editions of one game (Steam and PS5 copy, remaster, port) are linked automatically from IGDB's
|
||
"same game" key; an auditor can also link or unlink them for every user. A user who owns two or more of a linked
|
||
set sees "Also in your library" on the details page and an "x2" badge in the library; the library lists the set
|
||
once (no "Hide duplicates" filter any more), and the numbers count it once. The editions of a set share one status, rating and notes (the page
|
||
reloads after a save to show it). "Not the same game" splits the game off its set for this user only, and it stays
|
||
split. "Same game as..." on the details page asks the auditors for a link.
|
||
- **Detail per platform**: the details page of a game with several platforms (or a PlayStation title on a Steam game)
|
||
shows `PlatformsCard` above the editions: one row per platform with playtime, last played and the achievement or
|
||
trophy progress of that platform, a total row, and a link to the edition whose page lists the achievements. The hero
|
||
playtime and the library card show the set total (`totalPlaytimeMinutes`); sorting, stats, dashboard cards and the public profile use the set total (Core V45); per row stay the playtime history, rewards and the played / never started counts.
|
||
- **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.
|
||
- **Export files** in the same file field, told apart by their header (`src/utils/importFormats.js`): GOG Galaxy 2.0
|
||
(the community script [GOG-Galaxy-Export-Script](https://github.com/AB1908/GOG-Galaxy-Export-Script), minutes),
|
||
Grouvee (Settings, "Export your collection", shelves as status, the wish list is left out), HowLongToBeat (its CSV
|
||
export, the lists as status, "Ignored" left out) and Playnite (the add-on "Library Exporter Advanced", completion
|
||
status, seconds). Store and platform names become IGDB abbreviations (`platformFromNames`); an unknown one stays
|
||
empty and the review preselects. **Unverified:** each format is built from the exporter's public source, not from
|
||
a real file; the test files in `src/test/fixtures/import` are made up.
|
||
- **Store pages** (tab "Store page", `src/utils/storeList.js`): a bookmarklet for PlayStation
|
||
(`library.playstation.com`), Xbox (`www.xbox.com`), Nintendo eShop (`ec.nintendo.com`), Epic Games
|
||
(`www.epicgames.com`) and GOG (`www.gog.com`). On the store page it reads the user's games with the page's own
|
||
login and opens `/import#data=...` with at most 1000 titles and platforms. Firefox blocks bookmarklets on pages
|
||
with a strict Content Security Policy, so the same code can be copied and pasted in the browser console. Xbox has
|
||
no JSON without its own tokens: it reads the game links shown on the page and is the most likely to break.
|
||
Nintendo lists purchases only, no free games. **Unverified:** each reader follows the site's JSON as public
|
||
scripts use it, not a real account.
|
||
- **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.
|
||
- **PlayStation trophies** (slice 3): the game detail shows one card per set, Steam first, then PS5 and PS4
|
||
(`GameAchievements`, grouped by `achievementSets`). A trophy has its type (bronze to platinum), rarity and a DLC tag;
|
||
a hidden trophy shows "Hidden trophy" until it is earned; a set whose list is not stored yet says it loads with the
|
||
next sync. The sync banner and inbox text say "achievements or trophies". Library tiles show the best set of a game.
|
||
The sync details list games whose last achievement this sync unlocked, with a button to set them to 100 %.
|
||
- **Game detail**: the page shows the stored game at once (`?refresh=false`); when Core says `achievementsStale` a second call refreshes the achievements from Steam and replaces the data. 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" (system or the settings switch).
|
||
- **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 game, Steam or added by hand (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, reduce animations, 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. The registration sends the browser language (`de` for a German browser, else `en`): Auth
|
||
writes its mails in it and saves it as the user's language.
|
||
- **Registration** (`/register`): no invite code; the form asks for email and display name only (no password); the page says that the user gets a mail with a link, then an admin
|
||
checks the request, and stays on "registration sent" after the send. Auth answers `409 PENDING_LIMIT_REACHED` while
|
||
20 wait. The mail link opens `/confirm-email#<token>` (the token stays in the fragment and goes to Auth in a POST
|
||
body); the page asks for the password (and its repeat) and confirms on a button click, not on load, so mail scanners do not burn the link. An expired or used
|
||
link and the page after the send offer "send the link
|
||
again" (same answer for any address).
|
||
- **Forgot password** (`/reset-password`): the login page links to it. Without a fragment it asks for the email and
|
||
shows the same text for any address. The mail link opens `/reset-password#<token>` (fragment, sent in a POST body on
|
||
the button click only): the page asks for the new password twice. A used or expired link shows the email form again.
|
||
**Change password** is a card in the settings (current password and the new one twice); the other logins end, this
|
||
one stays. The admin users page has "Send password reset" for an active, unblocked user.
|
||
- **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), and set the role of another user in the "Roles" dialog, which also lists the role
|
||
changes. "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. Registrations that wait show "Waits" with Accept
|
||
and Deny (a "Waiting only" filter lists just them); the menu shows how many wait, loaded again on every page change
|
||
and menu opening. A request whose address is not confirmed shows "Not confirmed" and only a Delete button. The login tells a waiting user that the admin has not decided yet.
|
||
- **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). A hint can
|
||
target groups (without Steam, role and higher, new users, empty library; all must match). Four automatic hints
|
||
(no Steam, empty library, pile grew this month, open link queue for auditors) show after the admin hints while
|
||
their condition is true; the Frontend checks it with Core's library summary, stats and link queue. X hides an
|
||
automatic hint for 7 days, "Do not show again" for good, the 30 s auto close only for this browser session.
|
||
Admins turn each on or off on `/admin/hints`. Hint and news text may use `**bold**` and `[text](url)` (an app
|
||
path or https; anything else stays plain text); `HintText` renders React elements, never HTML.
|
||
- **Follow users** (#27): a logged-in visitor follows the owner of a public profile with "Follow" on `/u/<name>`; the
|
||
settings page lists who you follow ("Unfollow") and who follows you ("Remove", a private follower shows as "Private
|
||
user"). Core writes one inbox note per followed user and batch (new games, achievements, status changes, wishlist
|
||
adds; what the profile hides is left out). `/inbox` (bell in the header, with the unread count) lists the newest 50 notes
|
||
and marks them read; a digest note opens inline (click) and lists its games, achievements, status changes and wishlist
|
||
items, while you still follow the person and the part is public (5 achievements per game, then "show all"); a user
|
||
who goes private ends the follow with a note. Two more notes come from the user's own
|
||
PlayStation link (`PSN_EXPIRING` about 2 days before it ends, `PSN_EXPIRED` when it has ended), with a link to the
|
||
settings. Notes older than 90 days are deleted.
|
||
- **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it"
|
||
marks them seen in Auth; closing the dialog any other way shows them again on the next dashboard visit. `/whats-new`
|
||
(menu) lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`.
|
||
- **Broadcast mail**: admins write a mail in both languages on `/admin/broadcasts`, send it to all active users or to
|
||
picked ones, send a test to themselves, confirm with the real recipient count, and see the history with the counts.
|
||
- **Welcome page**: `/welcome` opens once after the first login (Auth `settings.welcomeSeenAt` is null; only the
|
||
dashboard redirects, deep links stay). Feature cards with links, two next steps (Steam, import) with checks from
|
||
`/me` and the library summary. "Let's go" and "Skip" stamp it in Auth and open the dashboard. The settings page
|
||
links to it again.
|
||
- **Correct game data** (AUDITOR or higher): "Correct data" on the game detail page changes name, header image,
|
||
speedrun link, moods, endless flag, genres and time to beat (hours and minutes) 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; an auditor can undo a merge for 30 days from the audit log ("Undo"). An old link of a merged game opens the kept game.
|
||
- **Game catalog** (`/audit/games`, AUDITOR or higher): search every game any user has by name (cover, platforms,
|
||
key), paged; "Correct" opens the correction dialog, also for a game the auditor does not own. The audit log links
|
||
its games here.
|
||
- **Link queue** (`/audit/links`, AUDITOR or higher): the pairs IGDB suggests and users asked for, with "requested by N
|
||
players"; Link or Dismiss; paged, 20 pairs a page. **Audit log** (`/audit/log`): the newest merges, merge undos, links, unlinks, dismissals, corrections and resets with who
|
||
did it, filter by action, paged; "Undo" on a merge made in the last 30 days.
|
||
- **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"]
|
||
Core -->|"NPSSO sign-in, played titles, trophies"| Psn["PlayStation API (unofficial)"]
|
||
Browser -.->|"game art, trophy icons"| Images["Steam, IGDB and PlayStation (psnobj) image hosts"]
|
||
Browser -.->|"Sign in through Steam"| SteamLogin["steamcommunity.com (OpenID)"]
|
||
Browser -.->|"bookmarklet, the user's login"| Stores["PlayStation, Xbox, Nintendo, Epic, GOG"]
|
||
Auth -->|"check_authentication"| SteamLogin
|
||
```
|
||
|
||
- `useDragSort` (`src/hooks/useDragSort.js`) sorts a list with pointer events. Only the drag handle has `touch-action: none`, so the page still scrolls from a row.
|
||
- 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 (Steam, IGDB and, for trophy icons,
|
||
`psnobj.prod.dl.playstation.net`).
|
||
- 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.
|
||
- The games behind a number: pages wrap their content in `StatDrillProvider` (`source` own, profile, community or
|
||
points), which renders one `StatGamesDialog`. Numbers call `useStatDrill()` / `StatCount`; outside a provider they
|
||
are plain text. `src/utils/statGames.js` builds the four endpoint URLs.
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
Number["Number, bar or row"] --> Provider["StatDrillProvider"]
|
||
Provider --> Dialog["StatGamesDialog"]
|
||
Dialog -->|own| Own["Core /api/library/stats/games"]
|
||
Dialog -->|"profile + ticket"| Profile["Core /api/public/profiles/library/stats/games"]
|
||
Dialog -->|community| Community["Core /api/stats/public/games"]
|
||
Dialog -->|points| Points["Core /api/rewards/.../games"]
|
||
Dialog -->|"community row, login"| Owners["Core /api/stats/owners"]
|
||
Landing["Landing user tiles, login"] --> Users["Auth /api/stats/users"]
|
||
```
|
||
- `/questions` (`QuestionsPage`, nav item with the open count; the badge is hidden on the page, whose title shows the
|
||
live count) lists one card per game with an open question (Start it? / Finished? / Dropped? / Rate it?). Core decides
|
||
which question is open; `src/utils/questions.js` only loads the list and sends the answer. A yes to Finished? gets
|
||
the Rate it? card back at once. `409` / `404` reload the list. `RatingStars` is shared with `BacklogCard`.
|
||
Below the cards, a closed "Removed games" section (`RemovedGamesSection`, `GET /api/library/removed-games`) has a
|
||
Restore button per game (`POST /api/library/games/{appid}/restore`). The toast after "Remove from library" on a
|
||
manual game has an Undo that restores it the same way.
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
Games["Core GET /api/library/games: one row per set + platforms[]"] --> Row["GameLibrary row"]
|
||
Row --> Icons["PlatformIcons: badge per platform"]
|
||
Row -.->|"no platforms (hidden game)"| Badge["single platform badge"]
|
||
```
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
DetailData["Core GET /api/library/games/{appid}?refresh=false, then without it when achievementsStale: platforms[] + totalPlaytimeMinutes"] --> Platforms["PlatformsCard: row per platform, total"]
|
||
DetailData --> Hero["GameHeroCard: total playtime"]
|
||
Platforms -->|"row of another edition"| Other["/library/{appid} of that edition"]
|
||
```
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
Detail["GameDetail: EditionsCard"] -->|"Not the same game"| Split["Core DELETE /api/library/games/{appid}/set"]
|
||
Detail -->|"Same game as... (request)"| Request["Core POST /api/library/games/{appid}/link-requests"]
|
||
Detail -->|"Unlink (auditor)"| Unlink["Core DELETE /api/library/games/{appid}/link"]
|
||
Split --> Reload["reload the detail"]
|
||
Backlog["BacklogCard save"] -->|"game has editions"| Reload
|
||
```
|
||
- `EditionsCard` shows "Not the same game" on every game that has other editions in the own library. The call goes
|
||
through `src/utils/links.js` (`splitGame`); `404 NOT_LINKED` shows the generic error toast.
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
Header["AppHeader badge"] --> Count["Core /api/library/questions/count"]
|
||
Page["QuestionsPage"] --> List["Core /api/library/questions"]
|
||
Page -->|"Yes / No / stars"| Answer["Core POST /api/library/questions/{appid}"]
|
||
Page -->|"Removed games, Restore"| Removed["Core /api/library/removed-games, POST /games/{appid}/restore"]
|
||
```
|
||
- Owner and user lists (`src/components/people/`, `src/utils/people.js`) need a login and show listed users by name
|
||
(a link to `/u/<name>`), everyone else as "+N private", the viewer as "You". The setting "show me in owner lists" is
|
||
saved on its own (`PUT /api/users/settings/owner-lists`), never through `saveSettings`.
|
||
- `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again.
|
||
- The bookmarklet hands its list over in the URL fragment (base64url JSON), which the browser sends to no server.
|
||
The import page reads it once and removes it from the URL. `RequireSession` keeps the fragment on the way
|
||
through the login page.
|
||
- The import page polls a running import every 2 s. After a `5xx` or a network error it waits twice as long (up to
|
||
30 s) and gives up after 10 failures in a row; a hidden tab does not poll.
|
||
- All `localStorage` access goes through `src/utils/storage.js`: with site data blocked a read is `null` and a
|
||
write is lost, but the app still starts.
|
||
- `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, ...): loads data, places cards
|
||
├── components/ # Cards and shared components; ui/ holds the base components, import/ and stats/ the parts
|
||
│ # of one page that share page-only code
|
||
├── hooks/ # Data hooks (game, dashboard, library sync, Steam profile, appearance, session)
|
||
├── 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
|
||
```
|
||
|
||
### Overflow check (phones)
|
||
|
||
`scripts/check-overflow.js` visits every page at 360, 390, 412 and 414 px and reports any page that scrolls sideways
|
||
(`scrollWidth != clientWidth`) with its widest elements. It is not part of the gate and needs no dependency:
|
||
|
||
1. Start the local stack (see "Run locally") and log in as an admin in the Playwright MCP browser.
|
||
2. Set `GAME_ID` (an appid in the library) and `PROFILE_NAME` (the display name of that user, the profile must be public) at the top of the script.
|
||
3. Run the file with the Playwright MCP tool `browser_run_code_unsafe` (`filename` = the script). An empty `hits` list is green.
|
||
4. Other runs: edit `FONT_PERCENT = 130` (large system font), `DARK`, `SKIN`. For the logged-out pages set `LOGGED_OUT = true` and run in a browser without a session (a logged-in browser redirects them).
|
||
|
||
Use long display names (up to 24 characters in Auth) and long game names in the local data to stress the layout.
|
||
|
||
## Roadmap
|
||
|
||
See `ROADMAP.md` in the ToBePlayed-Deployment repo.
|
||
|
||
## License
|
||
|
||
All rights reserved. The source is public for reading only.
|