Compare commits

...
110 Commits
Author SHA1 Message Date
lobo 3712e3905f feat(nav): simplify navigation and terms (#57)
Build, Test & Push Frontend / quality-check (push) Successful in 40s
Build, Test & Push Frontend / docker (push) Successful in 29s
One glossary (docs/GLOSSARY.md) for all de and en texts.
Five nav items; phones get a bottom bar with labels.
Community page with following, followers, player search and stats.
The bell is the one place for notes, news and open questions.
Settings in tabs; auto-save is silent and rolls back on error.
Questions page explains itself; removed games are a library filter.
Own profile: menu link, public chip, copy link, private hint.
Points page starts with a short summary; stats use status colors.
2026-10-21 14:54:48 +02:00
lobo 45f5087227 feat(library): set status and rating in one step (#56)
Library rows: the status badge is a menu that saves at once.
Game page: status and rating in the top card, saved on click.
Status changes and hiding a game show an Undo toast.
Other game actions moved into a "..." menu; split and unlink ask first.
New sorts: name, last played, time to beat; "last synced" is gone.
Page numbers and 44 px touch targets on phones.
2026-10-21 11:09:44 +02:00
lobo f9f0d94e2c feat(onboarding): remove dead ends for new users (#55)
Registration shows the address used and can start over.
Confirm page says an admin approves; no login link.
Welcome puts next steps first; Steam link returns to the dashboard.
Dashboard and library explain an empty or private library.
First sync shows queued, polls slower up to 10 min, then a calm text.
Login: simple checks, resend offer after 401, password toggles.
Only a real expired link counts as invalid; typed passwords stay.
Default language follows the browser; texts are platform-neutral.
2026-10-21 09:37:17 +02:00
lobo a60010e055 fix(ux): apply UX quick wins (#54)
Get started links to registration and says an admin approves accounts.
Back navigation restores the library scroll; search no longer fills history.
Steam and PSN unlink ask first. Any close marks news as seen.
Confetti plays only when the level rises on the page.
Count-ups respect reduced motion. Html lang follows the UI language.
Add a 404 page, translated role and dialog close label.
2026-10-21 08:21:39 +02:00
lobo 60dcfe8992 feat(search): add game search page (#50)
Build, Test & Push Frontend / quality-check (push) Successful in 44s
Build, Test & Push Frontend / docker (push) Successful in 27s
Add /search to find games by name on IGDB.
A game in the library opens its game page. Other hits open a
detail view with an Add button. The query stays in the URL.
To fit the new nav icon on 360 px phones, the header padding is
smaller and the points chip hides while the Questions icon shows.
2026-10-20 10:42:06 +02:00
lobo 5be0d42bca feat(follow): add feed links, following page and tile (#52, #51)
Build, Test & Push Frontend / quality-check (push) Successful in 37s
Build, Test & Push Frontend / docker (push) Successful in 1m23s
Feed links go to the game and to the profile view of that game.
Add /following page, nav item and an opt-in Following tile.
Games not in your library show a catalog view with an add button.
2026-10-14 16:23:11 +02:00
lobo 720764bf68 feat(docker): fail at start when AUTH_HOST or API_HOST is unset
Build, Test & Push Frontend / quality-check (push) Successful in 38s
Build, Test & Push Frontend / docker (push) Successful in 29s
An empty host gives a CSP connect-src that blocks every API call.
The new entrypoint check stops the container early with a clear error.
2026-10-14 15:22:20 +02:00
lobo 25ade98989 docs(readme): update features, repos and container env
Build, Test & Push Frontend / quality-check (push) Successful in 38s
Build, Test & Push Frontend / docker (push) Successful in 36s
Match README to the code: PlayStation import, stat charts, public
routes, Gitea CI, and the AUTH_HOST/API_HOST image variables.
2026-10-14 14:51:40 +02:00
lobo 42bc83bb28 chore(imprint): use info@andreas-dahm.eu as contact email
Build, Test & Push Frontend / quality-check (push) Successful in 36s
Build, Test & Push Frontend / docker (push) Successful in 27s
2026-10-14 07:53:04 +02:00
lobo ec0b2844c3 test: use fake steam id in test fixtures
Build, Test & Push Frontend / quality-check (push) Successful in 37s
Build, Test & Push Frontend / docker (push) Successful in 25s
Replace the real Steam ID with the fake 76561198000000001 in the
SettingsPage and validation tests. Keep personal data out of the repo.
2026-10-13 12:03:56 +02:00
lobo c599054059 docs(readme): replace screenshots with neutral demo data
Old screenshots showed a real account. Retake them from the local
stack with 30 demo games and persona "Dev". Add a dashboard shot and
docs/screenshots/README.md.
Refs Lobo_Corp/ToBePlayed-Deployment#12
2026-10-13 12:03:56 +02:00
lobo ad6f5575d2 feat(inbox): add expandable details to digest notes
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.
2026-10-13 09:17:41 +02:00
lobo 600cf07b5a test(game-detail): wait for points refetch instead of fixed sleep 2026-10-13 09:17:41 +02:00
lobo 0924600d95 perf(library): one dashboard call, stored game detail first
Build, Test & Push Frontend / quality-check (push) Successful in 36s
Build, Test & Push Frontend / docker (push) Successful in 28s
The Dashboard loads the top and playing tiles from
GET /api/library/dashboard-games in one call. The game page shows the
stored game first (refresh=false) and calls again only when
achievementsStale is true. The second answer merges the achievement
fields; it takes status and finishedHint only when the user did not
change the status. The points check runs again after the refresh.

Refs #47
2026-10-12 09:25:49 +02:00
lobo 73ad4fd3b8 refactor(hooks): add useSetStatus for game status saves
Build, Test & Push Frontend / quality-check (push) Successful in 37s
Build, Test & Push Frontend / docker (push) Successful in 23s
Replaces 5 copies of the save code in hints, PlayNextPage and sync.
An empty 2xx body now counts as saved, not a network error.
Refs #46
2026-10-11 17:55:30 +02:00
lobo bbc707de87 feat(sync): add Set to 100% button for all-unlocked games
Build, Test & Push Frontend / quality-check (push) Successful in 40s
Build, Test & Push Frontend / docker (push) Successful in 23s
The sync details show a Set to 100% button for ALL_UNLOCKED games.
The button uses the existing status PUT, checks points, and reloads
the Dashboard. Refs #21
2026-10-11 16:22:49 +02:00
lobo c966877b23 feat(points): show early-since date in point texts
Refs #44. Core sends earlySince in the games and wallet responses.
COMPLETION_TOO_OLD and the COMPLETED rule show it as {{date}}.
2026-10-11 13:51:34 +02:00
lobo e044959629 fix(rewards): explain manual edition finish opens COMPLETED
COMPLETED rule and missing-reason texts (de, en) now name the manual
edition finish as a way to open it. Refs #35
2026-10-11 13:07:26 +02:00
lobo 2794696daa fix(i18n): clarify de and en texts, add i18n tests
Build, Test & Push Frontend / quality-check (push) Successful in 39s
Build, Test & Push Frontend / docker (push) Successful in 22s
Review all de and en texts for clear wording. Use informal "du" and one
glossary (Nachweis, Punkte, Spieldauer). Add tests for matching
placeholders, matching hint links and points texts that say earn, not pay.

Refs #41 #42
2026-10-11 11:29:59 +02:00
lobo 4b25e7f5fe perf(dashboard): load core data in parallel with /me
Build, Test & Push Frontend / quality-check (push) Successful in 36s
Build, Test & Push Frontend / docker (push) Successful in 22s
The dashboard no longer waits for /me before it calls the library.
History loads when games-count says canSync or fails. Refs #40.
2026-10-11 11:05:18 +02:00
lobo 84b16a2ca8 feat(questions): list and restore removed games
Build, Test & Push Frontend / quality-check (push) Successful in 36s
Build, Test & Push Frontend / docker (push) Successful in 26s
Add a "Removed games" section on the questions page and an Undo
toast after removing a manual game (#39).
2026-10-11 09:52:11 +02:00
lobo c126592887 feat(points): show early finish and expected points on questions
Build, Test & Push Frontend / quality-check (push) Successful in 36s
Build, Test & Push Frontend / docker (push) Successful in 24s
Core pays 10 early points for an unproven Steam finish. The rest
comes with proof. Show this in the toast and the game line.
Questions cards show the full value from GET /api/rewards/expected.
2026-10-09 16:00:06 +02:00
lobo 814decc781 feat(points): show points result on every status save
Core now settles on GET /api/rewards/games/{appid}. The Frontend
shows a toast, refreshes the header balance, and lists the paid
rows and reasons on the game page. Deploy Core first.
2026-10-09 15:25:04 +02:00
lobo 9adc606c57 feat(library): show set total playtime on dashboard cards
Most played and currently playing cards show the total playtime of
the game set. Use the own minutes when the API sends no total.
2026-10-09 14:35:07 +02:00
lobo f1a6bc0443 feat(library): show playtime per platform on game detail
Hero and library card show the set total. Fall back to the
row's own minutes when the API sends no total.
2026-10-09 13:55:57 +02:00
lobo 16651a1f56 feat(library): add platform badges, remove hide duplicates
Gitea #34 slice 2 of 3. Core lists each set once now.
The hide duplicates toggle and its param are not needed.
PlatformIcons shows a badge per platform (platforms[]).
2026-10-09 11:31:11 +02:00
lobo 25cc5ff2d8 feat(library): add not the same game button to editions
Part of Gitea #34 slice 1 of 3: one entry per game across platforms.
Status, rating and notes are shared, so the detail page reloads after a save.
2026-10-09 10:57:23 +02:00
lobo e136a7abc1 feat(library): add source chips to game library
Chips show only when the user has 2 or more sources.
A selected source the user lacks stays visible, so it can be cleared.
2026-10-09 10:03:05 +02:00
lobo d3ad19e2fa feat(questions): add questions page with open count badge
Add /questions page and a nav badge with the open count.
Core decides which question is open. The page only shows and answers.
Move RatingStars out of BacklogCard so both pages share it.
2026-10-09 09:31:48 +02:00
lobo 1dab069a4d feat(header): add community link to nav
Build, Test & Push Frontend / quality-check (push) Successful in 39s
Build, Test & Push Frontend / docker (push) Successful in 23s
Add an icon-only nav link to "/" so logged-in users can get back
to the community stats. Make room for it: less nav link padding
below sm, points chip no longer shrinks from lg, and the view-as
badge hangs below the header at every width.
2026-10-08 13:17:29 +02:00
lobo e7b08cfb47 feat(psn): show trophy sets on game detail and dashboard
Build, Test & Push Frontend / quality-check (push) Successful in 35s
Build, Test & Push Frontend / docker (push) Successful in 33s
Detail page groups achievements by set: Steam, then PSN trophies.
Tiles show the real count; a perfect game gets an amber trophy.
Dashboard card shows for PSN-only users; CSP allows PSN icons.
2026-10-08 09:18:15 +02:00
lobo 27c402fa34 feat(psn): replace PSN import button with sync review
Remove the PSN import tab; linking now starts a sync.
Show the PSN review link, last played date and PSN expiry notes.
PSN rows hide the hours field; unchecked titles you saw are dismissed,
unseen ones stay open.
2026-10-08 08:15:37 +02:00
lobo 599b083591 feat(psn): add PlayStation link card and PSN import
Build, Test & Push Frontend / quality-check (push) Successful in 34s
Build, Test & Push Frontend / docker (push) Successful in 22s
Settings card to link and unlink with the NPSSO, shows the expiry
date. Import page gets a PSN source; blocked when the link has ended.
English and German texts.
2026-10-07 13:30:48 +02:00
lobo 6ae2abee07 feat(play-next): add FRIENDS row and reason text
Shows games from followed users. Reason displays friend name and finish
or playtime status. Supports reroll without mood. Added English and
German translations.
2026-10-07 09:42:10 +02:00
lobo e041c71cf8 feat(play-next): add series and studio rows
Build, Test & Push Frontend / quality-check (push) Successful in 34s
Build, Test & Push Frontend / docker (push) Successful in 23s
2026-10-06 20:22:53 +02:00
lobo df3d6d2537 feat(play-next): show picks as category rows with reroll per row
Four rows: up next, because you played, longest on list, mood.
Each row rerolls alone and tracks its own loading state.
Mood change reloads only the mood row. Phones scroll each row
sideways; wider screens show 3 columns.
2026-10-06 19:41:24 +02:00
lobo 43c4862606 feat(ui): add follow button, follows card, and inbox page
Build, Test & Push Frontend / quality-check (push) Successful in 34s
Build, Test & Push Frontend / docker (push) Successful in 23s
2026-10-06 16:22:22 +02:00
lobo 3aed243e35 feat(ui): add people lists for game owners and community stats
Build, Test & Push Frontend / quality-check (push) Successful in 47s
Build, Test & Push Frontend / docker (push) Successful in 22s
2026-10-06 13:19:58 +02:00
lobo b213b067dc feat(library): add ordered backlog page and up-next dashboard tile
Build, Test & Push Frontend / quality-check (push) Successful in 34s
Build, Test & Push Frontend / docker (push) Successful in 24s
2026-10-06 09:06:51 +02:00
lobo c158909083 feat(games): add coming badge for unreleased games
Build, Test & Push Frontend / quality-check (push) Successful in 32s
Build, Test & Push Frontend / docker (push) Successful in 22s
2026-10-05 20:33:25 +02:00
lobo edeea5a43d feat(play-next): show why each game was picked
Build, Test & Push Frontend / quality-check (push) Successful in 1m15s
Build, Test & Push Frontend / docker (push) Successful in 2m33s
Each card shows a reason line under the game name. Issue #28.
2026-10-05 16:13:20 +02:00
lobo 2b20b389b7 refactor(hooks): move import polling and settings load into hooks
useImportPolling and useSettingsLoad take the effects out of ImportView
and SettingsPage, line for line. useSettingsLoad no longer sets state
after unmount. Tests for both hooks. Issue #6.
2026-10-05 15:37:16 +02:00
lobo de199a20b7 feat(stats): add chart form pickers to dashboard and profile tiles
Build, Test & Push Frontend / quality-check (push) Successful in 31s
Build, Test & Push Frontend / docker (push) Successful in 23s
2026-10-04 21:06:21 +02:00
lobo 13b0d5e7e9 feat(admin): add user role dialog
Admins change a user's role and see the change log. The last admin
gets a clear error when deleting their own account.
2026-10-04 20:09:22 +02:00
lobo e5091de50e feat(sync): follow the visit sync on the dashboard
Show the "Syncing with Steam" badge while a visit sync runs, then
reload the sync changes banner and the library.
2026-10-04 19:05:23 +02:00
lobo 8aa3181c37 feat(stats): status donut, meters and fixed chart colors
Build, Test & Push Frontend / quality-check (push) Successful in 35s
Build, Test & Push Frontend / docker (push) Successful in 22s
Pie and donut charts use one fixed color-blind safe palette in every
skin and accent. The overview and the profile status tile show a status
donut with the total in the middle. Habits and achievements get meter
bars, top games get share bars, the pile of shame bar uses the palette.
2026-10-04 12:03:52 +02:00
lobo dabb0c941c feat(sync): show sync changes in banner and details dialog 2026-10-04 11:36:09 +02:00
lobo e054e13a59 feat(stats): add chart form selection and pie, donut, line charts
Build, Test & Push Frontend / quality-check (push) Successful in 30s
Build, Test & Push Frontend / docker (push) Successful in 22s
Tag rows with 0 games are no longer clickable.
2026-10-04 10:43:02 +02:00
lobo 8b94a25844 fix(stats): open game detail pages from stat lists, perfect games clickable
Add perfect games click-through on the dashboard. Remove Steam store
links from game lists. Stat numbers with count 0 are not clickable.
Tests cover the perfect-games click, zero guards, and owner versus
visitor behavior.
2026-10-04 10:07:27 +02:00
lobo 777ba87fd6 feat(ui): make stat numbers clickable to open games
Build, Test & Push Frontend / quality-check (push) Successful in 28s
Build, Test & Push Frontend / docker (push) Successful in 26s
StatDrill context holds one dialog for a page. StatCount wraps a number:
total and status counts link to the library filter, other numbers open
StatGamesDialog. DrillRow and DrillButton support drill-down in tag bars
(genres, platforms, moods) and other stat displays. Dialog shows 40 games
per page with bottom-sheet layout on phones, handles pagination and errors
(403 not shared, 401 expired, 400 bad argument).
2026-10-03 18:51:59 +02:00
lobo ed5df8c40d feat(effects): polish 8 effects and add 7 new engines with parallax
Build, Test & Push Frontend / quality-check (push) Successful in 32s
Build, Test & Push Frontend / docker (push) Successful in 26s
Synthwave, fireflies, snow, sakura, nebula: CSS/SVG. Matrix and
constellation: canvas. Existing engines gain depth, color, glow, and
parallax (mouse and device tilt, opt-in on iOS)
2026-10-03 17:31:19 +02:00
lobo d7810fbf94 fix(layout): fit names on phones without sideways scroll
Build, Test & Push Frontend / quality-check (push) Successful in 31s
Build, Test & Push Frontend / docker (push) Successful in 24s
Display and game names shrink to 0.875rem, then end with an ellipsis
(FitText + fit-text utility, CSS container units, full text in title).
Rows and buttons wrap at large fonts, header points chip can shrink.
Guard: scripts/check-overflow.js.
2026-10-03 12:18:31 +02:00
lobo 04387fcd0c fix(header): prevent mobile header overflow
Build, Test & Push Frontend / quality-check (push) Successful in 31s
Build, Test & Push Frontend / docker (push) Successful in 23s
Reduce responsive padding and gaps below 640 px, move nav labels to
lg breakpoint, and position the "view as" badge absolutely to prevent
horizontal scroll on narrow phones (360-414 px).
2026-10-03 10:37:35 +02:00
lobo b6f3d123fb feat(points): unlock items, progress bars and skins
Build, Test & Push Frontend / quality-check (push) Successful in 28s
Build, Test & Push Frontend / docker (push) Successful in 25s
Locked items show a progress bar until the goal is reached. Four
CSS-only skins (Pastel, Retro CRT, Cyberpunk, Steam) in skins.css,
light and dark. A skin overrides accent and corners; badges, borders,
frames, effects and game art stay on top. New Platinum shine and
Equalizer effects.
2026-10-02 15:40:46 +02:00
lobo 8598024bce feat(hint): targets, automatic hints and formatting
Target fields on the admin hints page. The dashboard shows the four
automatic hints after admin hints when their condition is true, using
data Core already serves. HintText renders **bold** and safe links (app
path or https) as React elements, never HTML, in hints and news.
Auto-close without tick hides for this session, X for 7 days, tick + X
for good.
2026-10-02 14:36:49 +02:00
lobo ae904d946a feat(audit): undo button for merges, hide manual games
Build, Test & Push Frontend / quality-check (push) Successful in 31s
Build, Test & Push Frontend / docker (push) Successful in 23s
The audit log shows Undo on a merge row for 30 days, with a confirm
dialog and clear errors. The details page follows a merged key to the
kept game. Hide and Show now work for manual games too. de/en texts.
2026-10-02 13:39:02 +02:00
lobo 0c300c455d feat(audit): game catalog search and time to beat correction
New page /audit/games: search every game and open the correction dialog
without owning it. Time to beat as hours + minutes. Audit log game links
open /audit/games?appid=. Menu entry next to the link queue.
2026-10-02 11:21:15 +02:00
lobo 6627a8db66 feat(password): add reset and change password pages
/reset-password page for the email request and the new password,
ChangePasswordCard in settings, password reset button on the admin
users page, 'Forgot password?' link on the login page, de/en texts.
2026-10-02 10:35:08 +02:00
lobo f71e8f7aff feat(library): add game links, auditor queue, audit log and filter
Build, Test & Push Frontend / quality-check (push) Successful in 25s
Build, Test & Push Frontend / docker (push) Successful in 23s
LinkGameDialog (user request or auditor link), EditionsCard on the
details page, LinkQueuePage and AuditLogPage for auditors with paging,
hideDuplicates filter and editions badge in the library, audit menu
entries, de/en texts.
2026-10-02 09:10:56 +02:00
lobo 80259cb0e9 feat(settings): remove the platform mails switch
Broadcasts go to every active user, so the settings switch and its API
call are gone. Admin broadcast texts no longer mention an opt-out.
2026-10-02 07:53:35 +02:00
lobo 6dda186bdf feat(registration): add email confirmation UI
Build, Test & Push Frontend / quality-check (push) Successful in 26s
Build, Test & Push Frontend / docker (push) Successful in 31s
New ConfirmEmailPage for users to verify their email and set password.
Resend form on registration and error pages. Registration form no
longer asks for a password. Confirmation requires a button click. The
admin list marks unconfirmed requests and offers only delete for them.
2026-10-01 16:20:51 +02:00
lobo 249973dd69 feat(points): apply worn effects on covers, details and backgrounds
Build, Test & Push Frontend / quality-check (push) Successful in 28s
Build, Test & Push Frontend / docker (push) Successful in 27s
2026-10-01 15:09:25 +02:00
lobo dd45c0b5f6 feat(points): add effect engines and effects shop 2026-10-01 15:09:25 +02:00
lobo 2ec5833f29 feat(settings): reduce animations switch 2026-10-01 15:09:25 +02:00
lobo fa0857f55d feat(broadcasts): add admin broadcast page and user opt-out switch
Admin page /admin/broadcasts to write, test and send broadcasts in both
languages; confirms the recipient count before send. History shows the
counts. A new switch in settings lets users opt out of platform mails;
account mails always come. Subject and body are plain text only.
2026-10-01 14:12:34 +02:00
lobo 0154202ddc feat(points): equipped looks on dashboard and public profile
Build, Test & Push Frontend / quality-check (push) Successful in 23s
Build, Test & Push Frontend / docker (push) Successful in 23s
Avatar borders frame the avatar on the dashboard, badges show next to the
heading, banner frames wrap the hero image, and equipped accents set the
color on both pages.
2026-10-01 13:17:23 +02:00
lobo ca3d34bad0 feat(points): balance, history and shop page
Add /points page with wallet balance, shop grouped by slot and ledger
history with game names. The wallet loads once in PreferencesContext after
login, so the header shows a balance chip and the link. Premium accents
gold and teal override the free accent while equipped. German and English.
2026-10-01 13:17:04 +02:00
lobo 22633a9e45 feat(stats): add platform statistics and landing blocks
Add PlatformStats component to stats page. Extract YearBars from
FinishedStats for reuse on landing. Add three blocks to landing: games
per platform, top genres, finished per year. Update translations.
2026-10-01 11:10:59 +02:00
lobo b129f6a6e4 feat(welcome): add welcome page for new users after first login
Shows six feature cards and two next steps (Steam, import). The dashboard
redirects to it while Auth settings.welcomeSeenAt is null; "Let's go" and
"Skip" set the stamp. Settings links to it to reopen later.
2026-10-01 10:44:22 +02:00
lobo b811255c16 feat: add What's new feature
Build, Test & Push Frontend / quality-check (push) Successful in 25s
Build, Test & Push Frontend / docker (push) Successful in 39s
Dashboard dialog shows unseen news entries. Users mark them seen with "Got it"
or revisit via the /whats-new page. Admins publish, edit, and delete entries
on /admin/news. Full i18n support and test coverage included.
2026-10-01 10:03:39 +02:00
lobo 79b086c525 feat(registration): no invite code, request text
Build, Test & Push Frontend / quality-check (push) Successful in 22s
Build, Test & Push Frontend / docker (push) Successful in 22s
2026-10-01 09:18:47 +02:00
lobo f4e69e1a94 feat(admin): accept, deny and pending count 2026-10-01 09:18:47 +02:00
lobo b8f70e7578 feat(registration): send the browser language
Build, Test & Push Frontend / quality-check (push) Successful in 22s
Build, Test & Push Frontend / docker (push) Successful in 25s
2026-10-01 08:30:24 +02:00
lobo 43968b3b7a test(import): wait for the URL without the store list 2026-10-01 08:30:24 +02:00
lobo f01ad03f6e feat(import): bookmarklet for store pages
Build, Test & Push Frontend / quality-check (push) Successful in 22s
Build, Test & Push Frontend / docker (push) Successful in 25s
2026-09-30 15:59:05 +02:00
lobo c6d76b4cb5 fix(session): keep the hash on the login round trip 2026-09-30 15:59:05 +02:00
lobo 8e31eafc06 feat(import): read the exports of GOG Galaxy, Grouvee, HowLongToBeat and Playnite 2026-09-30 15:59:05 +02:00
lobo 89d0b29698 refactor: item 29 cleanup
F16 F17 F18 F19 F20 F21: import polling backs off and pauses in a hidden tab, one date format module, fake timers in the import tests, error codes instead of raw messages, one safe storage wrapper, dead code.
2026-09-30 15:00:47 +02:00
lobo aa5832225d refactor(import): move the import parts to components/import
Build, Test & Push Frontend / quality-check (push) Successful in 23s
Build, Test & Push Frontend / docker (push) Successful in 33s
2026-09-30 08:46:51 +02:00
lobo 28ff6eae52 refactor(settings): one card per section
The name field takes its value from the page's own load, not from the user context: the context can still be empty when the form mounts.
2026-09-30 08:46:51 +02:00
lobo 6da612bebd refactor(dashboard): load in useDashboardData, welcome and most played cards 2026-09-30 08:46:51 +02:00
lobo 05f4ca42e5 refactor(game-detail): load in useGame, hero and achievements cards 2026-09-30 08:46:51 +02:00
lobo 628920a006 feat(backlog): save only the changed fields 2026-09-30 07:57:52 +02:00
lobo 9ddc50f819 fix: abort old loads, stale sync state, warn before unsaved edits are lost 2026-09-30 07:51:09 +02:00
lobo cbafa3d540 refactor(session): one session store for login, logout and 401 2026-09-30 07:36:08 +02:00
lobo 2a22789435 test: data router, one render helper, fetchRoutes in every test 2026-09-29 16:15:50 +02:00
lobo d0ea9e900d refactor(frontend): lint, one session guard, one request helper, one user context 2026-09-29 15:40:55 +02:00
lobo 111ab1e200 fix(import): readable dropdowns in dark mode, select all and none 2026-09-29 15:10:41 +02:00
lobo 24a98062ee refactor(limits): server limits in one file, own texts for unknown errors
Build, Test & Push Frontend / quality-check (push) Successful in 19s
Build, Test & Push Frontend / docker (push) Successful in 27s
2026-09-29 14:48:28 +02:00
lobo f605917874 fix(settings): keep the language and save settings one after the other 2026-09-29 13:27:31 +02:00
lobo 14dd59f9e0 feat(import): add the import page, multi-select add and the favorite platform 2026-09-29 10:20:54 +02:00
lobo aa9e83e0cb feat(tiles): let users arrange the dashboard and profile tiles
Build, Test & Push Frontend / quality-check (push) Successful in 18s
Build, Test & Push Frontend / docker (push) Successful in 28s
2026-09-28 15:03:16 +02:00
lobo b55fcfec92 feat(profile): let users design their public profile 2026-09-28 14:20:14 +02:00
lobo a5458b3f86 feat(game): play confetti for finished games 2026-09-28 13:36:36 +02:00
lobo 09e1f767d8 docs(readme): describe the local setup 2026-09-28 13:07:40 +02:00
lobo 78ba0700c8 feat(library): add the merge duplicate dialog
Build, Test & Push Frontend / quality-check (push) Successful in 18s
Build, Test & Push Frontend / docker (push) Successful in 24s
2026-09-28 11:25:45 +02:00
lobo 01ae372b2d feat(dashboard): show admin hints and add the hints admin page
Build, Test & Push Frontend / quality-check (push) Successful in 18s
Build, Test & Push Frontend / docker (push) Successful in 25s
2026-09-28 10:07:49 +02:00
lobo 6601277961 feat(library): add the game data correction dialog
Build, Test & Push Frontend / quality-check (push) Successful in 17s
Build, Test & Push Frontend / docker (push) Successful in 25s
2026-09-28 09:31:39 +02:00
lobo afe00ceae9 feat(admin): add the admin users page and view as
Build, Test & Push Frontend / quality-check (push) Successful in 17s
Build, Test & Push Frontend / docker (push) Successful in 28s
2026-09-27 12:18:10 +02:00
lobo 46587dfb5c feat(settings): let users delete their account
Build, Test & Push Frontend / quality-check (push) Successful in 16s
Build, Test & Push Frontend / docker (push) Successful in 26s
2026-09-27 11:46:22 +02:00
lobo cfceca5e5e feat(settings): show the build version of each service
Build, Test & Push Frontend / quality-check (push) Successful in 15s
Build, Test & Push Frontend / docker (push) Successful in 27s
2026-09-27 10:27:01 +02:00
lobo 9d1c7bb40d feat(stats): add stats page and pile trend
Build, Test & Push Frontend / quality-check (push) Successful in 15s
Build, Test & Push Frontend / docker (push) Successful in 22s
Show the stat groups on /stats, the pile trend on the dashboard card,
one switch per group in the settings, and the shared groups on the
public profile.
2026-09-27 07:16:10 +02:00
lobo 83bbc2730d feat(library): ask whether a game is finished
Build, Test & Push Frontend / quality-check (push) Successful in 15s
Build, Test & Push Frontend / docker (push) Successful in 23s
2026-09-26 19:00:57 +02:00
lobo 011711fe4f feat(library): hide and unhide games
Build, Test & Push Frontend / quality-check (push) Successful in 16s
Build, Test & Push Frontend / docker (push) Successful in 22s
2026-09-26 12:27:58 +02:00
lobo 42ed0afe51 feat(images): report broken covers and achievement icons
Build, Test & Push Frontend / quality-check (push) Successful in 15s
Build, Test & Push Frontend / docker (push) Successful in 22s
2026-09-26 12:07:47 +02:00
lobo ca75e6a530 feat(settings): link the Steam account through Steam
Build, Test & Push Frontend / quality-check (push) Successful in 16s
Build, Test & Push Frontend / docker (push) Successful in 25s
A Steam card in the settings links, verifies or unlinks the Steam
account through "Sign in through Steam". After the way back the page
shows the outcome and refreshes the session, so Core gets the new
Steam ID at once. Registration and settings no longer ask for a typed
Steam ID.
2026-09-26 11:20:04 +02:00
lobo 23ab5a91e7 feat(picker): add the speedrun mood and the speedrun.com link
Build, Test & Push Frontend / quality-check (push) Successful in 15s
Build, Test & Push Frontend / docker (push) Successful in 23s
"What to play next" gets a Speedrun mood. The game detail links the
game's speedrun.com page when Core found one with verified runs; only
https://www.speedrun.com/ links are shown.
2026-09-26 10:39:10 +02:00
lobo b0c78d8dca docs(readme): add the architecture overview
Build, Test & Push Frontend / quality-check (push) Successful in 14s
Build, Test & Push Frontend / docker (push) Successful in 24s
2026-09-26 07:37:44 +02:00
lobo fa1bd59509 feat(play-next): pick games by mood 2026-09-26 07:37:16 +02:00
lobo 7d9cf907fc feat(security): enforce the Content Security Policy
The report-only run showed no violation in production.
2026-09-26 07:15:14 +02:00
423 changed files with 35356 additions and 2586 deletions

No files matched your search

+4
View File
@@ -56,6 +56,7 @@ jobs:
BRANCH="$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]')"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
echo "sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
# Convert repository owner to lowercase
OWNER="${GITHUB_REPOSITORY_OWNER}"
@@ -75,6 +76,9 @@ jobs:
with:
context: .
push: true
build-args: |
GIT_SHA=${{ github.sha }}
BUILD_TIME=${{ steps.prep.outputs.build_time }}
tags: |
git.andreas-dahm.eu/${{ steps.prep.outputs.owner }}/tobeplayed-frontend:${{ steps.prep.outputs.branch }}
git.andreas-dahm.eu/${{ steps.prep.outputs.owner }}/tobeplayed-frontend:${{ steps.prep.outputs.branch }}-${{ steps.prep.outputs.sha }}
+1
View File
@@ -0,0 +1 @@
{"numTotalTestSuites":23,"numPassedTestSuites":23,"numFailedTestSuites":0,"numPendingTestSuites":0,"numTotalTests":56,"numPassedTests":56,"numFailedTests":0,"numPendingTests":0,"numTodoTests":0,"snapshot":{"added":0,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0,"didUpdate":false},"startTime":1790690796160,"success":true,"testResults":[{"assertionResults":[{"ancestorTitles":["App"],"fullName":"App ends the session on a 401 that the refresh cannot fix","status":"passed","title":"ends the session on a 401 that the refresh cannot fix","duration":931.6336369999999,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1790690797891,"endTime":1790690798822.6335,"status":"passed","message":"","name":"/workspace/tbp/ToBePlayed-Frontend/src/App.test.jsx"},{"assertionResults":[{"ancestorTitles":["AddGamePanel"],"fullName":"AddGamePanel does not search below two characters","status":"passed","title":"does not search below two characters","duration":769.245433,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AddGamePanel"],"fullName":"AddGamePanel searches once after typing stops and blocks games already in the library","status":"passed","title":"searches once after typing stops and blocks games already in the library","duration":433.3170190000003,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AddGamePanel"],"fullName":"AddGamePanel adds the picked game with the favorite platform and the hours, then opens its page","status":"passed","title":"adds the picked game with the favorite platform and the hours, then opens its page","duration":529.4851979999999,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AddGamePanel"],"fullName":"AddGamePanel preselects no platform without a favorite among several, so Add waits for a pick","status":"passed","title":"preselects no platform without a favorite among several, so Add waits for a pick","duration":444.57771,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AddGamePanel"],"fullName":"AddGamePanel sends several checked games to a search import and opens its review","status":"passed","title":"sends several checked games to a search import and opens its review","duration":428.44052999999985,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AddGamePanel"],"fullName":"AddGamePanel says so when the game is already in the library","status":"passed","title":"says so when the game is already in the library","duration":447.7203089999998,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AddGamePanel"],"fullName":"AddGamePanel shows that adding is off when Core has no IGDB app","status":"passed","title":"shows that adding is off when Core has no IGDB app","duration":408.3551859999998,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1790690797326,"endTime":1790690800787.3552,"status":"passed","message":"","name":"/workspace/tbp/ToBePlayed-Frontend/src/components/AddGamePanel.test.jsx"},{"assertionResults":[{"ancestorTitles":["AppHeader"],"fullName":"AppHeader says so when Auth refuses the language switch","status":"passed","title":"says so when Auth refuses the language switch","duration":364.1882109999999,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AppHeader"],"fullName":"AppHeader keeps a name on the home link when a narrow phone hides the app name","status":"passed","title":"keeps a name on the home link when a narrow phone hides the app name","duration":18.29670699999997,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AppHeader"],"fullName":"AppHeader offers the admin page and \"view as\" to an admin","status":"passed","title":"offers the admin page and \"view as\" to an admin","duration":54.321174000000156,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AppHeader"],"fullName":"AppHeader shows the badge and hides the admin page while an admin views as a user","status":"passed","title":"shows the badge and hides the admin page while an admin views as a user","duration":53.448589000000084,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]},{"ancestorTitles":["AppHeader"],"fullName":"AppHeader offers nothing of that to a user","status":"passed","title":"offers nothing of that to a user","duration":39.23294800000008,"failureMessages":[],"meta":{},"tags":[],"benchmarks":[]}],"startTime":1790690797351,"endTime":1790690797881.233,"status":"passed","message":"","name":"/workspace/tbp/ToBePlayed-Frontend/src/components/AppHeader.test.jsx"},{"assertionResults":[{"ancestorTitles":["BacklogCard"],"fullName":"BacklogCard enables saving only after a change","status":"passed","title":"enables saving only after a change","duration":227.31609400000002,"faLine truncated
+4 -2
View File
@@ -4,13 +4,15 @@ WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# Passed by the CI workflow; vite.config.js puts them into the settings page
ARG GIT_SHA=dev
ARG BUILD_TIME=
RUN npm run build
# Serve the static files using a lightweight web server
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
# nginx.conf is a template. An env var that is not set stays in it as ${...}, and nginx then fails to start.
ENV AUTH_HOST="" API_HOST=""
COPY --chmod=0755 docker-entrypoint.d/10-require-hosts.sh /docker-entrypoint.d/
COPY nginx.conf /etc/nginx/templates/default.conf.template
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+409 -26
View File
@@ -1,41 +1,248 @@
# 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.
Web app of **To Be Played**, a backlog tracker: import your Steam and PlayStation libraries or add games by hand,
decide what to play, and track what you finished. The UI is in German and English, with light and dark mode.
![Library](docs/screenshots/library-desktop-dark.png)
| Game detail | Public profile | Mobile |
|---|---|---|
| ![Game detail](docs/screenshots/detail-desktop-dark.png) | ![Public profile](docs/screenshots/profile-desktop-dark.png) | ![Mobile library](docs/screenshots/library-mobile-dark.png) |
| Dashboard | Game detail | Public profile | Mobile |
|---|---|---|---|
| ![Dashboard](docs/screenshots/dashboard-desktop-dark.png) | ![Game detail](docs/screenshots/detail-desktop-dark.png) | ![Public profile](docs/screenshots/profile-desktop-dark.png) | ![Mobile library](docs/screenshots/library-mobile-dark.png) |
To Be Played is split into three repositories:
To Be Played is split into four 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 |
| ToBePlayed-Deployment | Docker Compose stack, env template, ROADMAP.md (history), plans |
## Features
- **Dashboard**: most played games, games in progress, "Pile of Shame" with backlog hours and a random suggestion,
- **Navigation**: five items (Dashboard, Library, Play next, Stats, Community) in the header from `md` (icon and label);
on a phone a bottom bar with the same five items (`BottomNav`, `src/components/navItems.js`) and a header with logo,
points chip, bell and avatar. Search ("Find games"), the wishlist and the queue are reached from the Library. The avatar
menu has "My public profile", settings, points, What's new and the role pages; an admin's pending count is amber.
Tab pages (`/settings`, `/community`) use `?tab=` (`TabNav`, `tabOf`); an unknown tab shows the first one.
- **Texts** follow one [glossary](docs/GLOSSARY.md): one name per idea in German and English (checked by `src/i18n.test.js`).
- **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" and a "Following" tile. 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 %.
- **What to play next** (`/play-next`): three backlog picks for a time budget (up to 5 / 15 / 40 h or any), high
priority first, reroll without repeats, and "Start playing" in one click.
- **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.
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: queue (your own 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.
- **Queue** (`/backlog`, second 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 "Queue" dashboard tile shows the first games; the "Play next" row "Queue" 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.
The user picks a chart form per group (bars, pie, donut, line or list, `src/utils/statCharts.js`). Auth saves it
(`PUT /api/users/settings/stat-chart`).
- **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`): a three-line summary on top (start +5, finish +30 to +80, 100 % +40; points buy looks), then the shop, then the detailed rules in a closed section and the history. 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, newest
in library, rating, priority, name, last played or time to beat (games never played or without a time to beat come last), page
numbers with Previous / Next. Each row shows the unlocked achievements, and its status badge is a menu: choosing a status saves at once
(with an Undo in the toast and the points check). While sorted by rating or priority the row shows them. 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.
- **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %),
a hint when Steam shows recent playtime for a game still in the backlog, and one to set the status to 100 % when
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.
The **Find games** page (`/search`, a button in the library toolbar; a library search without a hit links to it) finds games by name: results with cover, year, platforms and an
"In library" badge; an own game opens its game page, any other hit opens a detail view with "Add".
- **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.
- **Public profile** at `/u/<display name>`, opt-in in the settings, with perfect games and average completion.
- **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art.
- **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. After a successful link the settings page opens the dashboard (success toast),
where the first sync starts; the other outcomes (in use, cancelled, failed) stay on the settings page.
- **Public profile** at `/u/<display name>`, opt-in in the settings (tab "Profile & community"), with perfect games and average completion. The owner sees a "Public" chip and "Copy link" on it; a private own profile says so and links to the settings.
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.
- **Settings** (`/settings`) have tabs: Account & security, Appearance (with dark mode), Connections (Steam, PlayStation), Profile & community, About (build version). A switch or select saves at once, shows the new value right away, makes no success toast and goes back with an error toast when the save fails; a form with a Save button (name, password) keeps its success toast, and the name field asks before the user leaves with unsaved text.
- **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, both with a show/hide toggle and the rules as helper text) and confirms on a button click, not on load, so mail scanners do not burn the link. Only a `410` (or a `400` that names the `token` field) shows "link
invalid"; any other `400` is a form error and keeps the typed password. An expired or used link and the page after
the send offer "send the link again" (same answer for any address). After the send the page shows the address used
and "Wrong address? Start over", which brings the form back. After the confirmation the page says that a mail comes
once an admin has approved the account (no waiting time, no login link).
- **Login** (`/login`): the form checks both fields before it sends (zod, like the other forms) and has a show/hide
toggle on the password. Auth answers every failed login with `401` (a wrong password, an unknown address and an
unconfirmed one look the same), so after a `401` the page offers "Not confirmed yet? Send the link again" with the
typed address.
- **Language default**: the cached language, else the browser language (`de*` gives German, any other English), else German.
- **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
Community page (`/community`) has the tabs "Following" ("Unfollow"), "Followers" ("Remove", a private follower shows as
"Private user"), "Find users" (search public profiles by name, `GET /api/follows/players?q=` in Core, 30 searches per
minute, only public profiles) and "Community stats" (the numbers of all users, also on the landing page). 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` (the bell in the header) is the one place for what is new: an "N open questions" row that links to `/questions`,
the news the user has not seen (marked seen on open, "All news" links to `/whats-new`) and the newest 50 notes (marked
read on open). The bell badge is the sum of unread notes, unseen news and open questions (`useNotifications`, loaded on
every page change; 0 on `/inbox` itself, and open questions count again after the visit until answered); 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.
The feed links on (#52): the person's name goes to their profile while it is public (`profileName`), a game goes to
your own game page (`/library/<appid>`), and "on their profile" opens `/u/<name>?game=<appid>`, which shows that
game above the tiles (`ProfileGameCard`: status, playtime, achievements, only what the profile shares; one message
when it hides or does not have the game). A game you do not own opens a catalog view (`CatalogGameCard`, `GET
/api/library/games/<appid>/preview`) with an "Add to my library" button that opens the add search with the name filled
in. The "Following" tab (`/community?tab=following`, the old `/following` redirects there, #51) lists the people you follow, newest feed message first, with an initial
letter instead of an avatar, and "Unfollow"; the "Following" dashboard tile (opt-in via "Customize") shows the first
3, 5 or 10 of them and loads its own data.
- **What's new**: unseen news entries show on `/inbox` (no dialog on the dashboard) and are marked seen in Auth when the
inbox opens. `/whats-new` (avatar menu) is the archive: it 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. "Next steps" come first; the main button is the first open step ("Link Steam", then
"Import games"), the one other button is "Go to dashboard". Every way out (buttons, step links, feature links)
stamps it in Auth first, so the gate does not send the user back. The settings page links to it again.
- **Empty library** (dashboard): a card above the tiles. Without a Steam ID or PlayStation link: "Link Steam /
PlayStation" and "Import games". With Steam, a finished sync and no games: Steam's "Game details" must be public,
with a link to the Steam privacy settings (also in the empty library list); no such text for a PlayStation-only user.
The "no Steam" and "empty library" hints stay until the user closes them. Texts are platform neutral ("Welcome"
before the first sync, "Games", "Syncing your library").
- **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.
@@ -47,25 +254,178 @@ To Be Played is split into three repositories:
- 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
(report-only for now) and security headers
- CI: Forgejo Actions (`npm ci`, `npm test`, `npm run build`, Docker image)
and security headers. nginx reads `AUTH_HOST` and `API_HOST` from the container env at start (`nginx.conf` is an
nginx template) and puts them into the CSP `connect-src`.
- ESLint with the React hooks rules (`npm run lint`)
- CI: Gitea Actions (`.gitea/workflows/docker-publish.yml`: `npm ci`, `npm run lint`, `npm test`, `npm run build`, then the
Docker image tagged `latest`, `<branch>`, `<branch>-<sha>`)
## 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.
- Public routes: `/`, `/login`, `/register`, `/confirm-email`, `/reset-password`, `/u/:name` and `/impressum`
(`ImprintPage`, linked from the footer). Every other route sits under `RequireSession`.
- `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`, reached from the bell and the inbox row "N open questions"; the title shows the live
count) starts with a short intro (where questions come from, what an answer gives, what "No" does) and 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 `GameHeroCard`.
Every card shows its points (+5 for a start, up to N or "by time to beat" for a finish, none for dropped and rate).
Removed games are the library filter "Removed" (`/library?removed=true`, `RemovedGamesList`, `GET /api/library/removed-games`) with 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["GameHeroCard / BacklogCard save"] -->|"game has editions"| Reload
```
- The game page edits at once: status (menu with Undo) and rating in `GameHeroCard`, priority in `BacklogCard`; only the
notes there have a Save button. Background, banner, profile art, hide (with Undo) and the auditor tools sit in the "..." menu of the hero card.
- `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`) after a confirm dialog (unlink asks too); `404 NOT_LINKED` shows the generic error toast.
```mermaid
flowchart LR
Header["Bell badge (useNotifications)"] --> Count["Core /api/library/questions/count"]
Header --> Inbox["Core /api/inbox, Auth /api/news"]
InboxPage["InboxPage"] --> Count
Page["QuestionsPage"] --> List["Core /api/library/questions"]
Page -->|"Yes / No / stars"| Answer["Core POST /api/library/questions/{appid}"]
Library["Library filter Removed"] -->|"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 sync runs, then loads the data again. While a first
sync is expected but Core has no status row (`queued`: "First sync queued ..." with "usually under a minute"), it
polls every 3 s for 15 s, then every 15 s, for 10 minutes in all; a run still going after that shows a calm "check
back later" text.
- 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, ...)
├── components/ # Shared components; ui/ holds the base components
├── hooks/ # Data hooks (library sync, Steam profile, appearance, session expiry)
├── context/ # PreferencesContext: theme, language, appearance
├── App.jsx, main.jsx, i18n.js # Routes, entry point, i18next setup
├── style.css, skins.css, effects.css # Base styles, the skins, the background effects
├── pages/ # One component per route (Dashboard, GameLibrary, GameDetail, ...): loads data, places cards
├── components/ # Cards and shared components; ui/ holds the base components, import/, stats/ and people/ the
│ # parts of one page that share page-only code
├── hooks/ # Data hooks (game, dashboard, library sync, Steam profile, appearance, session)
├── context/ # CurrentUserContext, PreferencesContext
├── lib/ # utils.js (class name helper)
├── utils/ # Validation schemas and helpers
└── test/ # Test setup (i18n mock)
└── test/ # renderWithProviders.jsx, fetchRoutes.js, i18n mock, WalletLoader, fixtures/import
```
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 30
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
@@ -81,17 +441,40 @@ npm run dev
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.
Production image (container env, read by nginx at start):
| Variable | Purpose |
|---|---|
| `AUTH_HOST` | Auth host name for the CSP `connect-src` |
| `API_HOST` | Core host name, same |
The image refuses to start when either is unset or empty (`docker-entrypoint.d/10-require-hosts.sh`);
`docker-compose.yml` of the Deployment repo passes both. `FRONTEND_HOST` is not
passed to the frontend image; compose uses it for the Traefik rule and as `APP_FRONTEND_URL` of Auth and Core (Deployment `docker-compose.yml`:68,152).
## 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
- Mood filter for "What to play next".
- Enforce the Content Security Policy (it runs in report-only mode now).
See `ROADMAP.md` in the ToBePlayed-Deployment repo.
## License
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
# Empty hosts would give a CSP connect-src that blocks every API call. Runs before 20-envsubst-on-templates.sh.
: "${AUTH_HOST:?set AUTH_HOST (public host name of Auth) in the container env}"
: "${API_HOST:?set API_HOST (public host name of Core) in the container env}"
+42
View File
@@ -0,0 +1,42 @@
# Glossary
One word per idea in all German (de) and English (en) texts (`public/i18n/*.json`). `src/i18n.test.js` fails when an
old term comes back. A new text uses these words; a new idea gets a row here first.
| Concept | de | en |
|---|---|---|
| finish a game (status FINISHED) | durchspielen, durchgespielt (never "beenden", "abgeschlossen", "Beender") | finish, finished |
| 100 % status (COMPLETED) | 100 % | 100% (no space) |
| proof of play (rewards) | Nachweis, nachgewiesen (never "Beweis", "frisches Spielen") | proof, proven; "recent play" (never "fresh play") |
| earn points | Punkte bringen / es gibt Punkte (never "zahlen") | earn points (never "pay") |
| points booked to the wallet | gutgeschrieben | credited |
| currency / page name | Pile Points | Pile Points |
| pile (games with status BACKLOG) | **Pile of Shame** (proper name, also in German, masculine; never "Stapel") | Pile of Shame; "pile" only inside "Pile of Shame" |
| status BACKLOG | Backlog | Backlog |
| `/backlog` tab and dashboard tile | Warteschlange | Queue |
| `/play-next` | Als Nächstes | Play next |
| game page card (priority, notes) | Mein Fortschritt | My progress |
| find games page (`/search`) | Spiele finden | Find games |
| library search box | In deiner Bibliothek suchen | Search your library |
| sync | der Sync; button "Sync starten"; "Sync läuft …"; "Letzter Sync {{when}}" (never "synchronisieren", "Synchronisierung") | sync; "Sync now"; "Syncing …"; "Last sync {{when}}" |
| retry | button "Noch einmal versuchen"; sentence "Versuche es noch einmal." / "… später noch einmal." (never "erneut") | button "Try again"; sentence "Try again." / "Try again later." (never "Please try again") |
| link queue (auditors) | Verknüpfungen (never "Warteschlange") | Link queue |
| notifications (bell) | Postfach, Nachricht | Inbox, message |
| people you follow (list, tile, tab) | Ich folge (never "Folgen" as a noun); the button stays "Folgen" | Following |
| community page | Community | Community |
| playtime (played hours) | Spielzeit | playtime (one word) |
| time to beat (IGDB) | Spieldauer (never "Spielzeit") | time to beat |
| status history (V14 log) | Status-Verlauf | status history |
| edition of one game | Edition, Editionen (never "Ausgabe") | edition |
| link / unlink an account (Steam, PSN) | verknüpfen / trennen | link / unlink |
| link games as one (auditor) | verknüpfen / Verknüpfung lösen | link / unlink |
| hide a game | ausblenden | hide |
| hide a tile | verstecken | hide |
| user | Nutzer (never "Spieler", "Person" for app users) | user |
| auditor role | Auditor | auditor |
| inbox entry | Nachricht | message (never "note": notes are game notes) |
| email | E-Mail (labels), Mail (in sentences ok) | email (never "a mail", "mails") |
| log in / out | anmelden / abmelden | log in / log out (verbs on buttons) |
| manual game | manuelles Spiel | manual game |
| address form | informal "du", imperative ("Versuche es …"); no impersonal "Bitte … versuchen" | – |
| quotes | „…“ | "…" |
+68
View File
@@ -0,0 +1,68 @@
# README screenshots
The screenshots in the main README show a neutral demo library (30 games, persona "Dev"), never real user data.
They come from the local stack with the WireMock mocks of `ToBePlayed-Core/local/wiremock`.
## Start
1. Start the stack as in the main README, section "Local development without keys".
2. Open the app in a desktop browser, log in with `dev@localhost` / `password123`, pass the welcome page.
3. Settings: dark mode on, language English, public profile on.
4. Dashboard: "Sync now". Wait about one minute: the achievements load one Steam call per second.
5. Set three things by hand (the sync cannot):
- No Man's Sky: status Dropped.
- Slay the Spire: "Set to 100%".
- Celeste: rating 4 stars, note "Chapter 7 next."
6. Reload the dashboard and close the "games imported" banner.
## Capture
Set the viewport (browser DevTools device toolbar, or Playwright) to the size in the table. Take a viewport PNG, no
scaling, not full page.
| File | Viewport | Page and state |
|---|---|---|
| `library-desktop-dark.png` | 1280x1022 | `/library`, filter All, sort "Most played", 20 per page |
| `detail-desktop-dark.png` | 1280x1414 | `/library/504230` (Celeste) |
| `dashboard-desktop-dark.png` | 1280x1100 | `/dashboard`, banner closed, mouse away from the cards |
| `library-mobile-dark.png` | 390x1036 | `/library`, same state as desktop |
| `profile-desktop-dark.png` | 1280x1288 | log out, then `/u/Dev` |
## From the Claude box
The box is itself a container, so the stack differs from the main README. Traps first: the WireMock `-v` path is
resolved on the Docker host (the mock then loads 0 mappings), and the box reaches sibling containers only through
the shared network, not through `localhost`.
```
docker run -d --name tbp_auth_db_local -e POSTGRES_DB=tbp_auth -e POSTGRES_USER=tbp_user -e POSTGRES_PASSWORD=local_secret_password postgres:17-alpine
docker run -d --name tbp_core_db_local -e POSTGRES_DB=tbp_core -e POSTGRES_USER=tbp_user -e POSTGRES_PASSWORD=local_secret_password postgres:17-alpine
docker run -d --name tbp_core_mocks_local wiremock/wiremock:3.9.1
for c in tbp_auth_db_local tbp_core_db_local tbp_core_mocks_local; do docker network connect ai_default $c; done
docker cp /workspace/private/tbp/ToBePlayed-Core/local/wiremock/. tbp_core_mocks_local:/home/wiremock/
curl -X POST tbp_core_mocks_local:8080/__admin/mappings/reset
```
Repeat the `docker cp` and the reset after every mock change. Run the built jars (`target/`) and Vite:
- Auth: `java -jar target/auth-service-0.0.1-SNAPSHOT.jar --spring.profiles.active=local --spring.docker.compose.enabled=false`
plus `--spring.datasource.url=jdbc:postgresql://tbp_auth_db_local:5432/tbp_auth`, `--spring.datasource.username=tbp_user`
and `--spring.datasource.password=local_secret_password`.
- Core: the same with `tbp_core_db_local` and `tbp_core`, plus `--app.steam.base-url=$M/steam`,
`--app.igdb.base-url=$M/igdb`, `--app.igdb.token-base-url=$M/twitch`, `--app.psn.auth-base-url=$M/psn/auth`,
`--app.psn.api-base-url=$M/psn` and `--app.speedrun.base-url=$M/speedrun`. `M` is `http://<mock container IP>:8080`
(`docker inspect tbp_core_mocks_local`): Java rejects the underscore in the container name.
- Frontend: `VITE_API_URL=http://localhost:8080 VITE_CORE_API_URL=http://localhost:8090 npx vite --port 5173`.
When done, stop the three processes and `docker rm -f` the three containers.
## Rules
- Only the demo persona may appear: name "Dev", Steam ID `76561198000000001`, the default avatar.
- Keep the file names. Keep each file below about 500 KB.
- Look at every image before you commit it.
## Data
The 30 games, their achievements and the profile are in `ToBePlayed-Core/local/wiremock`.
`LocalMocksParseTest` checks that the files fit the Core types and that every achievement stub belongs to an owned game.
Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 330 KiB

After

Width:  |  Height:  |  Size: 458 KiB

+28
View File
@@ -0,0 +1,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
export default [
{ignores: ['dist']},
js.configs.recommended,
{
files: ['**/*.{js,jsx}'],
languageOptions: {
parserOptions: {ecmaFeatures: {jsx: true}},
globals: {
...globals.browser,
// set by `define` in vite.config.js
__TRANSLATIONS_VERSION__: 'readonly',
__BUILD_COMMIT__: 'readonly',
__BUILD_TIME__: 'readonly',
},
},
plugins: {'react-hooks': reactHooks},
// Only the two classic rules: the React Compiler rules of the plugin's recommended set are a later step.
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
},
},
{files: ['*.config.js'], languageOptions: {globals: globals.node}},
];
+1 -2
View File
@@ -3,8 +3,7 @@ server {
root /usr/share/nginx/html;
# An nginx template: AUTH_HOST and API_HOST come from the container env at start (see Dockerfile).
# Report-Only until production shows no violation in the browser console; then rename the header.
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.steamstatic.com https://steamcdn-a.akamaihd.net https://media.steampowered.com https://images.igdb.com; font-src 'self' data:; connect-src 'self' https://${AUTH_HOST} https://${API_HOST}; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.steamstatic.com https://steamcdn-a.akamaihd.net https://media.steampowered.com https://images.igdb.com https://psnobj.prod.dl.playstation.net; font-src 'self' data:; connect-src 'self' https://${AUTH_HOST} https://${API_HOST}; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
+1312
View File
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -7,7 +7,8 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run"
"test": "vitest run",
"lint": "eslint ."
},
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
@@ -29,10 +30,14 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@vitejs/plugin-react": "^6.1.1",
"autoprefixer": "~10.6.1",
"eslint": "^10.11.0",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.12.0",
"jsdom": "^30.1.1",
"postcss": "~8.5.28",
"tailwindcss": "~4.3.3",
+1197 -60
View File
File diff suppressed because it is too large. Load diff
+1194 -57
View File
File diff suppressed because it is too large. Load diff
+65
View File
@@ -0,0 +1,65 @@
/*
* Sideways scroll check for phones. Not part of the gate, run by hand on the local stack (see README, "Overflow check").
* A Playwright function, run with the Playwright MCP tool browser_run_code_unsafe (filename = this file) or
* `playwright-cli run-code`. Log in as an admin in that browser first. A logged-in browser redirects the logged-out
* pages, so check those in a second run with LOGGED_OUT = true in a clean browser (no session).
* A hit is: scrollWidth != clientWidth, an element sticking out on the left (never raises scrollWidth), or a
* redirect away from the page.
* Content clipped inside an overflow-hidden ancestor is not checked (same rule as scrollWidth).
*/
async (page) => {
const BASE = 'http://localhost:5173';
const WIDTHS = [360, 390, 412, 414];
const FONT_PERCENT = 100; // set 130 for the large system font run
const DARK = false; // set true for dark mode
const SKIN = ''; // e.g. 'pastel' to check a skin
const LOGGED_OUT = false; // true: only the logged-out pages, run in a browser without a session
const GAME_ID = 620; // any appid in the library
const PROFILE_NAME = 'Dev'; // display name of the logged-in user, the profile must be public
const PAGES = LOGGED_OUT ? ['/', '/login', '/register', '/reset-password', '/impressum', `/u/${encodeURIComponent(PROFILE_NAME)}`] : [
'/dashboard', '/library', `/library/${GAME_ID}`, '/wishlist', '/play-next', '/stats', '/import', '/points',
'/settings', '/whats-new', '/welcome', '/audit/games', '/audit/links', '/audit/log', '/admin/users',
'/admin/hints', '/admin/news', '/admin/broadcasts', `/u/${encodeURIComponent(PROFILE_NAME)}`,
];
const hits = [];
let checked = 0;
for (const width of WIDTHS) {
await page.setViewportSize({width, height: 800});
for (const path of PAGES) {
await page.goto(BASE + path); // no networkidle: some pages poll
await page.waitForTimeout(1500);
if (FONT_PERCENT !== 100) await page.addStyleTag({content: `html{font-size:${FONT_PERCENT}%}`});
await page.evaluate(([dark, skin]) => {
document.documentElement.classList.toggle('dark', dark);
if (skin) document.documentElement.dataset.skin = skin;
}, [DARK, SKIN]);
await page.waitForTimeout(300);
const result = await page.evaluate(() => {
const root = document.documentElement;
const describe = (element) => `${element.tagName.toLowerCase()}.${String(element.className).slice(0, 60)}`;
const all = [...document.querySelectorAll('body *')].map((element) => [element, element.getBoundingClientRect()]);
const right = all.filter(([, box]) => box.right > root.clientWidth).sort((a, b) => b[1].right - a[1].right);
// Clipped by an overflow:hidden/auto ancestor (an effect preview, a scroller): not visible, not a hit.
const clipped = (element) => {
for (let parent = element.parentElement; parent && parent !== document.body; parent = parent.parentElement) {
if (getComputedStyle(parent).overflowX !== 'visible') return true;
}
return false;
};
const left = all.filter(([element, box]) => box.width > 0 && box.left < 0 && !clipped(element));
if (root.scrollWidth === root.clientWidth && left.length === 0) return null;
return {
scrollWidth: root.scrollWidth, clientWidth: root.clientWidth,
widest: right.slice(0, 5).map(([element]) => describe(element)),
offLeft: left.slice(0, 5).map(([element]) => describe(element)),
};
});
checked++;
const landed = await page.evaluate(() => location.pathname);
if (decodeURIComponent(landed) !== decodeURIComponent(path)) hits.push({path, width, redirectedTo: landed});
if (result) hits.push({path, width, ...result});
}
}
return {checked, hits};
}
+70 -21
View File
@@ -1,75 +1,122 @@
import {Link, Route, Routes, useLocation, useNavigate} from 'react-router-dom';
import {Link, Navigate, Route, Routes, useLocation, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {useEffect, useState} from 'react';
import {useEffect} from 'react';
import LandingPage from './pages/LandingPage';
import LoginPage from './pages/LoginPage';
import ImprintPage from './pages/ImprintPage';
import NotFoundPage from './pages/NotFoundPage.jsx';
import Dashboard from "./pages/Dashboard.jsx";
import RegistrationPage from './pages/RegistrationPage.jsx';
import ConfirmEmailPage from './pages/ConfirmEmailPage.jsx';
import ResetPasswordPage from './pages/ResetPasswordPage.jsx';
import SettingsPage from './pages/SettingsPage.jsx';
import AdminHintsPage from './pages/AdminHintsPage.jsx';
import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx';
import AdminNewsPage from './pages/AdminNewsPage.jsx';
import InboxPage from './pages/InboxPage.jsx';
import CommunityPage from './pages/CommunityPage.jsx';
import QuestionsPage from './pages/QuestionsPage.jsx';
import WhatsNewPage from './pages/WhatsNewPage.jsx';
import WelcomePage from './pages/WelcomePage.jsx';
import WelcomeGate from './components/WelcomeGate.jsx';
import AdminUsersPage from './pages/AdminUsersPage.jsx';
import AuditLogPage from './pages/AuditLogPage.jsx';
import GameCatalogPage from './pages/GameCatalogPage.jsx';
import LinkQueuePage from './pages/LinkQueuePage.jsx';
import GameLibrary from './pages/GameLibrary.jsx';
import GameDetail from './pages/GameDetail.jsx';
import WishlistPage from './pages/WishlistPage.jsx';
import BacklogPage from './pages/BacklogPage.jsx';
import PlayNextPage from './pages/PlayNextPage.jsx';
import StatsPage from './pages/StatsPage.jsx';
import ImportPage from './pages/ImportPage.jsx';
import GameSearchPage from './pages/GameSearchPage.jsx';
import PointsPage from './pages/PointsPage.jsx';
import PublicProfilePage from './pages/PublicProfilePage.jsx';
import AppHeader from './components/AppHeader.jsx';
import BottomNav from './components/BottomNav.jsx';
import RequireSession from './components/RequireSession.jsx';
import {cn} from '@/lib/utils';
import {Toaster} from '@/components/ui/sonner';
import {TooltipProvider} from '@/components/ui/tooltip';
import PageBackground from './components/PageBackground.jsx';
import {usePreferences} from './context/PreferencesContext.jsx';
import {clearSession, hasSession} from './utils/auth.js';
import {backgroundEffect} from './utils/rewards.js';
import {clearSession} from './utils/auth.js';
import useSession from './hooks/useSession.js';
import {UnsavedChangesGuard} from './hooks/useUnsavedChanges.js';
import {logout} from './utils/api.js';
function App() {
const {t} = useTranslation();
const [isAuthenticated, setIsAuthenticated] = useState(false);
const {appearance, syncAppearance, resetAppearance} = usePreferences();
const {t, i18n} = useTranslation();
const {active: isAuthenticated} = useSession();
const {appearance, equipped, syncPreferences} = usePreferences();
// Hooks for routing
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
// No local expiry check here: a 401 from the backend ends the session (useSessionExpired), and a browser
// clock that runs ahead would otherwise end it silently.
setIsAuthenticated(hasSession());
}, [location]);
useEffect(() => { document.documentElement.lang = i18n.language; }, [i18n.language]);
// On login and on page load; the cached look shows until Auth answers.
useEffect(() => {
if (isAuthenticated) syncAppearance();
}, [isAuthenticated, syncAppearance]);
if (isAuthenticated) syncPreferences();
}, [isAuthenticated, syncPreferences]);
// Waits for Auth: a late logout answer would clear the cookies of a login that came after it.
const handleLogout = async () => {
await logout();
clearSession();
resetAppearance();
setIsAuthenticated(false);
navigate('/login');
};
return (
<TooltipProvider>
{/* isolate: the background layer sits behind the content but in front of this div's own background. */}
<div className="isolate min-h-screen flex flex-col bg-background text-foreground font-sans">
{isAuthenticated && <PageBackground appid={appearance.backgroundAppid}/>}
<div className="isolate min-h-screen flex flex-col bg-background skin-bg text-foreground font-sans">
{/* A public profile shows the owner's background instead. */}
{isAuthenticated && !location.pathname.startsWith('/u/') && (
<PageBackground appid={appearance.backgroundAppid} effect={backgroundEffect(equipped)}/>
)}
<AppHeader isAuthenticated={isAuthenticated} onLogout={handleLogout}/>
<main className="grow flex flex-col">
<main className={cn('grow flex flex-col', isAuthenticated && 'max-md:pb-16')}>
<Routes>
<Route path="/" element={<LandingPage/>}/>
<Route path="/login" element={<LoginPage/>}/>
<Route path="/register" element={<RegistrationPage/>}/>
<Route path="/dashboard" element={<Dashboard/>}/>
<Route path="/confirm-email" element={<ConfirmEmailPage/>}/>
<Route path="/reset-password" element={<ResetPasswordPage/>}/>
<Route element={<RequireSession/>}>
<Route path="/dashboard" element={<WelcomeGate><Dashboard/></WelcomeGate>}/>
<Route path="/library" element={<GameLibrary/>}/>
<Route path="/library/:appid" element={<GameDetail/>}/>
<Route path="/search" element={<GameSearchPage/>}/>
<Route path="/wishlist" element={<WishlistPage/>}/>
<Route path="/backlog" element={<BacklogPage/>}/>
<Route path="/play-next" element={<PlayNextPage/>}/>
<Route path="/u/:name" element={<PublicProfilePage/>}/>
<Route path="/stats" element={<StatsPage/>}/>
<Route path="/community" element={<CommunityPage/>}/>
<Route path="/following" element={<Navigate to="/community?tab=following" replace/>}/>
<Route path="/import" element={<ImportPage/>}/>
<Route path="/points" element={<PointsPage/>}/>
<Route path="/settings" element={<SettingsPage/>}/>
<Route path="/whats-new" element={<WhatsNewPage/>}/>
<Route path="/inbox" element={<InboxPage/>}/>
<Route path="/questions" element={<QuestionsPage/>}/>
<Route path="/welcome" element={<WelcomePage/>}/>
<Route path="/audit/games" element={<GameCatalogPage/>}/>
<Route path="/audit/links" element={<LinkQueuePage/>}/>
<Route path="/audit/log" element={<AuditLogPage/>}/>
<Route path="/admin/users" element={<AdminUsersPage/>}/>
<Route path="/admin/hints" element={<AdminHintsPage/>}/>
<Route path="/admin/news" element={<AdminNewsPage/>}/>
<Route path="/admin/broadcasts" element={<AdminBroadcastPage/>}/>
</Route>
<Route path="/u/:name" element={<PublicProfilePage/>}/>
<Route path="/impressum" element={<ImprintPage/>}/>
<Route path="*" element={<NotFoundPage/>}/>
</Routes>
</main>
@@ -78,8 +125,10 @@ function App() {
{t('IMPRINT.TITLE')}
</Link>
</footer>
{isAuthenticated && <BottomNav/>}
</div>
<Toaster richColors position="bottom-right"/>
<UnsavedChangesGuard/>
<Toaster richColors position="bottom-right" mobileOffset={{bottom: 72}} className="max-md:![--offset-bottom:72px]"/>
</TooltipProvider>
);
}
+43 -19
View File
@@ -1,9 +1,9 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, useLocation} from 'react-router-dom';
import {act, cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import App from './App.jsx';
import {PreferencesProvider} from './context/PreferencesContext.jsx';
import {fetchRoutes, json} from './test/fetchRoutes.js';
import {renderWithProviders} from './test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('./test/i18n-mock.js'));
vi.mock('./hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
@@ -14,16 +14,8 @@ const page = {content: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, st
const wait = (ms) => act(() => new Promise((resolve) => setTimeout(resolve, ms)));
const current = {location: null};
function Spy() {
current.location = useLocation();
return null;
}
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => page}));
});
afterEach(() => {
@@ -32,20 +24,52 @@ afterEach(() => {
});
describe('App', () => {
it('redirectsTheOldFollowingUrlToTheCommunityTab', async () => {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {}, history: []}),
'GET /api/inbox': json(200, {results: [], unread: 0}),
'GET /api/news': json(200, {results: []}),
'GET /api/library/questions/count': json(200, {count: 0}),
'GET /api/follows': json(200, {following: [], followers: []}),
'GET /api/library/steam-profile': json(200, null),
});
const {router} = renderWithProviders(<App/>, {entries: ['/following']});
await waitFor(() => expect(router.state.location.pathname + router.state.location.search).toBe('/community?tab=following'));
});
it('sets the document language to the UI language', () => {
fetchRoutes({'GET /api/users/me': json(200, {settings: {}})});
localStorage.clear();
renderWithProviders(<App/>, {entries: ['/impressum']});
expect(document.documentElement.lang).toBe('de');
});
it('ends the session on a 401 that the refresh cannot fix', async () => {
render(
<MemoryRouter initialEntries={['/library']}>
<PreferencesProvider><App/><Spy/></PreferencesProvider>
</MemoryRouter>,
);
let expired = false;
const library = () => (expired ? json(401, {}) : json(200, page));
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {}, history: []}),
'GET /api/library/games': library,
'GET /api/library/games-count': json(200, {hasSteamId: true}),
'GET /api/library/steam-profile': json(200, null),
'POST /api/auth/refresh': json(401, {}),
'GET /api/inbox': json(200, {results: [], unread: 0}),
'GET /api/news': json(200, {results: []}),
'GET /api/library/questions/count': json(200, {count: 0}),
});
const {router} = renderWithProviders(<App/>, {entries: ['/library']});
await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy());
fetch.mockImplementation(async () => ({ok: false, status: 401, json: async () => ({})}));
expired = true;
fireEvent.change(screen.getByRole('searchbox'), {target: {value: 'half'}});
await wait(DEBOUNCE_WAIT_MS);
await waitFor(() => expect(current.location.pathname).toBe('/login'));
expect(current.location.state).toEqual({sessionExpired: true, from: '/library?q=half&page=0'});
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/library?q=half&page=0'});
expect(localStorage.getItem('tbp_session')).toBeNull();
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/auth/refresh'), expect.objectContaining({method: 'POST'}));
});
+49
View File
@@ -0,0 +1,49 @@
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Rocket, UserRound} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {formatDateTime} from '../utils/format.js';
const formatDate = (value, locale) => {
if (!value) return '—';
try {
return formatDateTime(value, locale);
} catch {
return value;
}
};
export default function AccountInfoCard() {
const {t, i18n} = useTranslation();
const {user: profile} = useCurrentUserContext();
const accountRows = [
[t('SETTINGS.EMAIL'), profile?.email],
[t('SETTINGS.ROLE'), profile?.role && t(`ROLES.${profile.role}`)],
[t('SETTINGS.CREATED_DATE'), formatDate(profile?.createdDate, i18n.language)],
[t('SETTINGS.LAST_LOGIN'), formatDate(profile?.lastLogin, i18n.language)],
];
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><UserRound className="size-4 text-primary"/>{t('SETTINGS.ACCOUNT')}</CardTitle>
</CardHeader>
<CardContent>
<dl className="divide-y">
{accountRows.map(([label, value]) => (
<div key={label} className="flex flex-col gap-1 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between">
<dt className="text-sm text-muted-foreground">{label}</dt>
<dd className="text-sm font-medium break-all">{value || '—'}</dd>
</div>
))}
</dl>
<Button asChild variant="link" className="mt-3 h-auto max-w-full whitespace-normal p-0 text-left">
<Link to="/welcome"><Rocket/>{t('SETTINGS.OPEN_WELCOME')}</Link>
</Button>
</CardContent>
</Card>
);
}
+25
View File
@@ -0,0 +1,25 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, screen} from '@testing-library/react';
import AccountInfoCard from './AccountInfoCard.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
});
describe('AccountInfoCard', () => {
it('shows the role by its translated name, not the enum value', async () => {
fetchRoutes({'GET /api/users/me': json(200, {email: 'a@b.de', role: 'PLATFORM_ADMIN', settings: {}})});
renderWithProviders(<AccountInfoCard/>, {path: '/settings', entries: ['/settings']});
expect(await screen.findByText('ROLES.PLATFORM_ADMIN')).toBeTruthy();
expect(screen.queryByText('PLATFORM_ADMIN')).toBeNull();
});
});
+18 -9
View File
@@ -4,6 +4,9 @@ import {Trophy} from 'lucide-react';
import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import {StatCount} from './stats/StatDrill.jsx';
import StatMeter from './stats/StatMeter.jsx';
/**
* @param summary the answer of GET /api/library/achievements/summary
@@ -14,14 +17,17 @@ export default function AchievementsCard({summary}) {
const started = summary.startedGames ?? 0;
const pending = summary.pendingGames ?? 0;
const hidden = summary.hiddenGames ?? 0;
const perfect = summary.perfectGames ?? 0;
// A private profile keeps games pending; the privacy hint explains that better than the pending count.
const steamHidesAll = hidden > 0 && started === 0;
const average = summary.averageCompletion != null ? `${summary.averageCompletion.toLocaleString(i18n.language)} %` : '–';
const stats = [
[(summary.unlocked ?? 0).toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.UNLOCKED')],
[(summary.perfectGames ?? 0).toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.PERFECT')],
[summary.averageCompletion != null ? `${summary.averageCompletion.toLocaleString(i18n.language)} %` : '–',
t('DASHBOARD.ACHIEVEMENTS.AVERAGE')],
[perfect.toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.PERFECT'),
{params: {key: 'PERFECT'}, count: perfect}],
[average, t('DASHBOARD.ACHIEVEMENTS.AVERAGE'), undefined,
summary.averageCompletion != null ? {percent: summary.averageCompletion, valueText: average} : null],
];
return (
@@ -32,9 +38,12 @@ export default function AchievementsCard({summary}) {
</h2>
<div className="grid grid-cols-3 gap-3">
{stats.map(([value, label]) => (
{stats.map(([value, label, drill, meter]) => (
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
<div className="text-xl md:text-2xl font-bold tabular-nums">{value}</div>
<div className="text-xl md:text-2xl font-bold tabular-nums">
<StatCount {...drill} title={label} label={`${label}: ${value}`}>{value}</StatCount>
</div>
{meter && <StatMeter {...meter} label={label}/>}
<div className="text-xs text-muted-foreground">{label}</div>
</div>
))}
@@ -54,11 +63,11 @@ export default function AchievementsCard({summary}) {
to={`/library/${game.appid}`}
className="group flex items-center gap-3 rounded-lg p-1 transition-colors hover:bg-muted/50"
>
<GameCover appid={game.appid} className="w-16 shrink-0"/>
<div className="min-w-0 flex-1 space-y-1">
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-16 shrink-0"/>
<div className="@container min-w-0 flex-1 space-y-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-sm font-medium group-hover:text-primary transition-colors">
{game.name ?? `App ${game.appid}`}
<span className="min-w-0 text-sm font-medium group-hover:text-primary transition-colors">
<FitText text={game.name ?? `App ${game.appid}`}/>
</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{game.unlocked} / {game.total}
+37 -1
View File
@@ -1,8 +1,10 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import AchievementsCard from './AchievementsCard.jsx';
import {StatDrillProvider} from './stats/StatDrill.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
@@ -29,6 +31,22 @@ describe('AchievementsCard', () => {
expect(screen.queryByText(/DASHBOARD.ACHIEVEMENTS.PENDING/)).toBeNull();
});
it('showsTheAverageCompletionAsAMeter', () => {
renderCard(summary());
const meter = screen.getByRole('meter', {name: 'DASHBOARD.ACHIEVEMENTS.AVERAGE'});
expect(meter.getAttribute('aria-valuenow')).toBe('71');
expect(meter.getAttribute('aria-valuetext')).toBe('71,3 %');
});
it('showsNoMeterWithoutAnAverage', () => {
renderCard(summary({averageCompletion: null}));
expect(screen.queryByRole('meter')).toBeNull();
expect(screen.getByText('–')).toBeTruthy();
});
it('says how many games are still syncing', () => {
renderCard(summary({pendingGames: 7}));
@@ -49,4 +67,22 @@ describe('AchievementsCard', () => {
expect(screen.getByText('DASHBOARD.ACHIEVEMENTS.NONE')).toBeTruthy();
});
it('opensThePerfectGamesInTheOwnLibrary', async () => {
fetchRoutes({'GET /api/library/stats/games': json(200, {
content: [{appid: -7, name: 'Manual Game'}], page: 0, size: 40, totalElements: 1, totalPages: 1})});
render(<MemoryRouter><StatDrillProvider source="own"><AchievementsCard summary={summary()}/></StatDrillProvider></MemoryRouter>);
fireEvent.click(screen.getByRole('button', {name: 'DASHBOARD.ACHIEVEMENTS.PERFECT: 2'}));
const link = await screen.findByRole('link', {name: /Manual Game/});
expect(fetch.mock.calls[0][0]).toContain('key=PERFECT&page=0');
expect(link.getAttribute('href')).toBe('/library/-7');
});
it('perfectGamesAreNotClickableAtZero', () => {
render(<MemoryRouter><StatDrillProvider source="own"><AchievementsCard summary={summary({perfectGames: 0})}/></StatDrillProvider></MemoryRouter>);
expect(screen.queryByRole('button')).toBeNull();
});
});
+44 -10
View File
@@ -4,53 +4,79 @@ import {Activity} from 'lucide-react';
import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import ChartPicker from './stats/ChartPicker.jsx';
import GamePlayChart from './stats/GamePlayChart.jsx';
import {formatPlaytime} from '../utils/games.js';
import {formatDay} from '../utils/format.js';
import {chartProps, linePoints, playChart} from '../utils/statCharts.js';
const CHART_HEIGHT = 80;
const BAR_WIDTH = 10;
const BAR_GAP = 2;
const formatDay = (day, locale) =>
new Date(`${day}T12:00:00`).toLocaleDateString(locale, {day: 'numeric', month: 'short'});
/**
* @param activity the answer of GET /api/library/activity
* @param charts the chart form per group of the user
* @param onChart `(group, form)`: shows the pickers
*/
export default function ActivityCard({activity}) {
export default function ActivityCard({activity, charts = null, onChart = null}) {
const {t, i18n} = useTranslation();
const {days, totalMinutes, recentGames} = activity;
const max = Math.max(1, ...days.map((d) => d.minutes));
const width = days.length * (BAR_WIDTH + BAR_GAP);
const dayChart = chartProps(charts, onChart, 'ACTIVITY');
const gameChart = chartProps(charts, onChart, 'ACTIVITY_GAMES');
const line = dayChart.chart === 'LINE' && days.length > 1;
const points = line ? linePoints(days.map((d) => d.minutes), width, CHART_HEIGHT) : [];
const recent = recentGames.map((g) => ({appid: g.appid, name: g.name, minutes: g.playtime2Weeks}));
const form = playChart(gameChart.chart, recent);
const dayTitle = (d) => `${formatDay(d.day, i18n.language)}: ${formatPlaytime(d.minutes)}`;
return (
<Card className="gap-5 p-5">
<div className="flex items-baseline justify-between gap-4">
<div className="flex flex-wrap items-baseline justify-between gap-x-4">
<h2 className="flex items-center gap-2 font-semibold">
<Activity className="size-4 text-primary"/>
{t('DASHBOARD.ACTIVITY')}
</h2>
<div className="flex flex-wrap items-center gap-x-3">
<span className="text-sm text-muted-foreground tabular-nums">
{t('DASHBOARD.ACTIVITY_TOTAL', {days: days.length, time: formatPlaytime(totalMinutes)})}
</span>
{dayChart.onChart && <ChartPicker group="ACTIVITY" value={dayChart.chart} onChange={dayChart.onChart}/>}
</div>
</div>
{totalMinutes === 0 ? (
<p className="text-sm text-muted-foreground">{t('DASHBOARD.ACTIVITY_EMPTY')}</p>
) : (
<div>
<div className="relative">
<svg viewBox={`0 0 ${width} ${CHART_HEIGHT}`} preserveAspectRatio="none" className="h-20 w-full"
role="img" aria-label={t('DASHBOARD.ACTIVITY')}>
{days.map((d, i) => {
{line ? (
<polyline points={points.map((p) => `${p.x},${p.y}`).join(' ')} fill="none"
className="stroke-primary" strokeWidth={2} strokeLinejoin="round"
vectorEffect="non-scaling-stroke"/>
) : days.map((d, i) => {
const height = (d.minutes / max) * CHART_HEIGHT;
return (
<rect key={d.day} data-testid="activity-bar" x={i * (BAR_WIDTH + BAR_GAP)}
y={CHART_HEIGHT - height} width={BAR_WIDTH} height={height} rx={2}
className="fill-primary">
<title>{`${formatDay(d.day, i18n.language)}: ${formatPlaytime(d.minutes)}`}</title>
<title>{dayTitle(d)}</title>
</rect>
);
})}
</svg>
{/* Dots are HTML over the stretched svg, so they stay round and can show a tooltip. */}
{line && days.map((d, i) => (
<span key={d.day} title={dayTitle(d)} data-testid="activity-dot"
className="absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary"
style={{left: `${(points[i].x / width) * 100}%`, top: `${(points[i].y / CHART_HEIGHT) * 100}%`}}/>
))}
</div>
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
<span>{formatDay(days[0].day, i18n.language)}</span>
<span>{formatDay(days[days.length - 1].day, i18n.language)}</span>
@@ -60,20 +86,28 @@ export default function ActivityCard({activity}) {
{recentGames.length > 0 && (
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-x-3">
<span className="text-sm font-medium">{t('DASHBOARD.RECENTLY_PLAYED')}</span>
{gameChart.onChart && (
<ChartPicker group="ACTIVITY_GAMES" value={gameChart.chart} onChange={gameChart.onChart}
label={`${t('STATS.CHART.LABEL')}: ${t('DASHBOARD.RECENTLY_PLAYED')}`}/>
)}
</div>
{form !== 'LIST' ? <GamePlayChart games={recent} chart={form} linked/> : (
<ul className="space-y-2">
{recentGames.map((g) => (
<li key={g.appid}>
<Link to={`/library/${g.appid}`} className="group flex items-center gap-3">
<GameCover appid={g.appid} className="w-16 shrink-0"/>
<span className="min-w-0 flex-1 truncate text-sm font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`}
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 shrink-0"/>
<span className="@container min-w-0 flex-1 text-sm font-medium group-hover:text-primary transition-colors">
<FitText text={g.name ?? `App ${g.appid}`}/>
</span>
<span className="text-sm text-muted-foreground tabular-nums">{formatPlaytime(g.playtime2Weeks)}</span>
</Link>
</li>
))}
</ul>
)}
</div>
)}
</Card>
+81 -2
View File
@@ -1,13 +1,15 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import {cleanup, fireEvent, render, screen, within} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import ActivityCard from './ActivityCard.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const days = (minutes) => minutes.map((m, i) => ({day: `2026-09-${String(10 + i).padStart(2, '0')}`, minutes: m}));
const renderCard = (activity) => render(<MemoryRouter><ActivityCard activity={activity}/></MemoryRouter>);
const renderCard = (activity, props) => render(<MemoryRouter><ActivityCard activity={activity} {...props}/></MemoryRouter>);
const recent = (n) => Array.from({length: n}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 900,
playtime2Weeks: 600 - i * 10, status: 'PLAYING'}));
describe('ActivityCard', () => {
afterEach(cleanup);
@@ -35,4 +37,81 @@ describe('ActivityCard', () => {
expect(screen.getByText('DASHBOARD.ACTIVITY_EMPTY')).toBeTruthy();
});
it('drawsALineForTheStoredLineForm', () => {
const {container} = renderCard({days: days([0, 60, 120]), totalMinutes: 180, recentGames: []},
{charts: {ACTIVITY: 'LINE'}});
expect(screen.queryAllByTestId('activity-bar')).toHaveLength(0);
const line = container.querySelector('polyline');
expect(line.getAttribute('vector-effect')).toBe('non-scaling-stroke');
const dots = screen.getAllByTestId('activity-dot');
expect(dots).toHaveLength(3);
expect(dots[1].getAttribute('title')).toMatch(/: 1\.0h$/);
expect(container.querySelectorAll('svg[role="img"] polyline')).toHaveLength(1);
});
it('drawsBarsForALineWithOneDay', () => {
renderCard({days: days([60]), totalMinutes: 60, recentGames: []}, {charts: {ACTIVITY: 'LINE'}});
expect(screen.getAllByTestId('activity-bar')).toHaveLength(1);
});
it('showsNoPickerWithoutOnChart', () => {
renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(1)});
expect(screen.queryByRole('group')).toBeNull();
});
it('showsTwoPickersWithDifferentNamesWithOnChart', () => {
const onChart = vi.fn();
renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(2)}, {onChart});
const day = screen.getByRole('group', {name: 'STATS.CHART.LABEL'});
expect(screen.getByRole('group', {name: 'STATS.CHART.LABEL: DASHBOARD.RECENTLY_PLAYED'})).toBeTruthy();
fireEvent.click(within(day).getByRole('button', {name: 'STATS.CHART.LINE'}));
expect(onChart).toHaveBeenCalledWith('ACTIVITY', 'LINE');
});
it('recentGamesPieLinksTheGamesAndOtherIsPlain', () => {
const {container} = renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(7)},
{charts: {ACTIVITY_GAMES: 'PIE'}});
expect(container.querySelectorAll('path.stroke-card')).toHaveLength(6);
const links = screen.getAllByRole('link');
expect(links).toHaveLength(5);
expect(links[0].getAttribute('href')).toBe('/library/100');
expect(screen.getByText('STATS.CHART.OTHER').closest('a,button')).toBeNull();
expect(screen.getByText('9.8h')).toBeTruthy();
expect(screen.getByText('STATS.TOP_GAMES.SHARE')).toBeTruthy();
});
it('recentGamesPieWithOneGameDrawsTheList', () => {
const {container} = renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(1)},
{charts: {ACTIVITY_GAMES: 'DONUT'}});
expect(container.querySelectorAll('path.stroke-card')).toHaveLength(0);
expect(screen.getByRole('link', {name: /Game 0/}).getAttribute('href')).toBe('/library/100');
});
it('recentGamesBarsHaveNoCovers', () => {
const {container} = renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(2)},
{charts: {ACTIVITY_GAMES: 'BARS'}});
expect(container.querySelectorAll('img')).toHaveLength(0);
expect(screen.getByRole('link', {name: /Game 1/}).getAttribute('href')).toBe('/library/101');
});
it('twoGamesWithTheSameNameGetTheirOwnSlice', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const games = [{appid: 1, name: 'Prey', playtimeMinutes: 900, playtime2Weeks: 300, status: 'PLAYING'},
{appid: 2, name: 'Prey', playtimeMinutes: 900, playtime2Weeks: 200, status: 'PLAYING'}];
renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: games}, {charts: {ACTIVITY_GAMES: 'PIE'}});
const sameKey = error.mock.calls.some((args) => args.join(' ').includes('same key'));
error.mockRestore();
expect(sameKey).toBe(false);
expect(screen.getAllByRole('link', {name: /Prey/}).map((a) => a.getAttribute('href'))).toEqual(['/library/1', '/library/2']);
});
});
+61 -93
View File
@@ -2,67 +2,55 @@ import {useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {ArrowLeft, Loader2, Plus, Search, X} from 'lucide-react';
import {ArrowLeft, Loader2, Plus, X} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import GameCover from './GameCover.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js';
import {GAME_STATUSES} from '../utils/backlog.js';
import {addGame, hoursToMinutes, searchGames} from '../utils/manualGames.js';
const SEARCH_DEBOUNCE_MS = 400;
const MIN_QUERY = 2;
import FitText from './FitText.jsx';
import GameSearch from './GameSearch.jsx';
import usePointsCheck from '../hooks/usePointsCheck.js';
import useStartImport from '../hooks/useStartImport.js';
import {GAME_STATUSES, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
import {addGame, hitTitle, hoursToMinutes} from '../utils/manualGames.js';
import {preselectPlatform} from '../utils/platforms.js';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
/**
* Search IGDB and add a game the user owns outside Steam, e.g. on PlayStation or Switch.
* Search IGDB and add a game the user owns outside Steam, e.g. on PlayStation or Switch; or check several and review
* them on the import page.
*
* @param onAdded called with the new appid before the navigation, for a page that shows the same address
* @param initialQuery the search text to start with, e.g. the name of a game seen elsewhere
*/
export default function AddGamePanel({onClose}) {
export default function AddGamePanel({onClose, onAdded, initialQuery = ''}) {
const {t} = useTranslation();
const navigate = useNavigate();
const expireSession = useSessionExpired();
const [query, setQuery] = useState('');
// idle | loading | done | unavailable | failed
const [searchState, setSearchState] = useState('idle');
const [results, setResults] = useState([]);
const [query, setQuery] = useState(initialQuery);
const [picked, setPicked] = useState(null);
// igdbId -> hit, over several searches.
const [selected, setSelected] = useState(() => new Map());
const [busy, setBusy] = useState(false);
const favorite = useCurrentUserContext().user?.settings?.favoritePlatform ?? null;
const startImport = useStartImport();
useEffect(() => {
const trimmed = query.trim();
if (trimmed.length < MIN_QUERY) {
setSearchState('idle');
setResults([]);
return undefined;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
setSearchState('loading');
try {
const response = await searchGames(trimmed, controller.signal);
if (response.status === 401) {
expireSession();
return;
}
if (!response.ok) {
setSearchState('failed');
return;
}
const data = await response.json();
setResults(data.results ?? []);
setSearchState(data.available ? 'done' : 'unavailable');
} catch (err) {
if (err.name !== 'AbortError') setSearchState('failed');
}
}, SEARCH_DEBOUNCE_MS);
return () => {
clearTimeout(timer);
controller.abort();
const toggle = (hit) => {
const next = new Map(selected);
if (!next.delete(hit.igdbId)) next.set(hit.igdbId, hit);
setSelected(next);
};
// Several games go through a SEARCH import: its review picks the platforms, and it can be undone.
const reviewSelected = async () => {
setBusy(true);
const id = await startImport('SEARCH', [...selected.values()]
.map((hit) => ({igdbId: hit.igdbId, title: hit.name.slice(0, 100)})));
setBusy(false);
if (id) navigate(`/import?id=${id}`);
};
}, [query, expireSession]);
return (
<Card className="p-5 gap-4">
@@ -77,48 +65,20 @@ export default function AddGamePanel({onClose}) {
</div>
{picked ? (
<AddForm hit={picked} onBack={() => setPicked(null)} navigate={navigate} expireSession={expireSession}/>
<AddForm hit={picked} favorite={favorite} onBack={() => setPicked(null)} navigate={navigate}
onAdded={onAdded}/>
) : (
<>
<p className="text-sm text-muted-foreground">{t('MANUAL_GAME.ADD_HINT')}</p>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
<Input type="search" autoFocus value={query} maxLength={100} className="pl-8"
placeholder={t('MANUAL_GAME.SEARCH_PLACEHOLDER')}
aria-label={t('MANUAL_GAME.SEARCH_PLACEHOLDER')}
onChange={(e) => setQuery(e.target.value)}/>
<GameSearch query={query} onQueryChange={setQuery} onPick={setPicked} blockInLibrary
selected={selected} onToggle={toggle}/>
{selected.size > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="text-sm text-muted-foreground">{t('MANUAL_GAME.SELECTED', {count: selected.size})}</span>
<Button onClick={reviewSelected} disabled={busy}>
{busy ? <Loader2 className="animate-spin"/> : <Plus/>}{t('MANUAL_GAME.ADD_SELECTED')}
</Button>
</div>
{searchState === 'loading' && (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin"/>{t('MANUAL_GAME.SEARCHING')}
</p>
)}
{searchState === 'unavailable' && <p className="text-sm text-muted-foreground">{t('MANUAL_GAME.UNAVAILABLE')}</p>}
{searchState === 'failed' && <p className="text-sm text-destructive">{t('MANUAL_GAME.SEARCH_FAILED')}</p>}
{searchState === 'done' && results.length === 0 && (
<p className="text-sm text-muted-foreground">{t('MANUAL_GAME.NO_HITS')}</p>
)}
{searchState === 'done' && results.length > 0 && (
<ul className="divide-y rounded-md border">
{results.map((hit) => (
<li key={hit.igdbId}>
<button type="button" disabled={hit.inLibrary} onClick={() => setPicked(hit)}
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-60">
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-20 shrink-0"/>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{hit.name}{hit.year ? ` (${hit.year})` : ''}
</span>
<span className="block truncate text-xs text-muted-foreground">
{hit.platforms.join(' · ')}
</span>
</span>
{hit.inLibrary && <Badge variant="secondary">{t('MANUAL_GAME.IN_LIBRARY')}</Badge>}
</button>
</li>
))}
</ul>
)}
</>
)}
@@ -126,12 +86,17 @@ export default function AddGamePanel({onClose}) {
);
}
function AddForm({hit, onBack, navigate, expireSession}) {
export function AddForm({hit, favorite, onBack, navigate, onAdded}) {
const {t} = useTranslation();
const [platform, setPlatform] = useState(hit.platforms[0] ?? '');
const [platform, setPlatform] = useState(() => preselectPlatform(null, hit.platforms, favorite));
// The favorite can arrive after the pick; it fills only a platform the user has not picked yet.
useEffect(() => {
setPlatform((current) => current || preselectPlatform(null, hit.platforms, favorite));
}, [favorite, hit.platforms]);
const [status, setStatus] = useState('BACKLOG');
const [hours, setHours] = useState('');
const [busy, setBusy] = useState(false);
const check = usePointsCheck();
const submit = async (e) => {
e.preventDefault();
@@ -140,10 +105,7 @@ function AddForm({hit, onBack, navigate, expireSession}) {
const response = await addGame({
igdbId: hit.igdbId, platform: platform.trim(), status, playtimeMinutes: hoursToMinutes(hours),
});
if (response.status === 401) {
expireSession();
return;
}
if (response.status === 401) return;
if (response.status === 409) {
toast.error(t('MANUAL_GAME.ALREADY_IN_LIBRARY'));
return;
@@ -154,6 +116,8 @@ function AddForm({hit, onBack, navigate, expireSession}) {
}
const {appid} = await response.json();
toast.success(t('MANUAL_GAME.ADDED', {name: hit.name}));
check(appid, {status, tell: true});
onAdded?.(appid);
navigate(`/library/${appid}`);
} catch (err) {
console.error('add game error:', err);
@@ -167,20 +131,24 @@ function AddForm({hit, onBack, navigate, expireSession}) {
<form onSubmit={submit} className="space-y-4">
<div className="flex items-center gap-3">
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-24 shrink-0"/>
<div className="min-w-0 font-medium">{hit.name}{hit.year ? ` (${hit.year})` : ''}</div>
<div className="@container min-w-0 flex-1 font-medium">
<FitText text={hitTitle(hit)}/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="add-platform">{t('MANUAL_GAME.PLATFORM')}</Label>
{hit.platforms.length > 0 ? (
<Select value={platform} onValueChange={setPlatform}>
<SelectTrigger id="add-platform" className="w-full"><SelectValue/></SelectTrigger>
<SelectTrigger id="add-platform" className="w-full">
<SelectValue placeholder={t('IMPORT.PICK_PLATFORM')}/>
</SelectTrigger>
<SelectContent>
{hit.platforms.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
) : (
<Input id="add-platform" value={platform} maxLength={32} onChange={(e) => setPlatform(e.target.value)}/>
<Input id="add-platform" value={platform} maxLength={MAX_PLATFORM_LENGTH} onChange={(e) => setPlatform(e.target.value)}/>
)}
</div>
<div className="space-y-2">
+65 -22
View File
@@ -1,12 +1,13 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import {toast} from 'sonner';
import AddGamePanel from './AddGamePanel.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
vi.mock('sonner', () => ({toast: Object.assign(vi.fn(), {success: vi.fn(), error: vi.fn()})}));
const zelda = {
appid: -1025, igdbId: 1025, name: 'Zelda', year: 2017, imageUrl: 'https://images.igdb.com/z.jpg',
@@ -14,22 +15,24 @@ const zelda = {
};
const portal = {...zelda, appid: 400, igdbId: 71, name: 'Portal', inLibrary: true};
const json = (status, body) => ({ok: status < 400, status, json: async () => body});
function renderPanel(post = json(201, {appid: -1025})) {
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'POST'
? post
: json(200, {available: true, results: [zelda, portal]}));
render(
<MemoryRouter initialEntries={['/library']}>
<Routes>
<Route path="/library" element={<AddGamePanel onClose={() => {}}/>}/>
<Route path="/library/:appid" element={<p>detail page</p>}/>
</Routes>
</MemoryRouter>,
);
function renderPanel(post = json(201, {appid: -1025}), favoritePlatform = 'Nintendo Switch',
found = json(200, {available: true, results: [zelda, portal]})) {
fetchRoutes({
'GET /api/users/me': json(200, {role: 'USER', settings: {favoritePlatform}}),
'GET /api/library/game-search': found,
'POST /api/library/games': post,
'POST /api/library/imports': post,
'GET /api/rewards/games/-1025': json(200, {balance: 15, missing: {}, paid: [
{kind: 'FINISHED_MANUAL', appid: -1025, points: 15, paidAt: '2026-10-09T10:00:00Z', bookedNow: true}]}),
'GET /api/rewards/wallet': json(200, {balance: 15, catalog: [], owned: [], equipped: {}, history: []}),
});
renderWithProviders(<AddGamePanel onClose={() => {}}/>, {path: '/library', entries: ['/library'],
routes: {'/library/:appid': <p>detail page</p>, '/import': <p>import page</p>}});
}
const searchCalls = () => fetch.mock.calls.filter(([url]) => url.includes('game-search'));
const search = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}});
beforeEach(() => {
@@ -48,7 +51,7 @@ describe('AddGamePanel', () => {
search('z');
await new Promise((resolve) => setTimeout(resolve, 600));
expect(fetch).not.toHaveBeenCalled();
expect(searchCalls()).toHaveLength(0);
});
it('searches once after typing stops and blocks games already in the library', async () => {
@@ -57,13 +60,13 @@ describe('AddGamePanel', () => {
search('zel');
const hit = await screen.findByRole('button', {name: /Zelda \(2017\)/});
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch.mock.calls[0][0]).toContain('/api/library/game-search?q=zel');
expect(searchCalls()).toHaveLength(1);
expect(searchCalls()[0][0]).toContain('/api/library/game-search?q=zel');
expect(hit.disabled).toBe(false);
expect(screen.getByRole('button', {name: /Portal/}).disabled).toBe(true);
});
it('adds the picked game with the first platform and the hours, then opens its page', async () => {
it('adds the picked game with the favorite platform and the hours, then opens its page', async () => {
renderPanel();
search('zelda');
fireEvent.click(await screen.findByRole('button', {name: /Zelda/}));
@@ -79,6 +82,47 @@ describe('AddGamePanel', () => {
});
});
it('checks the points after adding a game as finished and shows the toast', async () => {
// jsdom has no scrollIntoView, and the Radix select calls it when it opens.
Element.prototype.scrollIntoView = vi.fn();
renderPanel();
search('zelda');
fireEvent.click(await screen.findByRole('button', {name: /Zelda/}));
fireEvent.keyDown(screen.getByRole('combobox', {name: 'BACKLOG.STATUS_LABEL'}), {key: 'ArrowDown'});
fireEvent.click(await screen.findByRole('option', {name: 'BACKLOG.STATUS.FINISHED'}));
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.ADD'}));
await screen.findByText('detail page');
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('POINTS.GAME.TOAST_BOOKED:15'));
const points = fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/games/-1025'));
expect(points).toHaveLength(1);
expect(JSON.parse(fetch.mock.calls.find(([, call]) => call?.method === 'POST')[1].body).status).toBe('FINISHED');
});
it('preselects no platform without a favorite among several, so Add waits for a pick', async () => {
renderPanel(undefined, null);
search('zelda');
fireEvent.click(await screen.findByRole('button', {name: /Zelda/}));
expect(screen.getByRole('button', {name: 'MANUAL_GAME.ADD'}).disabled).toBe(true);
});
it('sends several checked games to a search import and opens its review', async () => {
renderPanel(json(202, {id: 7}));
search('zelda');
const [zeldaBox, portalBox] = await screen.findAllByRole('checkbox', {name: 'MANUAL_GAME.SELECT'});
expect(portalBox.disabled).toBe(true);
fireEvent.click(zeldaBox);
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.ADD_SELECTED'}));
expect(await screen.findByText('import page')).toBeTruthy();
const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'POST');
expect(url).toContain('/api/library/imports');
expect(JSON.parse(init.body)).toEqual({source: 'SEARCH', rows: [{igdbId: 1025, title: 'Zelda'}]});
});
it('says so when the game is already in the library', async () => {
renderPanel(json(409, {code: 'ALREADY_IN_LIBRARY'}));
search('zelda');
@@ -91,8 +135,7 @@ describe('AddGamePanel', () => {
});
it('shows that adding is off when Core has no IGDB app', async () => {
renderPanel();
fetch.mockImplementation(async () => json(200, {available: false, results: []}));
renderPanel(undefined, undefined, json(200, {available: false, results: []}));
search('zelda');
expect(await screen.findByText('MANUAL_GAME.UNAVAILABLE')).toBeTruthy();
+179 -26
View File
@@ -1,31 +1,44 @@
import {Link, NavLink, useLocation} from 'react-router-dom';
import {useEffect, useState} from 'react';
import {Link, useLocation, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Dices, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Sun} from 'lucide-react';
import {Bell, Coins, Eye, Languages, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun, UserRound} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {usePreferences} from '@/context/PreferencesContext.jsx';
import {cn} from '@/lib/utils';
import Logo from './Logo.jsx';
import {isNavActive, NAV_ITEMS} from './navItems.js';
import PageContainer from './PageContainer.jsx';
import SteamAvatar from './SteamAvatar.jsx';
import useCurrentUser from '../hooks/useCurrentUser.js';
import useNotifications from '../hooks/useNotifications.js';
import useSteamProfile from '../hooks/useSteamProfile.js';
import {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js';
import {canCorrect} from '../utils/corrections.js';
import {profileHref} from '../utils/people.js';
import {expireSession, markSession} from '../utils/auth.js';
const NAV_ITEMS = [
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
// The wishlist is a tab of the library.
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist']},
{to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices},
];
const CountBadge = ({count}) => (
<span aria-hidden="true"
className="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
{count > 9 ? '9+' : count}
</span>
);
const navLinkClass = (isActive) => cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
'flex min-w-0 items-center gap-1.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors lg:gap-2 lg:px-3',
isActive
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
@@ -33,47 +46,187 @@ const navLinkClass = (isActive) => cn(
export default function AppHeader({isAuthenticated, onLogout}) {
const {t, i18n} = useTranslation();
const {isDarkMode, setDarkMode, setLanguage} = usePreferences();
const {isDarkMode, setDarkMode, setLanguage, wallet} = usePreferences();
const {pathname} = useLocation();
const steamProfile = useSteamProfile(isAuthenticated);
const currentUser = useCurrentUser(isAuthenticated);
const navigate = useNavigate();
const isAdmin = currentUser?.role === 'PLATFORM_ADMIN';
const viewingAs = currentUser && currentUser.activeRole !== currentUser.role ? currentUser.activeRole : null;
const seesAdminPage = isAdmin && !viewingAs;
const [pendingCount, setPendingCount] = useState(0);
const [menuOpenings, setMenuOpenings] = useState(0);
const toggleLanguage = () => setLanguage(i18n.language === 'de' ? 'en' : 'de');
// Loaded again on every page change and menu opening, so a decision on the admin page shows soon.
useEffect(() => {
if (!seesAdminPage) return undefined;
let current = true;
fetchPendingCount()
.then(async (response) => {
if (current && response.ok) setPendingCount((await response.json()).count);
})
.catch((err) => console.error('Pending count error:', err));
return () => {
current = false;
};
}, [seesAdminPage, pathname, menuOpenings]);
const shownCount = seesAdminPage ? pendingCount : 0;
// The inbox page lists all of it, so it shows no count.
const {total} = useNotifications(isAuthenticated, pathname);
const shownTotal = pathname === '/inbox' ? 0 : total;
const switchRole = async (role) => {
try {
const response = await viewAs(role === currentUser.role ? null : role);
if (response.ok) {
// New cookies with the other role; the new stamp loads the user and the Steam data again.
markSession();
navigate('/dashboard');
return;
}
if (response.status === 401) {
expireSession();
return;
}
} catch (err) {
console.error('View as error:', err);
}
toast.error(t('NAV.VIEW_AS.ERROR'));
};
// Unsaved, the next login would switch back to Auth's language.
const toggleLanguage = async () => {
try {
const response = await setLanguage(i18n.language === 'de' ? 'en' : 'de');
if (response && !response.ok) toast.error(t('SETTINGS.ERROR_SAVE'));
} catch (err) {
console.error('Language save error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
}
};
const toggleTheme = () => setDarkMode(!isDarkMode);
const ThemeIcon = isDarkMode ? Sun : Moon;
return (
<header className="sticky top-0 z-50 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<PageContainer className="flex h-16 items-center gap-2 px-4 md:px-8">
<Link to={isAuthenticated ? '/dashboard' : '/'} aria-label="ToBePlayed" className="mr-2 flex items-center gap-2 font-bold tracking-tight whitespace-nowrap">
<PageContainer className="flex h-16 items-center gap-1 px-3 sm:gap-2 sm:px-4 md:px-8">
<Link to={isAuthenticated ? '/dashboard' : '/'} aria-label="ToBePlayed" className="flex items-center gap-2 font-bold tracking-tight sm:mr-2 whitespace-nowrap">
<Logo className="h-8 w-auto shrink-0"/>
{/* On a narrow phone the menu and buttons need the room; the logo alone stays. */}
<span className="hidden text-lg min-[380px]:inline">ToBePlayed</span>
<span className="hidden text-lg min-[520px]:inline">ToBePlayed</span>
</Link>
{isAuthenticated && (
<nav className="flex items-center gap-1">
{NAV_ITEMS.map(({to, labelKey, Icon, alsoActiveOn = []}) => (
<NavLink key={to} to={to} title={t(labelKey)}
className={({isActive}) => navLinkClass(isActive || alsoActiveOn.includes(pathname))}>
<Icon className="size-4"/>
<span className="hidden sm:inline">{t(labelKey)}</span>
</NavLink>
))}
<nav aria-label={t('NAV.MENU')} className="hidden min-w-0 items-center gap-0.5 md:flex lg:gap-1">
{NAV_ITEMS.map((item) => {
const active = isNavActive(item, pathname);
return (
<Link key={item.to} to={item.to} title={t(item.labelKey)} aria-current={active ? 'page' : undefined}
className={navLinkClass(active)}>
<item.Icon className="size-4 shrink-0"/>
<span className="sr-only lg:not-sr-only lg:truncate">{t(item.labelKey)}</span>
</Link>
);
})}
</nav>
)}
<div className="ml-auto flex items-center gap-1">
<div className="ml-auto flex min-w-0 items-center gap-1">
{/* The badge hangs on the header's bottom edge at every width: in the row it would push the page sideways. */}
{isAuthenticated && viewingAs && (
<Badge variant="secondary" className="absolute top-full left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none border-border">{t('NAV.VIEW_AS.BADGE', {role: t(`ROLES.${viewingAs}`)})}</Badge>
)}
{isAuthenticated && wallet && (
<Button asChild variant="ghost" size="sm" title={`${t('POINTS.TITLE')}: ${wallet.balance}`} className="shrink-0">
<Link to="/points" aria-label={`${t('POINTS.TITLE')}: ${wallet.balance}`}>
<Coins className="text-primary"/>
<span className="min-w-0 truncate tabular-nums">{wallet.balance}</span>
</Link>
</Button>
)}
{isAuthenticated && (
<Button asChild variant="ghost" size="icon" title={t('NAV.INBOX')} className="relative shrink-0">
<Link to="/inbox" aria-label={shownTotal > 0 ? t('NAV.INBOX_UNREAD', {count: shownTotal}) : t('NAV.INBOX')}>
<Bell/>
{shownTotal > 0 && <CountBadge count={shownTotal}/>}
</Link>
</Button>
)}
{isAuthenticated ? (
<DropdownMenu>
<DropdownMenu onOpenChange={(open) => open && setMenuOpenings((count) => count + 1)}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('NAV.MENU')} title={steamProfile?.personaName ?? undefined}>
<Button variant="ghost" size="icon" aria-label={t('NAV.MENU')} title={steamProfile?.personaName ?? undefined}
className="relative">
<SteamAvatar url={steamProfile?.avatarUrl}/>
{shownCount > 0 && (
<span aria-label={t('NAV.PENDING_REQUESTS', {count: shownCount})}
className="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full bg-amber-500 text-[10px] font-bold text-black ring-2 ring-background">
{shownCount > 9 ? '9+' : shownCount}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuContent align="end" className="w-56">
{currentUser?.displayName && (
<DropdownMenuItem asChild>
<Link to={profileHref(currentUser.displayName)}><UserRound/>{t('NAV.MY_PROFILE')}</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/points"><Coins/>{t('NAV.POINTS')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/whats-new"><Newspaper/>{t('NAV.WHATS_NEW')}</Link>
</DropdownMenuItem>
{canCorrect(currentUser) && (
<>
<DropdownMenuItem asChild>
<Link to="/audit/games"><Search/>{t('NAV.GAME_CATALOG')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/audit/links"><Link2/>{t('NAV.LINK_QUEUE')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/audit/log"><ScrollText/>{t('NAV.AUDIT_LOG')}</Link>
</DropdownMenuItem>
</>
)}
{seesAdminPage && (
<>
<DropdownMenuItem asChild>
<Link to="/admin/users">
<Shield/>{t('NAV.ADMIN')}
{shownCount > 0 && <Badge variant="outline" className="ml-auto border-amber-500 text-amber-600 dark:text-amber-400">{shownCount}</Badge>}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/admin/hints"><Megaphone/>{t('NAV.ADMIN_HINTS')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/admin/news"><Newspaper/>{t('NAV.ADMIN_NEWS')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/admin/broadcasts"><Mail/>{t('NAV.ADMIN_BROADCASTS')}</Link>
</DropdownMenuItem>
</>
)}
{isAdmin && (
<DropdownMenuSub>
<DropdownMenuSubTrigger><Eye/>{t('NAV.VIEW_AS.MENU')}</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{ROLES.map((role) => (
<DropdownMenuItem key={role} disabled={role === currentUser.activeRole}
onSelect={() => switchRole(role)}>
{t(`ROLES.${role}`)}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuItem onSelect={toggleLanguage}>
<Languages/>{t('NAV.LANGUAGE')}: {i18n.language.toUpperCase()}
</DropdownMenuItem>
+62
View File
@@ -0,0 +1,62 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, screen} from '@testing-library/react';
import AppHeader from './AppHeader.jsx';
import {usePreferences} from '../context/PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
afterEach(() => {
cleanup();
localStorage.clear();
});
function Load() {
const {reloadWallet} = usePreferences();
return <button onClick={reloadWallet}>load</button>;
}
async function loadChip() {
localStorage.setItem('tbp_session', '1');
fetchRoutes({
'GET /api/users/me': json(200, {role: 'USER', activeRole: 'USER'}),
'GET /api/library/steam-profile': json(200, {}),
'GET /api/inbox': json(200, {results: [], unread: 0}),
'GET /api/news': json(200, {results: []}),
'GET /api/library/questions/count': json(200, {count: 0}),
'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}),
});
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
fireEvent.click(screen.getByText('load'));
return screen.findByTitle('POINTS.TITLE: 135');
}
describe('AppHeader points chip', () => {
it('showsTheChipAtEveryWidth', async () => {
const chip = await loadChip();
expect(chip.className).not.toContain('max-sm:hidden');
});
it('shows the balance as a link to the points page once the wallet is loaded', async () => {
localStorage.setItem('tbp_session', '1');
fetchRoutes({
'GET /api/users/me': json(200, {role: 'USER', activeRole: 'USER'}),
'GET /api/library/steam-profile': json(200, {}),
'GET /api/inbox': json(200, {results: [], unread: 0}),
'GET /api/news': json(200, {results: []}),
'GET /api/library/questions/count': json(200, {count: 0}),
'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}),
});
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
expect(screen.queryByTitle('POINTS.TITLE')).toBeNull();
fireEvent.click(screen.getByText('load'));
const chip = await screen.findByTitle('POINTS.TITLE: 135');
expect(chip.textContent).toBe('135');
expect(chip.getAttribute('href')).toBe('/points');
});
});
+196 -10
View File
@@ -1,24 +1,210 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {cleanup, fireEvent, screen, within} from '@testing-library/react';
import {toast} from 'sonner';
import {useTranslation} from 'react-i18next';
import AppHeader from './AppHeader.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
afterEach(cleanup);
afterEach(() => {
cleanup();
localStorage.clear();
useTranslation().i18n.language = 'de';
});
const loggedInAs = (me, routes = {}, options = {}) => {
localStorage.setItem('tbp_session', '1');
fetchRoutes({'GET /api/users/me': json(200, me), 'GET /api/library/steam-profile': json(200, {}),
'GET /api/inbox': json(200, {results: [], unread: 0}),
'GET /api/news': json(200, {results: []}),
'GET /api/library/questions/count': json(200, {count: 0}),
...routes});
renderWithProviders(<AppHeader isAuthenticated onLogout={() => {}}/>, options);
};
const openMenu = () => fireEvent.keyDown(screen.getByRole('button', {name: 'NAV.MENU'}), {key: 'Enter'});
describe('AppHeader', () => {
// jsdom applies no media queries, so the test hides the name the way the 380px breakpoint does.
it('says so when Auth refuses the language switch', async () => {
loggedInAs({role: 'USER', activeRole: 'USER', settings: {language: 'de'}},
{'PUT /api/users/settings': json(500, {})});
await screen.findByRole('button', {name: 'NAV.MENU'});
openMenu();
fireEvent.click(await screen.findByRole('menuitem', {name: /NAV.LANGUAGE/}));
await vi.waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.ERROR_SAVE'));
});
// jsdom applies no media queries, so the test hides the name the way the 520px breakpoint does.
it('keeps a name on the home link when a narrow phone hides the app name', () => {
render(
<MemoryRouter>
<PreferencesProvider><AppHeader isAuthenticated={false} onLogout={() => {}}/></PreferencesProvider>
</MemoryRouter>,
);
fetchRoutes({});
renderWithProviders(<AppHeader isAuthenticated={false} onLogout={() => {}}/>);
screen.getByText('ToBePlayed').style.display = 'none';
expect(screen.getByRole('link', {name: 'ToBePlayed'})).toBeTruthy();
expect(screen.queryByRole('navigation', {name: 'NAV.MENU'})).toBeNull();
});
it('showsTheFiveMainItemsWithTheirLabelsAtEveryWidth', () => {
loggedInAs({role: 'USER', activeRole: 'USER'});
const nav = screen.getByRole('navigation', {name: 'NAV.MENU'});
expect(within(nav).getAllByRole('link').map((link) => link.getAttribute('href')))
.toEqual(['/dashboard', '/library', '/play-next', '/stats', '/community']);
for (const key of ['DASHBOARD', 'LIBRARY', 'PLAY_NEXT', 'STATS', 'COMMUNITY']) {
expect(within(nav).getByText(`NAV.${key}`).className).not.toContain('hidden');
}
expect(nav.className).toContain('md:flex');
});
it('hasNoQuestionsFollowingOrSearchItemInTheNav', () => {
loggedInAs({role: 'USER', activeRole: 'USER'});
const nav = screen.getByRole('navigation', {name: 'NAV.MENU'});
for (const key of ['QUESTIONS', 'FOLLOWING', 'SEARCH']) expect(within(nav).queryByText(`NAV.${key}`)).toBeNull();
});
it('marksTheLibraryActiveOnItsTabsAndSearch', () => {
loggedInAs({role: 'USER', activeRole: 'USER'}, {}, {entries: ['/search']});
const nav = screen.getByRole('navigation', {name: 'NAV.MENU'});
expect(within(nav).getByRole('link', {name: 'NAV.LIBRARY'}).getAttribute('aria-current')).toBe('page');
expect(within(nav).getByRole('link', {name: 'NAV.DASHBOARD'}).getAttribute('aria-current')).toBeNull();
});
it('offersMyPublicProfileFirstInTheMenuWhenTheUserHasAName', async () => {
loggedInAs({role: 'USER', activeRole: 'USER', displayName: 'Lo bo'});
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
await screen.findByRole('button', {name: 'NAV.MENU'});
await vi.waitFor(() => {
openMenu();
expect(screen.getAllByRole('menuitem')[0].getAttribute('href')).toBe('/u/Lo%20bo');
});
expect(screen.getAllByRole('menuitem')[0].textContent).toBe('NAV.MY_PROFILE');
expect(screen.queryByRole('menuitem', {name: 'NAV.INBOX'})).toBeNull();
});
it('offersNoProfileItemWithoutAName', async () => {
loggedInAs({role: 'USER', activeRole: 'USER'});
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
openMenu();
await screen.findByText('NAV.SETTINGS');
expect(screen.queryByText('NAV.MY_PROFILE')).toBeNull();
});
it('shows how many inbox notes are unread', async () => {
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/inbox': json(200, {results: [], unread: 3})});
const link = await screen.findByRole('link', {name: 'NAV.INBOX_UNREAD:3'});
expect(link.getAttribute('href')).toBe('/inbox');
expect(link.textContent).toBe('3');
});
it('theBellCountsUnreadNotesUnseenNewsAndOpenQuestions', async () => {
loggedInAs({role: 'USER', activeRole: 'USER'}, {
'GET /api/inbox': json(200, {results: [], unread: 2}),
'GET /api/news': json(200, {results: [{id: 1, seen: false}, {id: 2, seen: true}]}),
'GET /api/library/questions/count': json(200, {count: 4}),
});
expect((await screen.findByRole('link', {name: 'NAV.INBOX_UNREAD:7'})).textContent).toBe('7');
});
it('theInboxPageShowsNoBellCount', async () => {
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 4})},
{entries: ['/inbox']});
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/library/questions/count'), expect.anything()));
expect(screen.getByRole('link', {name: 'NAV.INBOX'}).textContent).toBe('');
});
it('shows no count without unread notes', async () => {
loggedInAs({role: 'USER', activeRole: 'USER'});
const link = await screen.findByRole('link', {name: 'NAV.INBOX'});
expect(link.textContent).toBe('');
});
it('offers the admin page and "view as" to an admin', async () => {
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'},
{'GET /api/admin/users/pending-count': json(200, {count: 0})});
await screen.findByRole('button', {name: 'NAV.MENU'});
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
openMenu();
expect(await screen.findByText('NAV.ADMIN')).toBeTruthy();
expect(screen.getByText('NAV.ADMIN_NEWS')).toBeTruthy();
expect(screen.getByText('NAV.ADMIN_BROADCASTS')).toBeTruthy();
expect(screen.getByText('NAV.VIEW_AS.MENU')).toBeTruthy();
expect(screen.queryByText('NAV.VIEW_AS.BADGE')).toBeNull();
expect(screen.queryByLabelText('NAV.PENDING_REQUESTS:0')).toBeNull();
});
it('shows the admin how many registrations wait', async () => {
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'},
{'GET /api/admin/users/pending-count': json(200, {count: 3})});
const badge = await screen.findByLabelText('NAV.PENDING_REQUESTS:3');
expect(badge.className).toContain('bg-amber-500');
openMenu();
expect((await screen.findByRole('menuitem', {name: /^NAV\.ADMIN ?3$/})).textContent).toContain('3');
});
it('shows no count when Auth does not know the call yet', async () => {
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'},
{'GET /api/admin/users/pending-count': json(404, {})});
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('pending-count'), expect.anything()));
expect(screen.queryByLabelText(/NAV.PENDING_REQUESTS/)).toBeNull();
});
it('shows the badge and hides the admin page while an admin views as a user', async () => {
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'USER'});
expect(await screen.findByText('NAV.VIEW_AS.BADGE')).toBeTruthy();
openMenu();
expect(await screen.findByText('NAV.VIEW_AS.MENU')).toBeTruthy();
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
});
it('offersTheCatalogLinkQueueAndAuditLogToAnAuditorOnly', async () => {
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
openMenu();
expect(await screen.findByText('NAV.GAME_CATALOG')).toBeTruthy();
expect(screen.getByText('NAV.LINK_QUEUE')).toBeTruthy();
expect(screen.getByText('NAV.AUDIT_LOG')).toBeTruthy();
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
});
it('offers nothing of that to a user', async () => {
loggedInAs({role: 'USER', activeRole: 'USER'});
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
openMenu();
expect(await screen.findByText('NAV.SETTINGS')).toBeTruthy();
expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy();
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
expect(screen.queryByText('NAV.LINK_QUEUE')).toBeNull();
expect(screen.queryByText('NAV.GAME_CATALOG')).toBeNull();
expect(screen.queryByText('NAV.ADMIN_NEWS')).toBeNull();
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull();
});
});
+133
View File
@@ -0,0 +1,133 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {ImageOff, Palette, RotateCcw} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Label} from '@/components/ui/label';
import {Switch} from '@/components/ui/switch';
import {AccentPicker, CornersPicker} from './StylePickers.jsx';
import {equippedSkin} from '../utils/rewards.js';
import {usePreferences} from '../context/PreferencesContext.jsx';
import usePersistSettings from '../hooks/usePersistSettings.js';
import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {coverUrl} from '../utils/games.js';
import {disableTilt, requestTilt, tiltAllowed, tiltNeedsPermission} from '../utils/parallax.js';
import {saveSettings} from '../utils/settings.js';
export default function AppearanceCard() {
const {t} = useTranslation();
const {appearance, equipped, isDarkMode, setDarkMode} = usePreferences();
const persistSettings = usePersistSettings();
const saveAppearance = useSaveAppearance();
const [tilt, setTilt] = useState(tiltAllowed);
const [tiltRefused, setTiltRefused] = useState(false);
const changeDarkMode = async (next) => {
setDarkMode(next);
const {ok} = await persistSettings(() => saveSettings({useDarkTheme: next}), {silent: true});
if (!ok) setDarkMode(!next);
};
// requestTilt() starts inside the tap, as iOS demands.
const changeTilt = (on) => {
if (!on) {
disableTilt();
setTilt(false);
return;
}
requestTilt().then((granted) => {
setTilt(granted);
setTiltRefused(!granted);
});
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Palette className="size-4 text-primary"/>{t('SETTINGS.APPEARANCE')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between gap-4">
<Label htmlFor="useDarkTheme">{t('SETTINGS.DARK_THEME')}</Label>
<Switch id="useDarkTheme" checked={isDarkMode} onCheckedChange={changeDarkMode}/>
</div>
<AccentPicker id="accent" value={appearance.accent} onChange={(accent) => saveAppearance({accent})}/>
<CornersPicker id="corners" value={appearance.corners} onChange={(corners) => saveAppearance({corners})}/>
{equippedSkin(equipped) && <p className="text-xs text-muted-foreground">{t('SETTINGS.SKIN_OVERRIDES')}</p>}
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="wideLayout">{t('SETTINGS.WIDE_LAYOUT')}</Label>
<p className="text-xs text-muted-foreground">{t('SETTINGS.WIDE_LAYOUT_HINT')}</p>
</div>
<Switch id="wideLayout" checked={appearance.wideLayout}
onCheckedChange={(wideLayout) => saveAppearance({wideLayout})}/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="reduceMotion">{t('SETTINGS.REDUCE_MOTION')}</Label>
<p className="text-xs text-muted-foreground">{t('SETTINGS.REDUCE_MOTION_HINT')}</p>
</div>
<Switch id="reduceMotion" checked={appearance.reduceMotion}
onCheckedChange={(reduceMotion) => saveAppearance({reduceMotion})}/>
</div>
{tiltNeedsPermission() && (
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="tilt">{t('SETTINGS.TILT')}</Label>
<p className="text-xs text-muted-foreground">
{t(tiltRefused ? 'SETTINGS.TILT_DENIED' : 'SETTINGS.TILT_HINT')}
</p>
</div>
<Switch id="tilt" checked={tilt} disabled={appearance.reduceMotion}
onCheckedChange={changeTilt}/>
</div>
)}
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label id="background-label">{t('SETTINGS.BACKGROUND')}</Label>
{!appearance.backgroundAppid && (
<p className="text-xs text-muted-foreground">{t('SETTINGS.BACKGROUND_HINT')}</p>
)}
</div>
{appearance.backgroundAppid && (
<div className="flex items-center gap-2">
<img src={coverUrl(appearance.backgroundAppid)} alt=""
onError={(event) => { event.currentTarget.hidden = true; }}
className="h-10 w-20 rounded-md object-cover"/>
<Button type="button" variant="outline" size="sm"
aria-describedby="background-label"
onClick={() => saveAppearance({backgroundAppid: null})}>
<ImageOff/>{t('SETTINGS.BACKGROUND_REMOVE')}
</Button>
</div>
)}
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label id="banner-label" htmlFor="showBanner">{t('SETTINGS.BANNER')}</Label>
{appearance.showBanner && !appearance.bannerAppid && (
<p className="text-xs text-muted-foreground">{t('SETTINGS.BANNER_HINT')}</p>
)}
</div>
<Switch id="showBanner" checked={appearance.showBanner}
onCheckedChange={(showBanner) => saveAppearance({showBanner})}/>
</div>
{appearance.showBanner && appearance.bannerAppid && (
<div className="flex items-center justify-end gap-2">
<img src={coverUrl(appearance.bannerAppid)} alt=""
onError={(event) => { event.currentTarget.hidden = true; }}
className="h-10 w-20 rounded-md object-cover"/>
<Button type="button" variant="outline" size="sm"
aria-describedby="banner-label"
onClick={() => saveAppearance({bannerAppid: null})}>
<RotateCcw/>{t('SETTINGS.BANNER_AUTO')}
</Button>
</div>
)}
</div>
</CardContent>
</Card>
);
}
+75
View File
@@ -0,0 +1,75 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import AppearanceCard from './AppearanceCard.jsx';
const preferences = vi.hoisted(() => ({value: null}));
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('../context/PreferencesContext.jsx', () => ({usePreferences: () => preferences.value}));
vi.mock('../hooks/useSaveAppearance.js', () => ({default: () => vi.fn()}));
const show = (equipped, reduceMotion = false) => {
preferences.value = {
appearance: {accent: 'violet', corners: 'default', wideLayout: false, reduceMotion, showBanner: true},
equipped,
};
render(<AppearanceCard/>);
};
afterEach(() => {
cleanup();
localStorage.clear();
delete window.DeviceOrientationEvent;
});
describe('AppearanceCard', () => {
it('tells that the skin sets colors and corners only while a skin is worn', () => {
show({SKIN: 'SKIN_STEAM'});
expect(screen.getByText('SETTINGS.SKIN_OVERRIDES')).toBeTruthy();
});
it('shows no skin hint without a skin', () => {
show({});
expect(screen.queryByText('SETTINGS.SKIN_OVERRIDES')).toBeNull();
});
describe('tilt effects', () => {
const stubPermission = (answer) => {
window.DeviceOrientationEvent = {requestPermission: vi.fn().mockResolvedValue(answer)};
return window.DeviceOrientationEvent.requestPermission;
};
it('has no tilt switch where the browser needs no permission', () => {
show({});
expect(document.querySelector('#tilt')).toBeNull();
});
it('asks for permission on the tap, then shows the switch on and stores it', async () => {
const request = stubPermission('granted');
show({});
fireEvent.click(document.querySelector('#tilt'));
expect(request).toHaveBeenCalledTimes(1);
await waitFor(() => expect(document.querySelector('#tilt').getAttribute('aria-checked')).toBe('true'));
expect(localStorage.getItem('tbp_tilt')).toBe('true');
});
it('stays off and tells why when the permission is refused', async () => {
stubPermission('denied');
show({});
fireEvent.click(document.querySelector('#tilt'));
await waitFor(() => expect(screen.getByText('SETTINGS.TILT_DENIED')).toBeTruthy());
expect(document.querySelector('#tilt').getAttribute('aria-checked')).toBe('false');
});
it('is disabled while animations are reduced', () => {
stubPermission('granted');
show({}, true);
expect(document.querySelector('#tilt').disabled).toBe(true);
});
});
});
+28 -109
View File
@@ -1,75 +1,31 @@
import {useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {ListTodo, Loader2, Star} from 'lucide-react';
import {ListTodo, Loader2} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Label} from '@/components/ui/label';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import {Textarea} from '@/components/ui/textarea';
import {cn} from '@/lib/utils';
import {STATUS_META} from './GameStatusBadge.jsx';
import {GAME_PRIORITIES, GAME_STATUSES, NO_PRIORITY, saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js';
import useSessionExpired from '../hooks/useSessionExpired.js';
import {hasSession} from '../utils/auth.js';
const NOTES_MAX = 2000;
const toForm = (entry) => ({
status: entry.status ?? 'BACKLOG',
rating: entry.rating ?? 0,
notes: entry.notes ?? '',
priority: entry.priority ?? NO_PRIORITY,
});
import {changedFields, NO_PRIORITY} from '../utils/backlog.js';
import {GAME_PRIORITIES, MAX_NOTES_LENGTH} from '../utils/limits.js';
import useSaveBacklogField from '../hooks/useSaveBacklogField.js';
import useUnsavedChanges from '../hooks/useUnsavedChanges.js';
/**
* Priority (saved on change) and notes (saved with the Save button). Status and rating live in the hero card.
*
* @param onSaved gets the saved backlog entry
*/
export default function BacklogCard({game, onSaved}) {
const {t} = useTranslation();
const navigate = useNavigate();
const expireSession = useSessionExpired();
const [saved, setSaved] = useState(() => toForm(game));
const [form, setForm] = useState(saved);
const [notes, setNotes] = useState(game.notes ?? '');
const {save, pending, busy} = useSaveBacklogField(game.appid, {onSaved});
// The PlayingHint can change the status from outside; keep the other unsaved edits.
useEffect(() => {
setSaved((current) => (current.status === game.status ? current : {...current, status: game.status}));
setForm((current) => (current.status === game.status ? current : {...current, status: game.status}));
}, [game.status]);
const [busy, setBusy] = useState(false);
const dirty = JSON.stringify(form) !== JSON.stringify(saved);
const set = (field) => (value) => setForm((f) => ({...f, [field]: value}));
const save = async () => {
if (!hasSession()) {
navigate('/login');
return;
}
setBusy(true);
try {
const response = await saveBacklogEntry(game.appid, toBacklogRequest(form));
if (response.status === 401) {
expireSession();
return;
}
if (!response.ok) {
toast.error(t('BACKLOG.SAVE_FAILED'));
return;
}
const entry = await response.json();
const next = toForm(entry);
setSaved(next);
onSaved?.(entry);
setForm(next);
toast.success(t('BACKLOG.SAVED'));
} catch (err) {
console.error('backlog save error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
const savingNotes = pending?.field === 'notes';
const dirty = 'notes' in changedFields({notes: game.notes}, {notes});
useUnsavedChanges(dirty);
const priority = pending?.field === 'priority' ? pending.value : (game.priority ?? NO_PRIORITY);
return (
<Card className="p-6 gap-5">
@@ -78,28 +34,9 @@ export default function BacklogCard({game, onSaved}) {
{t('BACKLOG.TITLE')}
</h2>
<div className="grid gap-5 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="backlog-status">{t('BACKLOG.STATUS_LABEL')}</Label>
<Select value={form.status} onValueChange={set('status')}>
<SelectTrigger id="backlog-status" className="w-full"><SelectValue/></SelectTrigger>
<SelectContent>
{GAME_STATUSES.map((s) => {
const {icon: Icon, text} = STATUS_META[s];
return (
<SelectItem key={s} value={s}>
<Icon className={text}/>
{t(`BACKLOG.STATUS.${s}`)}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="space-y-2 sm:max-w-xs">
<Label htmlFor="backlog-priority">{t('BACKLOG.PRIORITY_LABEL')}</Label>
<Select value={form.priority} onValueChange={set('priority')}>
<Select value={priority} onValueChange={(value) => save('priority', value)} disabled={busy}>
<SelectTrigger id="backlog-priority" className="w-full"><SelectValue/></SelectTrigger>
<SelectContent>
<SelectItem value={NO_PRIORITY}>{t('BACKLOG.PRIORITY.NONE')}</SelectItem>
@@ -110,47 +47,29 @@ export default function BacklogCard({game, onSaved}) {
</Select>
</div>
<div className="space-y-2">
<Label id="backlog-rating">{t('BACKLOG.RATING_LABEL')}</Label>
<div role="group" aria-labelledby="backlog-rating" className="flex h-9 items-center gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
aria-label={t('BACKLOG.RATING_STARS', {count: n})}
aria-pressed={form.rating === n}
onClick={() => set('rating')(form.rating === n ? 0 : n)}
className="rounded-sm p-0.5 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<Star className={cn('size-6 transition-colors',
n <= form.rating ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/40 hover:text-amber-400')}/>
</button>
))}
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-baseline justify-between">
<Label htmlFor="backlog-notes">{t('BACKLOG.NOTES_LABEL')}</Label>
<span className="text-xs text-muted-foreground tabular-nums">{form.notes.length} / {NOTES_MAX}</span>
<span className="text-xs text-muted-foreground tabular-nums">{notes.length} / {MAX_NOTES_LENGTH}</span>
</div>
{/* Locked while saving: an edit made meanwhile would be lost. */}
<Textarea
id="backlog-notes"
rows={3}
maxLength={NOTES_MAX}
maxLength={MAX_NOTES_LENGTH}
placeholder={t('BACKLOG.NOTES_PLACEHOLDER')}
value={form.notes}
onChange={(e) => set('notes')(e.target.value)}
value={notes}
disabled={savingNotes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setForm(saved)} disabled={!dirty || busy}>
<Button variant="ghost" onClick={() => setNotes(game.notes ?? '')} disabled={!dirty || busy}>
{t('BACKLOG.RESET')}
</Button>
<Button onClick={save} disabled={!dirty || busy}>
{busy && <Loader2 className="animate-spin"/>}
<Button onClick={() => save('notes', notes)} disabled={!dirty || busy}>
{savingNotes && <Loader2 className="animate-spin"/>}
{t('BACKLOG.SAVE')}
</Button>
</div>
+100 -10
View File
@@ -1,7 +1,9 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import BacklogCard from './BacklogCard.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
@@ -9,40 +11,128 @@ vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const game = {appid: 220, status: 'PLAYING', rating: 4, notes: 'old', priority: null};
const saveButton = () => screen.getByRole('button', {name: 'BACKLOG.SAVE'});
const PATCH = 'PATCH /api/library/games/220/backlog';
const openPriority = () => fireEvent.keyDown(screen.getByRole('combobox'), {key: 'Enter'});
const patchBodies = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PATCH').map(([, init]) => JSON.parse(init.body));
describe('BacklogCard', () => {
beforeEach(() => {
// jsdom lacks both; the Radix select needs them when it opens.
Element.prototype.hasPointerCapture ??= () => false;
Element.prototype.scrollIntoView ??= () => {};
localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: 200, json: async () => JSON.parse(init.body)}));
// Core answers the whole entry; the fields not sent come from the server.
fetchRoutes({[PATCH]: (url, init) => json(200, {...game, ...JSON.parse(init.body)})});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
localStorage.clear();
});
it('enables saving only after a change', () => {
it('showsNeitherStatusNorRating', () => {
render(<MemoryRouter><BacklogCard game={game}/></MemoryRouter>);
expect(screen.queryByText('BACKLOG.STATUS_LABEL')).toBeNull();
expect(screen.queryByText('BACKLOG.RATING_LABEL')).toBeNull();
expect(screen.queryByRole('button', {name: /BACKLOG.RATING_STARS/})).toBeNull();
});
it('savesAPriorityAtOnceAndOnlyThatField', async () => {
const onSaved = vi.fn();
render(<MemoryRouter><BacklogCard game={game} onSaved={onSaved}/></MemoryRouter>);
openPriority();
fireEvent.click(await screen.findByRole('option', {name: 'BACKLOG.PRIORITY.HIGH'}));
await waitFor(() => expect(onSaved).toHaveBeenCalledWith({...game, priority: 'HIGH'}));
expect(patchBodies()).toEqual([{priority: 'HIGH'}]);
});
it('clearsThePriorityWithNull', async () => {
const onSaved = vi.fn();
render(<MemoryRouter><BacklogCard game={{...game, priority: 'LOW'}} onSaved={onSaved}/></MemoryRouter>);
openPriority();
fireEvent.click(await screen.findByRole('option', {name: 'BACKLOG.PRIORITY.NONE'}));
await waitFor(() => expect(onSaved).toHaveBeenCalled());
expect(patchBodies()).toEqual([{priority: null}]);
});
it('showsTheSavedPriorityAgainWhenSavingFails', async () => {
fetchRoutes({[PATCH]: json(500, {})});
render(<MemoryRouter><BacklogCard game={{...game, priority: 'LOW'}}/></MemoryRouter>);
openPriority();
fireEvent.click(await screen.findByRole('option', {name: 'BACKLOG.PRIORITY.HIGH'}));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED'));
expect(screen.getByRole('combobox').textContent).toContain('BACKLOG.PRIORITY.LOW');
});
it('enablesSavingOnlyAfterANotesChange', () => {
render(<MemoryRouter><BacklogCard game={game}/></MemoryRouter>);
expect(saveButton().disabled).toBe(true);
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:5'}));
fireEvent.change(screen.getByRole('textbox'), {target: {value: 'new notes'}});
expect(saveButton().disabled).toBe(false);
});
it('clears the rating on a click on the current star and sends blank notes as null', async () => {
it('sendsBlankNotesAsNull', async () => {
const onSaved = vi.fn();
render(<MemoryRouter><BacklogCard game={game} onSaved={onSaved}/></MemoryRouter>);
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:4'}));
fireEvent.change(screen.getByRole('textbox'), {target: {value: ' '}});
fireEvent.click(saveButton());
await waitFor(() => expect(onSaved).toHaveBeenCalled());
const [url, init] = fetch.mock.calls[0];
expect(url).toMatch(/\/api\/library\/games\/220\/backlog$/);
expect(init.method).toBe('PUT');
expect(JSON.parse(init.body)).toEqual({status: 'PLAYING', rating: null, notes: null, priority: null});
expect(patchBodies()).toEqual([{notes: null}]);
});
it('sendsOnlyTheChangedNotes', async () => {
const onSaved = vi.fn();
render(<MemoryRouter><BacklogCard game={game} onSaved={onSaved}/></MemoryRouter>);
fireEvent.change(screen.getByRole('textbox'), {target: {value: 'new notes'}});
fireEvent.click(saveButton());
await waitFor(() => expect(onSaved).toHaveBeenCalled());
expect(patchBodies()).toEqual([{notes: 'new notes'}]);
});
it('locksTheNotesWhileTheSaveRuns', async () => {
let answer;
fetchRoutes({[PATCH]: (url, init) => new Promise((resolve) => {
answer = () => resolve(json(200, {...game, ...JSON.parse(init.body)}));
})});
render(<MemoryRouter><BacklogCard game={game}/></MemoryRouter>);
fireEvent.change(screen.getByRole('textbox'), {target: {value: 'new notes'}});
fireEvent.click(saveButton());
expect(screen.getByRole('textbox').matches(':disabled')).toBe(true);
await act(async () => answer());
expect(screen.getByRole('textbox').matches(':disabled')).toBe(false);
});
it('doesNotCountBlankNotesInsteadOfNoneAsAChange', () => {
render(<MemoryRouter><BacklogCard game={{...game, notes: null}}/></MemoryRouter>);
fireEvent.change(screen.getByRole('textbox'), {target: {value: ' '}});
expect(saveButton().disabled).toBe(true);
});
it('resetBringsBackTheSavedNotes', () => {
render(<MemoryRouter><BacklogCard game={game}/></MemoryRouter>);
fireEvent.change(screen.getByRole('textbox'), {target: {value: 'draft'}});
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RESET'}));
expect(screen.getByRole('textbox').value).toBe('old');
expect(saveButton().disabled).toBe(true);
});
});
+29
View File
@@ -0,0 +1,29 @@
import {Link, useLocation} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {cn} from '@/lib/utils';
import {isNavActive, NAV_ITEMS} from './navItems.js';
/**
* The main navigation on a phone: a bar at the bottom edge, every item with its label.
*/
export default function BottomNav() {
const {t} = useTranslation();
const {pathname} = useLocation();
return (
<nav aria-label={t('NAV.MENU')}
className="fixed inset-x-0 bottom-0 z-50 flex border-t bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden">
{NAV_ITEMS.map((item) => {
const active = isNavActive(item, pathname);
return (
<Link key={item.to} to={item.to} aria-current={active ? 'page' : undefined}
className={cn('flex flex-1 flex-col items-center gap-0.5 py-2 text-[11px] font-medium',
active ? 'text-primary' : 'text-muted-foreground')}>
<item.Icon className="size-5"/>
<span className="max-w-full truncate px-1">{t(item.labelKey)}</span>
</Link>
);
})}
</nav>
);
}
+37
View File
@@ -0,0 +1,37 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, screen} from '@testing-library/react';
import BottomNav from './BottomNav.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
afterEach(cleanup);
const renderNav = (path) => {
fetchRoutes({'GET /api/users/me': json(200, {settings: {}})});
renderWithProviders(<BottomNav/>, {entries: [path]});
};
describe('BottomNav', () => {
it('hasOneLabeledLinkPerMainItemAndIsHiddenFromMd', () => {
renderNav('/dashboard');
const links = screen.getAllByRole('link');
expect(links.map((link) => link.getAttribute('href')))
.toEqual(['/dashboard', '/library', '/play-next', '/stats', '/community']);
expect(links.map((link) => link.textContent))
.toEqual(['NAV.DASHBOARD', 'NAV.LIBRARY', 'NAV.PLAY_NEXT', 'NAV.STATS', 'NAV.COMMUNITY']);
expect(screen.getByRole('navigation').className).toContain('md:hidden');
});
it('theActiveLinkHasAriaCurrent', () => {
renderNav('/wishlist');
const current = screen.getAllByRole('link').filter((link) => link.getAttribute('aria-current') === 'page');
expect(current.map((link) => link.getAttribute('href'))).toEqual(['/library']);
});
});
+63
View File
@@ -0,0 +1,63 @@
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Info} from 'lucide-react';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {commitUrl, fetchVersion, formatBuildTime, FRONTEND_VERSION, VERSION_SOURCES} from '../utils/version.js';
const SHORT_HASH = 7;
function VersionValue({repo, version}) {
const {t, i18n} = useTranslation();
if (version === undefined) return <span className="text-muted-foreground">…</span>;
if (version === null) return <span className="text-muted-foreground">{t('SETTINGS.VERSION_UNKNOWN')}</span>;
const url = commitUrl(repo, version.commit);
const builtAt = formatBuildTime(version.builtAt, i18n.language);
return (
<span className="flex min-w-0 items-center gap-2">
{url
? <a href={url} target="_blank" rel="noopener noreferrer"
className="font-mono text-primary hover:underline">{version.commit.slice(0, SHORT_HASH)}</a>
: <span className="font-mono">{version.commit}</span>}
{builtAt && <span className="truncate text-muted-foreground">· {builtAt}</span>}
</span>
);
}
/**
* A backend version is undefined while loading and null when it is unknown.
*/
export default function BuildVersionCard() {
const {t} = useTranslation();
const [versions, setVersions] = useState({Frontend: FRONTEND_VERSION});
useEffect(() => {
let cancelled = false;
VERSION_SOURCES.filter((source) => source.url).forEach(async (source) => {
const version = await fetchVersion(source.url());
if (!cancelled) setVersions((current) => ({...current, [source.name]: version}));
});
return () => {
cancelled = true;
};
}, []);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Info className="size-4 text-primary"/>{t('SETTINGS.VERSION')}</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{VERSION_SOURCES.map(({name, repo}) => (
<li key={name} className="flex items-center justify-between gap-4">
<span>{name}</span>
<VersionValue repo={repo} version={versions[name]}/>
</li>
))}
</ul>
</CardContent>
</Card>
);
}
+49
View File
@@ -0,0 +1,49 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen, within} from '@testing-library/react';
import BuildVersionCard from './BuildVersionCard.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
// vite.config.js reads GIT_SHA from the shell; the test must not depend on it.
vi.mock('../utils/version.js', async (importOriginal) => ({
...await importOriginal(),
FRONTEND_VERSION: {commit: 'dev', builtAt: null},
}));
const AUTH_SHA = '4ec47f1aa0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5';
const row = (name) => screen.getByText(name).closest('li');
describe('BuildVersionCard', () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it('links the short hash of each backend and marks a service that does not answer', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => (url.startsWith('http://auth')
? {ok: true, json: async () => ({commit: AUTH_SHA, builtAt: '2026-09-27T08:05:00Z'})}
: {ok: false, status: 502}));
vi.stubEnv('VITE_API_URL', 'http://auth');
vi.stubEnv('VITE_CORE_API_URL', 'http://core');
render(<BuildVersionCard/>);
const link = await within(row('Auth')).findByRole('link', {name: '4ec47f1'});
expect(link.getAttribute('href')).toBe(`https://git.andreas-dahm.eu/Lobo_Corp/ToBePlayed-Auth/commit/${AUTH_SHA}`);
expect(await within(row('Core')).findByText('SETTINGS.VERSION_UNKNOWN')).toBeTruthy();
expect(fetch).toHaveBeenCalledWith('http://auth/api/version');
expect(fetch).toHaveBeenCalledWith('http://core/api/version');
});
it('shows a local build as dev without a link', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({ok: true, json: async () => ({commit: 'dev', builtAt: null})});
render(<BuildVersionCard/>);
expect(within(row('Frontend')).getByText('dev')).toBeTruthy();
expect(await within(row('Core')).findByText('dev')).toBeTruthy();
expect(screen.queryByRole('link')).toBeNull();
});
});
+63
View File
@@ -0,0 +1,63 @@
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Plus} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton';
import AddGamePanel from './AddGamePanel.jsx';
import FitText from './FitText.jsx';
import GameCover from './GameCover.jsx';
import TimeToBeat from './TimeToBeat.jsx';
import {fetchPreview} from '../utils/catalog.js';
/**
* A game the user does not have: its catalog data and a way to add it. The add flow is the search of the library,
* started with the game's name; adding ends on the user's own game page.
*
* @param onAdded called after the game was added, so the page can load the user's own game
* @param fallback shown when the catalog does not know the game or the request fails
*/
export default function CatalogGameCard({appid, fallback, onAdded}) {
const {t} = useTranslation();
const [preview, setPreview] = useState(null);
const [failed, setFailed] = useState(false);
const [adding, setAdding] = useState(false);
useEffect(() => {
let cancelled = false;
setPreview(null);
setFailed(false);
setAdding(false);
(async () => {
const {ok, data} = await fetchPreview(appid);
if (cancelled) return;
if (ok && data) setPreview(data);
else setFailed(true);
})();
return () => {
cancelled = true;
};
}, [appid]);
if (failed) return fallback;
if (!preview) return <Skeleton className="h-52 rounded-xl"/>;
return (
<div className="space-y-6">
<Card className="gap-4 p-5 md:flex-row md:items-start">
<GameCover appid={preview.appid} imageUrl={preview.imageUrl} className="w-full md:w-72 md:shrink-0"/>
<div className="min-w-0 flex-1 space-y-3">
<h1 className="@container text-2xl font-bold"><FitText text={preview.name}/></h1>
{preview.genres?.length > 0 && (
<p className="text-sm text-muted-foreground">{preview.genres.join(', ')}</p>
)}
<TimeToBeat time={preview.timeToBeat}/>
<p className="text-sm text-muted-foreground">{t('CATALOG.NOT_IN_LIBRARY')}</p>
{!adding && <Button onClick={() => setAdding(true)}><Plus/>{t('CATALOG.ADD')}</Button>}
</div>
</Card>
{adding && <AddGamePanel initialQuery={preview.name} onAdded={onAdded} onClose={() => setAdding(false)}/>}
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import {useTranslation} from 'react-i18next';
import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
import {KeyRound, Loader2} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import FormField from './FormField.jsx';
import PasswordInput from './PasswordInput.jsx';
import {changePassword} from '../utils/account.js';
import {changePasswordSchema} from '../utils/validation.js';
const EMPTY = {currentPassword: '', password: '', passwordConfirmation: ''};
/**
* "Change password": Auth ends every other login and keeps this one.
*/
export default function ChangePasswordCard() {
const {t} = useTranslation();
const {register, handleSubmit, reset, formState: {errors, isSubmitting}} = useForm({
resolver: zodResolver(changePasswordSchema),
defaultValues: EMPTY,
});
const submit = async ({currentPassword, password}) => {
try {
const response = await changePassword(currentPassword, password);
if (response.status === 204) {
toast.success(t('SETTINGS.CHANGE_PASSWORD.DONE'));
reset(EMPTY);
} else if (response.status === 403) {
toast.error(t('SETTINGS.CHANGE_PASSWORD.WRONG_PASSWORD'));
} else if (response.status === 429) {
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
toast.error(t('SETTINGS.CHANGE_PASSWORD.TOO_MANY', {minutes}));
} else if (response.status !== 401) {
toast.error(t('SETTINGS.CHANGE_PASSWORD.ERROR'));
}
} catch (err) {
console.error('Change password error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><KeyRound className="size-4"/>{t('SETTINGS.CHANGE_PASSWORD.TITLE')}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(submit)} className="space-y-4" noValidate>
<FormField id="currentPassword" label={t('SETTINGS.CHANGE_PASSWORD.CURRENT')} error={errors.currentPassword} t={t}>
<Input id="currentPassword" type="password" autoComplete="current-password"
aria-invalid={!!errors.currentPassword} {...register('currentPassword')}/>
</FormField>
<FormField id="newPassword" label={t('SETTINGS.CHANGE_PASSWORD.NEW')} error={errors.password}
description={t('VALIDATION.PASSWORD_RULES')} t={t}>
<PasswordInput id="newPassword" autoComplete="new-password" aria-invalid={!!errors.password}
aria-describedby="newPassword-description" {...register('password')}/>
</FormField>
<FormField id="newPasswordRepeat" label={t('SETTINGS.CHANGE_PASSWORD.REPEAT')} error={errors.passwordConfirmation} t={t}>
<PasswordInput id="newPasswordRepeat" autoComplete="new-password"
aria-invalid={!!errors.passwordConfirmation} {...register('passwordConfirmation')}/>
</FormField>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="animate-spin"/>}
{t('SETTINGS.CHANGE_PASSWORD.SUBMIT')}
</Button>
</form>
</CardContent>
</Card>
);
}
@@ -0,0 +1,80 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {toast} from 'sonner';
import ChangePasswordCard from './ChangePasswordCard.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const answers = (answer) => fetchRoutes({'POST /api/auth/password': answer});
function fillAndSubmit(current = 'old password', next = 'new password 1', repeat = next) {
fireEvent.change(screen.getByLabelText('SETTINGS.CHANGE_PASSWORD.CURRENT'), {target: {value: current}});
fireEvent.change(screen.getByLabelText('SETTINGS.CHANGE_PASSWORD.NEW'), {target: {value: next}});
fireEvent.change(screen.getByLabelText('SETTINGS.CHANGE_PASSWORD.REPEAT'), {target: {value: repeat}});
fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.CHANGE_PASSWORD.SUBMIT'}));
}
describe('ChangePasswordCard', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
it('showsToggleAndRulesOnTheNewPasswordFields', () => {
render(<ChangePasswordCard/>);
expect(screen.getAllByRole('button', {name: 'LOGIN.SHOW_PASSWORD'}).length).toBeGreaterThanOrEqual(2);
expect(screen.getByLabelText('SETTINGS.CHANGE_PASSWORD.NEW').getAttribute('aria-describedby'))
.toBe(screen.getByText('VALIDATION.PASSWORD_RULES').id);
});
it('sends the current and the new password and clears the form on success', async () => {
const mock = answers(json(204));
render(<ChangePasswordCard/>);
fillAndSubmit();
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('SETTINGS.CHANGE_PASSWORD.DONE'));
expect(mock.mock.calls[0][0]).toMatch(/\/api\/auth\/password$/);
expect(JSON.parse(mock.mock.calls[0][1].body)).toEqual({currentPassword: 'old password', newPassword: 'new password 1'});
expect(screen.getByLabelText('SETTINGS.CHANGE_PASSWORD.CURRENT').value).toBe('');
});
it('tells a wrong current password', async () => {
answers(json(403, {code: 'INVALID_PASSWORD'}));
render(<ChangePasswordCard/>);
fillAndSubmit();
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.CHANGE_PASSWORD.WRONG_PASSWORD'));
});
it('tells the lock with the minutes', async () => {
answers({...json(429, {}), headers: new Headers({'Retry-After': '900'})});
render(<ChangePasswordCard/>);
fillAndSubmit();
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.CHANGE_PASSWORD.TOO_MANY'));
});
it('sends nothing when the repeat differs or the current password is empty', async () => {
const mock = fetchRoutes({});
render(<ChangePasswordCard/>);
fillAndSubmit('old password', 'new password 1', 'new password 2');
expect(await screen.findByText('VALIDATION.PASSWORD_MISMATCH')).toBeTruthy();
fillAndSubmit('', 'new password 1');
expect(await screen.findByText('VALIDATION.CURRENT_PASSWORD_REQUIRED')).toBeTruthy();
expect(mock).not.toHaveBeenCalled();
});
});
+25
View File
@@ -0,0 +1,25 @@
import {useTranslation} from 'react-i18next';
import {CalendarClock} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {formatDay} from '../utils/format.js';
/**
* The "coming <date>" badge of a game that is not released yet; nothing for a released game. The date comes from the
* backend as `comingOn`, null for "coming soon".
*
* @param compact shows only the icon below the sm breakpoint
*/
export default function ComingBadge({game, compact = false}) {
const {t, i18n} = useTranslation();
if (!game.coming) return null;
const label = game.comingOn
? t('LIBRARY.COMING', {date: formatDay(game.comingOn, i18n.language, {dateStyle: 'medium'})})
: t('LIBRARY.COMING_SOON');
return (
<Badge variant="outline" title={label}>
<CalendarClock/>
<span className={compact ? 'hidden sm:inline' : undefined}>{label}</span>
</Badge>
);
}
+49
View File
@@ -0,0 +1,49 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import ComingBadge from './ComingBadge.jsx';
const i18n = vi.hoisted(() => ({language: 'en'}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key, options) => (options?.date ? `${key} ${options.date}` : key),
i18n,
}),
}));
describe('ComingBadge', () => {
afterEach(cleanup);
it('shows the release date in English', () => {
i18n.language = 'en';
render(<ComingBadge game={{coming: true, comingOn: '2026-12-04'}}/>);
expect(screen.getByText('LIBRARY.COMING Dec 4, 2026')).toBeTruthy();
});
it('shows the release date in German', () => {
i18n.language = 'de';
render(<ComingBadge game={{coming: true, comingOn: '2026-12-04'}}/>);
expect(screen.getByText('LIBRARY.COMING 04.12.2026')).toBeTruthy();
});
it('says "coming soon" when the date is unknown', () => {
render(<ComingBadge game={{coming: true, comingOn: null}}/>);
expect(screen.getByText('LIBRARY.COMING_SOON')).toBeTruthy();
});
it('shows nothing for a released game', () => {
const {container} = render(<ComingBadge game={{coming: false, comingOn: null}}/>);
expect(container.firstChild).toBeNull();
});
it('shows nothing when an older Core sends no field', () => {
const {container} = render(<ComingBadge game={{appid: 220}}/>);
expect(container.firstChild).toBeNull();
});
});
+157
View File
@@ -0,0 +1,157 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Archive, Blocks, Clock, Gamepad2, Library, Link2, Sparkles, Trophy, Users} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import StatCard from './StatCard.jsx';
import PeopleDialog from './people/PeopleDialog.jsx';
import {YearBars} from './stats/FinishedStats.jsx';
import {TagBars} from './stats/TasteStats.jsx';
import {StatDrillProvider, useStatDrill, useTagDrill} from './stats/StatDrill.jsx';
import useSession from '../hooks/useSession.js';
import usePublicStats from '../hooks/usePublicStats.js';
import {fetchUsers} from '../utils/people.js';
import {genreKey} from '../utils/stats.js';
function RankedGames({title, games, formatValue, drillKey}) {
const {t} = useTranslation();
const drill = useStatDrill();
return (
<Card className="gap-0 py-0">
<h3 className="flex items-center gap-2 border-b px-5 py-4 font-semibold">
<Trophy className="size-4 text-amber-500"/>
{title}
</h3>
<ol className="divide-y">
{games.map((g, idx) => (
<li key={g.appid} className="flex items-center gap-3 px-5 py-3">
<span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">{idx + 1}</span>
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 shrink-0"/>
<GameName game={g}/>
<span className="text-sm text-muted-foreground tabular-nums">{formatValue(g.value ?? 0)}</span>
</li>
))}
</ol>
{drill && (
<Button variant="ghost" className="m-2 self-start max-sm:min-h-11"
onClick={() => drill.open(title, {key: drillKey})}>
{t('STATS.DRILL.SHOW_ALL')}
</Button>
)}
</Card>
);
}
function GameName({game}) {
return (
<span className="@container min-w-0 flex-1 font-medium">
<FitText text={game.name ?? `App ${game.appid}`}/>
</span>
);
}
function CommunityBars({core}) {
const {t} = useTranslation();
const drill = useStatDrill();
const select = useTagDrill();
const genreLabel = (name) => t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name});
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{core?.gamesPerPlatform?.length > 0 && (
<BarsCard title={t('LANDING.PLATFORMS')}>
<TagBars tags={core.gamesPerPlatform} onSelect={select('PLATFORM')}/>
</BarsCard>
)}
{core?.topGenres?.length > 0 && (
<BarsCard title={t('LANDING.TOP_GENRES')}>
<TagBars tags={core.topGenres} label={genreLabel} onSelect={select('GENRE')}/>
</BarsCard>
)}
{core?.finishedPerYear?.length > 0 && (
<BarsCard title={t('LANDING.FINISHED_PER_YEAR')}>
<YearBars perYear={core.finishedPerYear}
onSelect={drill && ((year) => drill.open(`${t('LANDING.FINISHED_PER_YEAR')}: ${year}`, {key: 'FINISHED_YEAR', arg: year}))}/>
</BarsCard>
)}
</div>
);
}
function BarsCard({title, children}) {
return (
<Card className="gap-0 py-0">
<h3 className="flex items-center gap-2 border-b px-5 py-4 font-semibold">{title}</h3>
<div className="px-5 py-4">{children}</div>
</Card>
);
}
/**
* The numbers of all users: totals, top games and bars. Logged-in users open the people behind a total.
*/
export default function CommunityStats() {
const {t, i18n} = useTranslation();
const {data, isLoading} = usePublicStats();
const {active} = useSession();
const [people, setPeople] = useState(null);
const auth = data.auth;
const core = data.core;
const stats = [
[t('LANDING.TOTAL_USERS'), auth?.totalUsers, Users, undefined, undefined, 'TOTAL_USERS'],
[t('LANDING.STEAM_LINKED'), auth?.usersWithSteamLinked, Link2, undefined, undefined, 'STEAM_LINKED'],
[t('LANDING.TOTAL_GAMES'), core?.totalLibraryEntries, Gamepad2, undefined, 'ENTRIES'],
[t('LANDING.GAMES_PLAYED'), core?.totalGamesPlayed, Sparkles, undefined, 'PLAYED'],
[t('LANDING.DISTINCT_GAMES'), core?.totalDistinctGames, Blocks, undefined, 'OWNED'],
[t('LANDING.TOTAL_PLAYTIME_DAYS'), core?.totalPlaytimeDays, Clock, ` ${t('LANDING.DAYS_SUFFIX')}`],
[t('LANDING.BIGGEST_LIBRARY'), core?.biggestLibrary, Library],
[t('LANDING.BIGGEST_BACKLOG'), core?.biggestBacklog, Archive],
];
return (
<StatDrillProvider source="community">
<div className="space-y-8">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{isLoading
? stats.map(([label]) => <Skeleton key={label} className="h-36 rounded-xl"/>)
: stats.map(([label, value, icon, suffix, key, usersKey]) => (
<StatCard key={label} label={label} value={value ?? 0} icon={icon} suffix={suffix}
drill={key && {params: {key}}}
onClick={active && usersKey && value > 0
? () => setPeople({title: label, key: usersKey}) : undefined}/>
))}
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{core?.topPlayedGames?.length > 0 && (
<RankedGames
title={t('LANDING.TOP_PLAYED')}
games={core.topPlayedGames}
formatValue={(minutes) => `${Math.round(minutes / 60).toLocaleString(i18n.language)}h`}
drillKey="MOST_PLAYED"
/>
)}
{core?.topOwnedGames?.length > 0 && (
<RankedGames
title={t('LANDING.TOP_OWNED')}
games={core.topOwnedGames}
formatValue={(count) => count.toLocaleString(i18n.language)}
drillKey="OWNED"
/>
)}
</div>
<CommunityBars core={core}/>
</div>
{people && (
<PeopleDialog title={people.title} onClose={() => setPeople(null)}
load={(page) => fetchUsers({key: people.key, page})}/>
)}
</StatDrillProvider>
);
}
+5 -32
View File
@@ -1,12 +1,10 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {Loader2, Trophy} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import useSessionExpired from '../hooks/useSessionExpired.js';
import {saveGameStatus} from '../utils/backlog.js';
import useSetStatus from '../hooks/useSetStatus.js';
import {allAchievementsUnlocked} from '../utils/confetti.js';
/**
* Shown for a game with every achievement unlocked that is not marked as 100 % yet.
@@ -15,34 +13,9 @@ import {saveGameStatus} from '../utils/backlog.js';
*/
export default function CompletedHint({game, onChanged}) {
const {t} = useTranslation();
const expireSession = useSessionExpired();
const [busy, setBusy] = useState(false);
const {setStatus, busy} = useSetStatus(game.appid, {onChanged});
const allUnlocked = game.hasAchievements && game.statsVisible && game.achievementsTotal > 0
&& game.achievementsUnlocked >= game.achievementsTotal;
if (!allUnlocked || game.status === 'COMPLETED') return null;
const setCompleted = async () => {
setBusy(true);
try {
const response = await saveGameStatus(game.appid, 'COMPLETED');
if (response.status === 401) {
expireSession();
return;
}
if (!response.ok) {
toast.error(t('BACKLOG.SAVE_FAILED'));
return;
}
onChanged(await response.json());
toast.success(t('BACKLOG.SAVED'));
} catch (err) {
console.error('set completed error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
if (!allAchievementsUnlocked(game) || game.status === 'COMPLETED') return null;
return (
<Card className="gap-3 border-amber-500/40 bg-amber-500/5 p-4 sm:flex-row sm:items-center sm:gap-4">
@@ -50,7 +23,7 @@ export default function CompletedHint({game, onChanged}) {
<Trophy className="size-5 shrink-0 text-amber-500"/>
<p className="text-sm">{t('GAME_DETAIL.ALL_UNLOCKED')}</p>
</div>
<Button size="sm" className="self-end sm:self-auto" onClick={setCompleted} disabled={busy}>
<Button size="sm" className="self-end sm:self-auto" onClick={() => setStatus('COMPLETED')} disabled={busy}>
{busy && <Loader2 className="animate-spin"/>}
{t('GAME_DETAIL.SET_COMPLETED')}
</Button>
+4 -3
View File
@@ -2,10 +2,12 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import CompletedHint from './CompletedHint.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
vi.mock('../hooks/usePointsCheck.js', () => ({default: () => vi.fn()}));
const game = {appid: 220, status: 'FINISHED', hasAchievements: true, statsVisible: true, achievementsTotal: 10, achievementsUnlocked: 10};
const renderHint = (props) => render(<MemoryRouter><CompletedHint game={game} onChanged={() => {}} {...props}/></MemoryRouter>);
@@ -13,9 +15,8 @@ const renderHint = (props) => render(<MemoryRouter><CompletedHint game={game} on
describe('CompletedHint', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url, init) => ({
ok: true, status: 200, json: async () => ({...JSON.parse(init.body), rating: 5, notes: 'keep', priority: null}),
}));
fetchRoutes({'PUT /api/library/games/220/status': (url, init) =>
json(200, {...JSON.parse(init.body), rating: 5, notes: 'keep', priority: null})});
});
afterEach(() => {
+98
View File
@@ -0,0 +1,98 @@
import {useEffect, useRef, useState} from 'react';
import {LOGO_COLORS} from './Logo.jsx';
import {reduceMotion} from '../utils/appearance.js';
const DURATION_MS = 2800;
const PIECES = {small: 70, big: 240};
const GRAVITY = 0.12;
// The speeds are per frame at 60 Hz; a faster screen moves the same distance in the same time.
const FRAME_MS = 1000 / 60;
// The logo colors of the chosen accent and the accent itself.
function colors() {
const style = getComputedStyle(document.documentElement);
const logo = LOGO_COLORS.map(([name, hex]) => style.getPropertyValue(name).trim() || hex);
const accent = style.getPropertyValue('--primary').trim();
return accent ? [...logo, accent] : logo;
}
// Small: one burst from the top center. Big: two cannons from the lower corners.
function pieces(size, width, height) {
const palette = colors();
const big = size === 'big';
return Array.from({length: PIECES[size]}, (_, i) => {
const left = i % 2 === 0;
const angle = big ? (left ? -Math.PI / 3 : -2 * Math.PI / 3) + (Math.random() - 0.5) * 0.6
: Math.PI / 2 + (Math.random() - 0.5) * 2.4;
const speed = big ? 9 + Math.random() * 9 : 2 + Math.random() * 5;
return {
x: big ? (left ? 0 : width) : width / 2,
y: big ? height : height * 0.1,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
spin: Math.random() * Math.PI,
spinSpeed: (Math.random() - 0.5) * 0.3,
w: 6 + Math.random() * 6,
h: 3 + Math.random() * 4,
color: palette[i % palette.length],
};
});
}
/**
* Confetti over the whole page for about 3 s after mount, then onDone. Remount (a new key) to play again.
*
* @param size 'small' or 'big'
*/
export default function Confetti({size, onDone}) {
const canvasRef = useRef(null);
const [reduce] = useState(reduceMotion);
// In a ref: a new function from a re-render must not restart the animation, and the latest one runs at the end.
const onDoneRef = useRef(onDone);
onDoneRef.current = onDone;
useEffect(() => {
const canvas = canvasRef.current;
const context = canvas?.getContext('2d');
if (!context) return undefined;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const all = pieces(size, canvas.width, canvas.height);
const start = performance.now();
let last = start;
let frame;
const draw = (now) => {
const elapsed = now - start;
const steps = (now - last) / FRAME_MS;
last = now;
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, canvas.width, canvas.height);
if (elapsed >= DURATION_MS) {
onDoneRef.current?.();
return;
}
context.globalAlpha = 1 - elapsed / DURATION_MS;
for (const piece of all) {
piece.vy += GRAVITY * steps;
piece.vx *= 0.99 ** steps;
piece.x += piece.vx * steps;
piece.y += piece.vy * steps;
piece.spin += piece.spinSpeed * steps;
if (piece.y > canvas.height + 20) continue;
const cos = Math.cos(piece.spin);
const sin = Math.sin(piece.spin);
context.setTransform(cos, sin, -sin, cos, piece.x, piece.y);
context.fillStyle = piece.color;
context.fillRect(-piece.w / 2, -piece.h / 2, piece.w, piece.h * Math.abs(cos));
}
frame = requestAnimationFrame(draw);
};
frame = requestAnimationFrame(draw);
return () => cancelAnimationFrame(frame);
}, [size]);
if (reduce) return null;
return <canvas ref={canvasRef} aria-hidden="true" data-size={size}
className="pointer-events-none fixed inset-0 z-50 size-full"/>;
}
+46
View File
@@ -0,0 +1,46 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render} from '@testing-library/react';
import Confetti from './Confetti.jsx';
// jsdom has no canvas drawing.
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const reducedMotion = (reduce) => vi.stubGlobal('matchMedia', (query) => ({
matches: reduce && query.includes('prefers-reduced-motion'),
}));
describe('Confetti', () => {
it('draws on a canvas hidden from screen readers', () => {
reducedMotion(false);
const {container} = render(<Confetti size="big"/>);
const canvas = container.querySelector('canvas');
expect(canvas.getAttribute('aria-hidden')).toBe('true');
expect(canvas.dataset.size).toBe('big');
});
it('draws nothing when the user asked for reduced motion', () => {
reducedMotion(true);
const {container} = render(<Confetti size="small"/>);
expect(container.querySelector('canvas')).toBeNull();
});
it('draws nothing when the reduce animations setting is on', () => {
reducedMotion(false);
document.documentElement.setAttribute('data-reduce-motion', '');
const {container} = render(<Confetti size="small"/>);
document.documentElement.removeAttribute('data-reduce-motion');
expect(container.querySelector('canvas')).toBeNull();
});
});
+11 -6
View File
@@ -4,14 +4,19 @@ import {ArrowRight, Gamepad2} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import {formatPlaytime} from '../utils/games.js';
export default function CurrentlyPlayingCard({games, syncing}) {
/**
* @param wide four games per row instead of two, for the large tile
*/
export default function CurrentlyPlayingCard({games, syncing, wide}) {
const {t} = useTranslation();
return (
<Card className="gap-0 py-0">
<div className="flex items-center justify-between px-5 py-4 border-b">
<div className="flex flex-wrap items-center justify-between gap-x-3 px-5 py-4 border-b">
<h2 className="flex items-center gap-2 font-semibold">
<Gamepad2 className="size-4 text-primary"/>
{t('DASHBOARD.CURRENTLY_PLAYING')}
@@ -26,16 +31,16 @@ export default function CurrentlyPlayingCard({games, syncing}) {
{syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('DASHBOARD.NOTHING_PLAYING')}
</p>
) : (
<ul className="grid grid-cols-2 gap-3 p-4">
<ul className={cn('grid grid-cols-2 gap-3 p-4', wide && 'sm:grid-cols-4')}>
{games.map((g) => (
<li key={g.appid}>
<Link to={`/library/${g.appid}`} className="group block space-y-1.5">
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-full transition-opacity group-hover:opacity-80"/>
<div className="truncate text-sm font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`}
<div className="@container text-sm font-medium group-hover:text-primary transition-colors">
<FitText text={g.name ?? `App ${g.appid}`}/>
</div>
<div className="text-xs text-muted-foreground tabular-nums">
{formatPlaytime(g.playtimeMinutes)}
{formatPlaytime(g.totalPlaytimeMinutes ?? g.playtimeMinutes)}
</div>
</Link>
</li>
@@ -0,0 +1,18 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import CurrentlyPlayingCard from './CurrentlyPlayingCard.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
describe('CurrentlyPlayingCard', () => {
afterEach(cleanup);
it('shows the set total of a game, not the minutes of its own row', () => {
render(<MemoryRouter><CurrentlyPlayingCard games={[{appid: 620, name: 'Portal 2', playtimeMinutes: 100,
totalPlaytimeMinutes: 600, status: 'PLAYING'}]}/></MemoryRouter>);
expect(screen.getByText('10h')).toBeTruthy();
});
});
+124
View File
@@ -0,0 +1,124 @@
import {useCallback, useEffect, useMemo, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import HintCard from './HintCard.jsx';
import {AUTO_HINTS, fetchHints, hideAutoHint, markHintDone, pileGrowth, snoozeAutoHint} from '../utils/hints.js';
import {canCorrect} from '../utils/corrections.js';
import {fetchLinkQueue} from '../utils/links.js';
export const AUTO_CLOSE_MS = 30_000;
// Getting-started hints stay until the user closes them.
const STICKY_HINTS = ['NO_STEAM', 'EMPTY_LIBRARY'];
const CLOSED_KEY = 'tbp_closed_hints';
// Hints closed in this tab, so going back to the dashboard does not show them again.
const closedIds = () => {
try {
return JSON.parse(sessionStorage.getItem(CLOSED_KEY)) ?? [];
} catch {
return [];
}
};
const rememberClosed = (hintId) => {
try {
sessionStorage.setItem(CLOSED_KEY, JSON.stringify([...closedIds(), hintId]));
} catch {
// Storage blocked: the hint shows again on the next dashboard visit.
}
};
/**
* The hints on the dashboard, one at a time: the admin hints newest first, then the automatic ones. A DISMISS hint
* closed by the 30 s timer without "Do not show again" comes back in the next browser session; STICKY_HINTS have no
* timer. The X on an automatic hint hides it for 7 days, "Do not show again" for good.
*
* @param user the current user
* @param canSync a Steam ID or a PlayStation link: then the NO_STEAM hint has nothing to say; undefined while loading
* @param summary Core's library summary; undefined while loading, null when it failed
* @param trend the TREND group of the stats; undefined while loading, null when it failed
*/
export default function DashboardHint({user, canSync, summary, trend}) {
const {t} = useTranslation();
const [data, setData] = useState({hints: [], automatic: []});
const [closed, setClosed] = useState(closedIds);
const [hideForever, setHideForever] = useState(false);
const [linkCount, setLinkCount] = useState();
const wantsLinkQueue = data.automatic.includes('LINK_QUEUE') && canCorrect(user);
useEffect(() => {
// A failed load shows nothing, not an error.
fetchHints()
.then((response) => (response.ok ? response.json() : null))
.then((body) => setData({hints: body?.results ?? [], automatic: body?.automatic ?? []}))
.catch(() => undefined);
}, []);
useEffect(() => {
if (!wantsLinkQueue) return;
// The queue is an AUDITOR page: a failure or 403 counts as an empty queue.
fetchLinkQueue({page: 0, size: 20})
.then((response) => (response.ok ? response.json() : null))
.then((body) => setLinkCount(body?.totalElements ?? 0))
.catch(() => setLinkCount(0));
}, [wantsLinkQueue]);
const hint = useMemo(() => {
const {hints, automatic} = data;
const links = wantsLinkQueue ? linkCount : 0;
const needsSummary = hints.some((h) => h.emptyLibraryOnly) || automatic.includes('EMPTY_LIBRARY');
// Show nothing until every fact the cards need has settled, so a later fact does not swap the card.
if ((needsSummary && summary === undefined) || (automatic.includes('PILE_GREW') && trend === undefined)
|| (automatic.includes('NO_STEAM') && (!user || canSync === undefined)) || links === undefined) {
return undefined;
}
const libraryEmpty = summary?.total === 0;
const growth = pileGrowth(trend);
const condition = {NO_STEAM: !canSync, EMPTY_LIBRARY: libraryEmpty, LINK_QUEUE: links > 0, PILE_GREW: growth > 0};
const count = {LINK_QUEUE: links, PILE_GREW: growth};
return [
...hints.filter((h) => !h.emptyLibraryOnly || libraryEmpty),
...AUTO_HINTS.filter((key) => automatic.includes(key) && condition[key]).map((key) => ({
id: `auto:${key}`, auto: key, kind: 'DISMISS', text: t(`HINT.AUTO_${key}`, {count: count[key]}),
})),
].find((h) => !closed.includes(h.id));
}, [data, closed, summary, trend, user, canSync, linkCount, wantsLinkQueue, t]);
// A failed save only shows an error: the hint comes back in the next browser session.
const close = useCallback(async (forever, byUser) => {
rememberClosed(hint.id);
setClosed((current) => [...current, hint.id]);
setHideForever(false);
let save = null;
if (hint.auto) {
if (forever) save = () => hideAutoHint(hint.auto);
else if (byUser) save = () => snoozeAutoHint(hint.auto);
} else if (forever) {
save = () => markHintDone(hint.id);
}
if (!save) return;
try {
const response = await save();
if (!response.ok) toast.error(t('HINT.SAVE_FAILED'));
} catch {
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
}
}, [hint, t]);
useEffect(() => {
if (hint?.kind !== 'DISMISS' || STICKY_HINTS.includes(hint.auto)) return undefined;
const timer = setTimeout(() => close(hideForever, false), AUTO_CLOSE_MS);
return () => clearTimeout(timer);
}, [hint, hideForever, close]);
if (!hint) return null;
// The next card shows under the same pointer: the second click of a double click must not close it unseen.
return (
<HintCard key={hint.id} hint={hint} hideForever={hideForever} onHideForeverChange={setHideForever}
onClose={(event) => event.detail < 2 && close(hideForever, true)}
onAccept={(event) => event.detail < 2 && close(true, true)}
className="fixed inset-x-4 bottom-4 z-50 mx-auto max-w-md max-md:bottom-20"/>
);
}
+328
View File
@@ -0,0 +1,328 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
import DashboardHint, {AUTO_CLOSE_MS} from './DashboardHint.jsx';
import {hintText, pileGrowth} from '../utils/hints.js';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {error: vi.fn()}}));
const DISMISS = {id: 2, textEn: 'Server update tonight', textDe: 'Heute Nacht Update', kind: 'DISMISS'};
const ACCEPT = {id: 1, textEn: 'Link your Steam account', textDe: null, kind: 'ACCEPT'};
const USER = {steamId: '1', activeRole: 'USER'};
const AUDITOR = {steamId: '1', activeRole: 'AUDITOR'};
const NO_TREND = {days: []};
const GREW = {days: [{day: '2026-10-01', backlogGames: 10}, {day: '2026-10-02', backlogGames: 12}]};
const hintRoutes = (list, done = json(204), more = {}) => fetchRoutes({
'GET /api/hints': list, 'PUT /api/hints/1/done': done, 'PUT /api/hints/2/done': done,
'PUT /api/hints/auto/NO_STEAM/snooze': done, 'PUT /api/hints/auto/NO_STEAM/done': done,
'PUT /api/hints/auto/PILE_GREW/done': done, 'PUT /api/hints/auto/EMPTY_LIBRARY/snooze': done, ...more,
});
const doneCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([url]) => url);
const mount = (props = {}) => render(
<MemoryRouter><DashboardHint user={USER} summary={{total: 5}} trend={NO_TREND} {...props}/></MemoryRouter>);
const showHints = async (...hints) => {
hintRoutes(json(200, {results: hints}));
mount();
await act(async () => undefined);
};
const showAuto = async (automatic, props = {}, results = [], more = {}) => {
hintRoutes(json(200, {results, automatic}), json(204), more);
mount(props);
await act(async () => undefined);
};
describe('DashboardHint', () => {
beforeEach(() => vi.useFakeTimers({shouldAdvanceTime: true}));
afterEach(() => {
cleanup();
sessionStorage.clear();
vi.useRealTimers();
vi.clearAllMocks();
});
it('shows one hint at a time and the next after closing', async () => {
await showHints(DISMISS, ACCEPT);
expect(screen.getByText('Heute Nacht Update')).toBeTruthy();
expect(screen.queryByText(ACCEPT.textEn)).toBeNull();
fireEvent.click(screen.getByLabelText('HINT.CLOSE'));
expect(screen.getByText(ACCEPT.textEn)).toBeTruthy();
expect(doneCalls()).toEqual([]);
});
it('does not show a hint closed in this tab again when the dashboard mounts again', async () => {
await showHints(DISMISS, ACCEPT);
fireEvent.click(screen.getByLabelText('HINT.CLOSE'));
cleanup();
mount();
await act(async () => undefined);
expect(screen.queryByText(DISMISS.textDe)).toBeNull();
expect(screen.getByText(ACCEPT.textEn)).toBeTruthy();
expect(doneCalls()).toEqual([]);
});
it('stores a dismiss hint only when "Do not show again" is ticked', async () => {
await showHints(DISMISS);
fireEvent.click(screen.getByLabelText('HINT.DO_NOT_SHOW_AGAIN'));
await act(async () => fireEvent.click(screen.getByLabelText('HINT.CLOSE')));
expect(screen.queryByRole('status')).toBeNull();
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/2/done')]);
});
it('closes a dismiss hint by itself after 30 s without storing it', async () => {
await showHints(DISMISS);
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS));
expect(screen.queryByRole('status')).toBeNull();
expect(doneCalls()).toEqual([]);
});
it('keeps an accept hint until OK and stores it', async () => {
await showHints(ACCEPT);
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS * 2));
expect(screen.getByText(ACCEPT.textEn)).toBeTruthy();
expect(screen.queryByLabelText('HINT.CLOSE')).toBeNull();
await act(async () => fireEvent.click(screen.getByText('HINT.OK')));
expect(screen.queryByRole('status')).toBeNull();
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/1/done')]);
});
it('shows an error when the answer is not stored', async () => {
hintRoutes(json(200, {results: [ACCEPT]}), json(500));
mount();
await act(async () => undefined);
await act(async () => fireEvent.click(screen.getByText('HINT.OK')));
expect(toast.error).toHaveBeenCalledWith('HINT.SAVE_FAILED');
});
it('does not let a double click on OK accept the next hint unseen', async () => {
await showHints(ACCEPT, {...ACCEPT, id: 3, textEn: 'Rate your games'});
await act(async () => fireEvent.click(screen.getByText('HINT.OK'), {detail: 1}));
// The second click of the same double click lands on the next card's OK button.
await act(async () => fireEvent.click(screen.getByText('HINT.OK'), {detail: 2}));
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/1/done')]);
expect(screen.getByText('Rate your games')).toBeTruthy();
});
it('shows nothing when the hints do not load', async () => {
hintRoutes(json(500));
mount();
await act(async () => undefined);
expect(screen.queryByRole('status')).toBeNull();
expect(toast.error).not.toHaveBeenCalled();
});
});
describe('DashboardHint, automatic hints', () => {
beforeEach(() => vi.useFakeTimers({shouldAdvanceTime: true}));
afterEach(() => {
cleanup();
sessionStorage.clear();
vi.useRealTimers();
vi.clearAllMocks();
});
it('shows the admin hints before the automatic ones', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: false}, [DISMISS]);
expect(screen.getByText('Heute Nacht Update')).toBeTruthy();
fireEvent.click(screen.getByLabelText('HINT.CLOSE'));
expect(screen.getByText('HINT.AUTO_NO_STEAM')).toBeTruthy();
});
it('shows NO_STEAM only when nothing can sync', async () => {
await showAuto(['NO_STEAM'], {canSync: true});
expect(screen.queryByRole('status')).toBeNull();
});
it('shows no NO_STEAM hint to a user with a PlayStation link and no Steam ID', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: true});
expect(screen.queryByRole('status')).toBeNull();
});
it('waits with NO_STEAM until it is known whether the user can sync', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: undefined});
expect(screen.queryByRole('status')).toBeNull();
});
it('hides an automatic hint for 7 days with X', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: false});
await act(async () => fireEvent.click(screen.getByLabelText('HINT.CLOSE')));
expect(screen.queryByRole('status')).toBeNull();
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/auto/NO_STEAM/snooze')]);
});
it('hides an automatic hint for good with the tick', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: false});
fireEvent.click(screen.getByLabelText('HINT.DO_NOT_SHOW_AGAIN'));
await act(async () => fireEvent.click(screen.getByLabelText('HINT.CLOSE')));
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/auto/NO_STEAM/done')]);
});
it('does not store the auto close of an automatic hint and shows it again next session', async () => {
await showAuto(['PILE_GREW'], {trend: GREW});
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS));
expect(screen.queryByRole('status')).toBeNull();
expect(doneCalls()).toEqual([]);
cleanup();
sessionStorage.clear();
await showAuto(['PILE_GREW'], {trend: GREW});
expect(screen.getByText('HINT.AUTO_PILE_GREW:2')).toBeTruthy();
});
it('hides the auto closed hint for good when the tick is set', async () => {
await showAuto(['PILE_GREW'], {trend: GREW});
fireEvent.click(screen.getByLabelText('HINT.DO_NOT_SHOW_AGAIN'));
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS));
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/auto/PILE_GREW/done')]);
});
it.each([
['NO_STEAM', {user: {...USER, steamId: null}, canSync: false}],
['EMPTY_LIBRARY', {summary: {total: 0}}],
])('keeps the %s hint after the auto close time, it closes only by the user', async (key, props) => {
await showAuto([key], props);
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS * 2));
expect(screen.getByText(`HINT.AUTO_${key}`)).toBeTruthy();
await act(async () => fireEvent.click(screen.getByLabelText('HINT.CLOSE')));
expect(screen.queryByRole('status')).toBeNull();
});
it('does not show an automatic hint that Auth did not send', async () => {
await showAuto([], {user: {...USER, steamId: null}, summary: {total: 0}});
expect(screen.queryByRole('status')).toBeNull();
});
it('shows EMPTY_LIBRARY only with an empty library', async () => {
await showAuto(['EMPTY_LIBRARY'], {summary: {total: 3}});
expect(screen.queryByRole('status')).toBeNull();
cleanup();
await showAuto(['EMPTY_LIBRARY'], {summary: {total: 0}});
expect(screen.getByText('HINT.AUTO_EMPTY_LIBRARY')).toBeTruthy();
});
it('shows an admin hint with emptyLibraryOnly only with an empty library', async () => {
const only = {...DISMISS, emptyLibraryOnly: true};
await showAuto([], {summary: {total: 3}}, [only]);
expect(screen.queryByRole('status')).toBeNull();
cleanup();
await showAuto([], {summary: {total: 0}}, [only]);
expect(screen.getByText('Heute Nacht Update')).toBeTruthy();
});
it('shows nothing while a fact it needs is loading', async () => {
await showAuto(['PILE_GREW'], {trend: undefined});
expect(screen.queryByRole('status')).toBeNull();
cleanup();
await showAuto(['EMPTY_LIBRARY'], {summary: undefined});
expect(screen.queryByRole('status')).toBeNull();
});
it('shows PILE_GREW with the growth since the first day of the month', async () => {
await showAuto(['PILE_GREW'], {trend: GREW});
expect(screen.getByText('HINT.AUTO_PILE_GREW:2')).toBeTruthy();
});
it('does not ask the link queue below AUDITOR', async () => {
await showAuto(['LINK_QUEUE'], {user: USER});
expect(screen.queryByRole('status')).toBeNull();
expect(fetch.mock.calls.some(([url]) => url.includes('link-queue'))).toBe(false);
});
it('shows LINK_QUEUE to an auditor with the number of pairs', async () => {
await showAuto(['LINK_QUEUE'], {user: AUDITOR}, [],
{'GET /api/library/link-queue': json(200, {content: [], totalElements: 3})});
expect(screen.getByText('HINT.AUTO_LINK_QUEUE:3')).toBeTruthy();
});
it('shows no LINK_QUEUE hint when the queue is empty or refused', async () => {
await showAuto(['LINK_QUEUE'], {user: AUDITOR}, [],
{'GET /api/library/link-queue': json(403, {})});
expect(screen.queryByRole('status')).toBeNull();
});
it('shows only the admin hints when Auth sends no automatic list', async () => {
hintRoutes(json(200, {results: [DISMISS]}));
mount({user: {...USER, steamId: null}, canSync: false});
await act(async () => undefined);
expect(screen.getByText('Heute Nacht Update')).toBeTruthy();
fireEvent.click(screen.getByLabelText('HINT.CLOSE'));
expect(screen.queryByRole('status')).toBeNull();
});
});
describe('pileGrowth', () => {
it('counts from the first day of the last month and is 0 without a second day', () => {
expect(pileGrowth(GREW)).toBe(2);
expect(pileGrowth({days: [{day: '2026-09-30', backlogGames: 5}, {day: '2026-10-02', backlogGames: 4}]})).toBe(-1);
expect(pileGrowth({days: [{day: '2026-10-02', backlogGames: 4}]})).toBe(0);
expect(pileGrowth({days: [{day: '2026-10-01', backlogGames: 9}, {day: '2026-10-02', backlogGames: 7}]})).toBe(-2);
expect(pileGrowth(null)).toBe(0);
});
it('counts games added on the first day of the month', () => {
// Pile 5 at the end of September, 3 games added on October 1, nothing after.
const trend = {days: [{day: '2026-09-30', backlogGames: 5}, {day: '2026-10-01', backlogGames: 8},
{day: '2026-10-02', backlogGames: 8}]};
expect(pileGrowth(trend)).toBe(3);
// On October 1 itself: last month's snapshot and today's live value.
expect(pileGrowth({days: [{day: '2026-09-30', backlogGames: 5}, {day: '2026-10-01', backlogGames: 8}]})).toBe(3);
});
});
describe('hintText', () => {
it('takes the German text for a German UI only when there is one', () => {
expect(hintText(DISMISS, 'de-DE')).toBe(DISMISS.textDe);
expect(hintText(DISMISS, 'en')).toBe(DISMISS.textEn);
expect(hintText(ACCEPT, 'de')).toBe(ACCEPT.textEn);
});
});
+79
View File
@@ -0,0 +1,79 @@
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Hash, LayoutGrid, Link2, RefreshCw} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import HeroBanner from './HeroBanner.jsx';
import FitText from './FitText.jsx';
import PointsBadge from './PointsBadge.jsx';
import SteamAvatar from './SteamAvatar.jsx';
import SyncStatusBadge from './SyncStatusBadge.jsx';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {bannerEffect, lookClass} from '../utils/rewards.js';
// canSync: a Steam ID or a PlayStation link; the sync controls follow it, the Steam ID badge only the Steam ID.
export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forceSync, canSync, editing, onCustomize, equipped, returning}) {
const {t} = useTranslation();
const {user: userData} = useCurrentUserContext();
const hasSteamId = !!userData?.steamId;
const showSync = canSync ?? hasSteamId;
const syncBusy = forceSync.busy || sync.isRunning;
return (
<Card className={cn('relative overflow-hidden p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6',
lookClass(equipped, 'BANNER_FRAME'))}>
{/* A picked game, else the game played most in the last two weeks. */}
<HeroBanner appid={bannerAppid} effect={bannerEffect(equipped)}/>
<div className="relative flex min-w-0 items-center gap-4 md:flex-1">
{/* The slot is there before the avatar loads, so the title does not jump. */}
{hasSteamId && (
<span className={cn('flex size-16 shrink-0 items-center justify-center rounded-full bg-muted',
lookClass(equipped, 'AVATAR_BORDER') ?? 'ring-2 ring-primary/60')}>
<SteamAvatar url={avatarUrl} className="size-16" iconClassName="size-8 text-muted-foreground"/>
</span>
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-muted-foreground">{t(returning ? 'DASHBOARD.WELCOME_BACK' : 'DASHBOARD.WELCOME')}</p>
<h1 className="@container mt-1 text-3xl md:text-4xl font-bold tracking-tight">
<FitText text={[t('DASHBOARD.HELLO'), userData?.displayName ?? userData?.email?.split('@')[0], '👋'].filter(Boolean).join(' ')}/>
</h1>
<PointsBadge equipped={equipped} className="mt-2"/>
{hasSteamId ? (
<Badge variant="outline" className="mt-3 max-w-full gap-1.5 font-mono text-muted-foreground">
<Hash className="size-3"/>
<span className="min-w-0 truncate">{userData.steamId}</span>
</Badge>
) : (
<Button asChild variant="link" className="mt-2 h-auto p-0">
<Link to="/settings?tab=connections"><Link2 className="size-4"/>{t('DASHBOARD.NO_STEAM_ID')}</Link>
</Button>
)}
</div>
</div>
<div className="relative flex flex-col items-start md:items-end gap-3 shrink-0">
{showSync && (
<>
<SyncStatusBadge
status={sync.status}
lastSyncedAt={sync.lastSyncedAt}
isRunning={sync.isRunning}
queued={sync.queued}
stale={sync.stale}
/>
<Button onClick={forceSync.trigger} disabled={syncBusy}>
<RefreshCw className={syncBusy ? 'animate-spin' : ''}/>
{t('LIBRARY.RESYNC')}
</Button>
</>
)}
<Button variant="ghost" size="sm" disabled={editing} onClick={onCustomize}>
<LayoutGrid/>{t('TILES.CUSTOMIZE')}
</Button>
</div>
</Card>
);
}
@@ -0,0 +1,65 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, screen} from '@testing-library/react';
import DashboardWelcomeCard from './DashboardWelcomeCard.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
afterEach(() => {
cleanup();
localStorage.clear();
});
const renderCard = async (equipped, bannerAppid) => {
localStorage.setItem('tbp_session', '1');
fetchRoutes({'GET /api/users/me': json(200, {displayName: 'Lobo', steamId: '1'}), 'GET /api/library/hero-art': json(200, {})});
renderWithProviders(<DashboardWelcomeCard bannerAppid={bannerAppid} sync={{}} forceSync={{}} equipped={equipped}/>);
await screen.findByText(/Lobo/);
};
describe('DashboardWelcomeCard looks', () => {
it('wears the border, the frame and the badge from the shop', async () => {
await renderCard({AVATAR_BORDER: 'BORDER_GOLD', BANNER_FRAME: 'FRAME_NEON', BADGE: 'BADGE_COMPLETIONIST'});
const card = screen.getByText(/Lobo/).closest('[data-slot="card"]');
expect(card.className).toContain('ring-fuchsia-500');
expect(card.querySelector('.rounded-full').className).toContain('ring-amber-400');
expect(screen.getByText('POINTS.ITEMS.BADGE_COMPLETIONIST')).toBeTruthy();
});
it('shows a worn banner background instead of the game art', async () => {
await renderCard({BANNER_BACKGROUND: 'BN_GRID'}, 620);
const card = screen.getByText(/Lobo/).closest('[data-slot="card"]');
expect(card.querySelector('[data-effect="grid"]')).not.toBeNull();
expect(card.querySelector('img[src*="library_hero"]')).toBeNull();
});
it('shows the game art without a worn banner background', async () => {
await renderCard({}, 620);
const card = screen.getByText(/Lobo/).closest('[data-slot="card"]');
expect(card.querySelector('[data-effect]')).toBeNull();
expect(card.querySelector('img[src*="library_hero"]')).not.toBeNull();
});
it('keeps the default avatar ring without a worn border', async () => {
await renderCard({});
expect(screen.getByText(/Lobo/).closest('[data-slot="card"]').querySelector('.rounded-full').className)
.toContain('ring-primary/60');
});
});
describe('DashboardWelcomeCard greeting', () => {
it('greetsWithoutUndefinedBeforeTheUserIsLoaded', () => {
fetchRoutes({'GET /api/library/hero-art': json(200, {})});
renderWithProviders(<DashboardWelcomeCard sync={{}} forceSync={{}} equipped={{}}/>);
const heading = screen.getByRole('heading', {level: 1});
expect(heading.textContent).not.toContain('undefined');
expect(heading.querySelector('[title*="undefined"]')).toBeNull();
});
});
+109
View File
@@ -0,0 +1,109 @@
import {useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Loader2, Trash2} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {deleteAccount} from '../utils/account.js';
import {clearSession} from '../utils/auth.js';
const sameEmail = (typed, email) => !!email && typed.trim().toLowerCase() === email.toLowerCase();
/**
* "Delete my account": Auth blocks the account at once and removes it after 30 days. The user types the own
* email against a misclick and the password against a borrowed session.
*/
export default function DeleteAccountCard({email}) {
const {t} = useTranslation();
const navigate = useNavigate();
const [typedEmail, setTypedEmail] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const confirm = async () => {
setBusy(true);
try {
const response = await deleteAccount(password);
if (response.status === 204) {
toast.success(t('SETTINGS.DELETE_ACCOUNT.DONE'));
// First away from the settings, which would open the login page without a session. flushSync: the
// router shows the new page before the session change renders, not later in a transition.
await navigate('/', {replace: true, flushSync: true});
clearSession();
return;
}
if (response.status === 403) {
toast.error(t('SETTINGS.DELETE_ACCOUNT.WRONG_PASSWORD'));
} else if (response.status === 409) {
toast.error(t('SETTINGS.DELETE_ACCOUNT.LAST_ADMIN'));
} else if (response.status === 429) {
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
toast.error(t('SETTINGS.DELETE_ACCOUNT.TOO_MANY', {minutes}));
} else if (response.status !== 401) {
toast.error(t('SETTINGS.DELETE_ACCOUNT.ERROR'));
}
} catch (err) {
console.error('Delete account error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
}
setBusy(false);
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Trash2 className="size-4 text-destructive"/>{t('SETTINGS.DELETE_ACCOUNT.TITLE')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">{t('SETTINGS.DELETE_ACCOUNT.EXPLAIN')}</p>
<Dialog onOpenChange={(open) => { if (!open) { setTypedEmail(''); setPassword(''); } }}>
<DialogTrigger asChild>
<Button type="button" variant="outline" className="border-destructive text-destructive">
<Trash2/>{t('SETTINGS.DELETE_ACCOUNT.BUTTON')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('SETTINGS.DELETE_ACCOUNT.DIALOG_TITLE')}</DialogTitle>
<DialogDescription>{t('SETTINGS.DELETE_ACCOUNT.EXPLAIN')}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="deleteEmail">{t('SETTINGS.DELETE_ACCOUNT.TYPE_EMAIL')}</Label>
<Input id="deleteEmail" type="email" autoComplete="off" value={typedEmail}
onChange={(event) => setTypedEmail(event.target.value)}/>
</div>
<div className="space-y-2">
<Label htmlFor="deletePassword">{t('SETTINGS.DELETE_ACCOUNT.PASSWORD')}</Label>
<Input id="deletePassword" type="password" autoComplete="current-password" value={password}
onChange={(event) => setPassword(event.target.value)}/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t('SETTINGS.DELETE_ACCOUNT.CANCEL')}</Button>
</DialogClose>
<Button type="button" variant="destructive" onClick={confirm}
disabled={busy || !password || !sameEmail(typedEmail, email)}>
{busy ? <Loader2 className="animate-spin"/> : <Trash2/>}
{t('SETTINGS.DELETE_ACCOUNT.CONFIRM')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
);
}
+124
View File
@@ -0,0 +1,124 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {createMemoryRouter, RouterProvider} from 'react-router-dom';
import RequireSession from './RequireSession.jsx';
import {toast} from 'sonner';
import DeleteAccountCard from './DeleteAccountCard.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const EMAIL = 'player@example.com';
const deleteAnswers = (answer, routes = {}) => fetchRoutes({'DELETE /api/users/me': answer, ...routes});
// A data router like main.jsx, without the providers: their /me load would change the request order.
const renderCard = () => render(<RouterProvider router={createMemoryRouter([
{element: <RequireSession/>, children: [{path: '/settings', element: <DeleteAccountCard email={EMAIL}/>}]},
{path: '/', element: <p>landing</p>},
{path: '/login', element: <p>login</p>},
], {initialEntries: ['/settings']})}/>);
const openAndFill = (email, password) => {
fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.DELETE_ACCOUNT.BUTTON'}));
fireEvent.change(screen.getByLabelText('SETTINGS.DELETE_ACCOUNT.TYPE_EMAIL'), {target: {value: email}});
fireEvent.change(screen.getByLabelText('SETTINGS.DELETE_ACCOUNT.PASSWORD'), {target: {value: password}});
};
const confirmButton = () => screen.getByRole('button', {name: 'SETTINGS.DELETE_ACCOUNT.CONFIRM'});
describe('DeleteAccountCard', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
deleteAnswers(json(204, {}));
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
it('asks for the own email and a password before it deletes', () => {
renderCard();
openAndFill('someone@else.com', 'secret');
expect(confirmButton().disabled).toBe(true);
fireEvent.change(screen.getByLabelText('SETTINGS.DELETE_ACCOUNT.TYPE_EMAIL'), {target: {value: ' Player@Example.com '}});
expect(confirmButton().disabled).toBe(false);
});
it('sends the password, ends the session and goes to the landing page', async () => {
renderCard();
openAndFill(EMAIL, 'secret');
fireEvent.click(confirmButton());
expect(await screen.findByText('landing')).toBeTruthy();
const [url, init] = fetch.mock.calls[0];
expect(url).toMatch(/\/api\/users\/me$/);
expect(init.method).toBe('DELETE');
expect(JSON.parse(init.body)).toEqual({password: 'secret'});
expect(localStorage.getItem('tbp_session')).toBeNull();
expect(toast.success).toHaveBeenCalledWith('SETTINGS.DELETE_ACCOUNT.DONE');
});
it('keeps the dialog open on a wrong password', async () => {
deleteAnswers(json(403, {}));
renderCard();
openAndFill(EMAIL, 'wrong');
fireEvent.click(confirmButton());
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.DELETE_ACCOUNT.WRONG_PASSWORD'));
expect(confirmButton()).toBeTruthy();
expect(localStorage.getItem('tbp_session')).toBe('1');
});
it('tells the last admin to make another admin first and stays on the page', async () => {
deleteAnswers(json(409, {code: 'LAST_ADMIN'}));
renderCard();
openAndFill(EMAIL, 'secret');
fireEvent.click(confirmButton());
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.DELETE_ACCOUNT.LAST_ADMIN'));
expect(confirmButton()).toBeTruthy();
expect(localStorage.getItem('tbp_session')).toBe('1');
});
it('shows the lock message after too many wrong passwords', async () => {
deleteAnswers({...json(429, {}), headers: new Headers({'Retry-After': '900'})});
renderCard();
openAndFill(EMAIL, 'wrong');
fireEvent.click(confirmButton());
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.DELETE_ACCOUNT.TOO_MANY'));
});
it('opens the login page when the session is gone', async () => {
deleteAnswers(json(401, {}), {'POST /api/auth/refresh': json(401, {})});
renderCard();
openAndFill(EMAIL, 'secret');
fireEvent.click(confirmButton());
expect(await screen.findByText('login')).toBeTruthy();
});
it('shows the network error when Auth does not answer', async () => {
deleteAnswers(() => {
throw new TypeError('Failed to fetch');
});
renderCard();
openAndFill(EMAIL, 'secret');
fireEvent.click(confirmButton());
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('INFO.ERROR.NETWORK_FAILURE'));
});
});
+81
View File
@@ -0,0 +1,81 @@
import {useState} from 'react';
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Layers, Link2Off, Unlink} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import GameStatusBadge from './GameStatusBadge.jsx';
import UnlinkDialog from './UnlinkDialog.jsx';
import FitText from './FitText.jsx';
import {splitGame, unlinkGame} from '../utils/links.js';
import {platformLabel} from '../utils/platforms.js';
/**
* The other editions of this game in the own library, and the buttons to ask for a link (everyone), to split the game
* off its set (everyone, for the own library) or to unlink (auditor). Shown on every game: a request must be possible
* from any game.
*/
export default function EditionsCard({game, auditor, onRequest, onChanged}) {
const {t} = useTranslation();
const [busy, setBusy] = useState(false);
const run = async (call, doneKey) => {
setBusy(true);
try {
const response = await call(game.appid);
if (response.status === 401) return;
if (response.ok) {
toast.success(t(doneKey));
onChanged();
} else {
toast.error(t('LINKS.ERROR'));
}
} catch (err) {
console.error('Edition change error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
return (
<Card className="gap-3 p-4">
{game.editions?.length > 0 && (
<div className="space-y-2 text-sm">
<p className="font-medium">{t('LINKS.ALSO_IN_LIBRARY')}</p>
<ul className="space-y-1">
{game.editions.map((edition) => (
<li key={edition.appid} className="@container flex flex-wrap items-center gap-2">
<Link to={`/library/${edition.appid}`} className="min-w-0 max-w-full hover:text-primary hover:underline">
<FitText text={edition.name}/>
</Link>
{platformLabel(edition) && (
<span className="text-muted-foreground">({platformLabel(edition)})</span>
)}
<GameStatusBadge status={edition.status} compact/>
</li>
))}
</ul>
<p className="text-muted-foreground">{t('LINKS.SHARED_STATUS')}</p>
</div>
)}
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" onClick={onRequest}>
<Layers/>{t(auditor ? 'LINKS.LINK_WITH' : 'LINKS.SAME_AS')}
</Button>
{game.editions?.length > 0 && (
<UnlinkDialog size="sm" icon={Unlink} label={t('LINKS.SPLIT')} title={t('LINKS.SPLIT_CONFIRM_TITLE')}
description={t('LINKS.SPLIT_CONFIRM_TEXT')} confirmLabel={t('LINKS.SPLIT')} disabled={busy}
onConfirm={() => run(splitGame, 'LINKS.SPLIT_DONE')}/>
)}
{auditor && game.linked && (
<UnlinkDialog size="sm" icon={Link2Off} label={t('LINKS.UNLINK')} title={t('LINKS.UNLINK_CONFIRM_TITLE')}
description={t('LINKS.UNLINK_CONFIRM_TEXT')} confirmLabel={t('LINKS.UNLINK')} disabled={busy}
onConfirm={() => run(unlinkGame, 'LINKS.UNLINKED')}/>
)}
</div>
</Card>
);
}
+86
View File
@@ -0,0 +1,86 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
import EditionsCard from './EditionsCard.jsx';
import {splitGame, unlinkGame} from '../utils/links.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
vi.mock('../utils/links.js', () => ({splitGame: vi.fn(), unlinkGame: vi.fn()}));
const edition = {appid: 221, name: 'Game (PS5)', platform: 'PS5', source: 'PSN', status: 'PLAYING'};
const renderCard = (game, onChanged = vi.fn()) => {
render(<MemoryRouter><EditionsCard game={game} auditor={false} onRequest={vi.fn()} onChanged={onChanged}/></MemoryRouter>);
return onChanged;
};
const confirmSplit = () => {
fireEvent.click(screen.getByRole('button', {name: 'LINKS.SPLIT'}));
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', {name: 'LINKS.SPLIT'}));
};
describe('EditionsCard', () => {
beforeEach(() => vi.clearAllMocks());
afterEach(cleanup);
it('showsTheSplitButtonAndTheSharedHintOnlyWithEditions', () => {
renderCard({appid: 220, editions: []});
expect(screen.queryByText('LINKS.SPLIT')).toBeNull();
expect(screen.queryByText('LINKS.SHARED_STATUS')).toBeNull();
cleanup();
renderCard({appid: 220, editions: [edition]});
expect(screen.getByText('LINKS.SPLIT')).toBeTruthy();
expect(screen.getByText('LINKS.SHARED_STATUS')).toBeTruthy();
});
it('asksBeforeSplittingAndSendsNothingOnCancel', () => {
renderCard({appid: 220, editions: [edition]});
fireEvent.click(screen.getByRole('button', {name: 'LINKS.SPLIT'}));
expect(screen.getByText('LINKS.SPLIT_CONFIRM_TITLE')).toBeTruthy();
fireEvent.click(screen.getByRole('button', {name: 'DIALOG.CANCEL'}));
expect(splitGame).not.toHaveBeenCalled();
});
it('splitsTheGameAndReloads', async () => {
splitGame.mockResolvedValue({ok: true, status: 204});
const onChanged = renderCard({appid: 220, editions: [edition]});
confirmSplit();
await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(splitGame).toHaveBeenCalledWith(220);
expect(toast.success).toHaveBeenCalledWith('LINKS.SPLIT_DONE');
});
it('showsAnErrorAndDoesNotReloadWhenTheCallFails', async () => {
splitGame.mockResolvedValue({ok: false, status: 404});
const onChanged = renderCard({appid: 220, editions: [edition]});
confirmSplit();
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('LINKS.ERROR'));
expect(onChanged).not.toHaveBeenCalled();
});
it('asksBeforeUnlinkingAndUnlinksOnConfirm', async () => {
unlinkGame.mockResolvedValue({ok: true, status: 204});
const onChanged = vi.fn();
render(<MemoryRouter><EditionsCard game={{appid: 220, linked: true, editions: []}} auditor onRequest={vi.fn()}
onChanged={onChanged}/></MemoryRouter>);
fireEvent.click(screen.getByRole('button', {name: 'LINKS.UNLINK'}));
expect(unlinkGame).not.toHaveBeenCalled();
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', {name: 'LINKS.UNLINK'}));
await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(unlinkGame).toHaveBeenCalledWith(220);
expect(toast.success).toHaveBeenCalledWith('LINKS.UNLINKED');
});
});
+228
View File
@@ -0,0 +1,228 @@
import {useEffect, useId, useRef} from 'react';
import {cn} from '@/lib/utils';
import useReducedMotion from '../hooks/useReducedMotion.js';
import {watchParallax} from '../utils/parallax.js';
import EffectCanvas from './EffectCanvas.jsx';
import {LOGO_COLORS} from './Logo.jsx';
export const EFFECTS = [
'triangles', 'starfield', 'pixels', 'aurora', 'mesh', 'grid', 'waves', 'bokeh', 'equalizer',
'synthwave', 'fireflies', 'snow', 'sakura', 'nebula', 'matrix', 'constellation',
];
const range = (count) => Array.from({length: count}, (_, i) => i);
// Four layers of triangles on a 1600x900 canvas: far and small, mid, near and big, and a wireframe. A fixed
// pattern, so the picture is the same on every visit.
const TRIANGLE_LAYERS = [
{count: 8, min: 40, span: 30, opacity: 0.05, step: 0.01},
{count: 8, min: 80, span: 50, opacity: 0.08, step: 0.02},
{count: 6, min: 140, span: 80, opacity: 0.12, step: 0.03},
{count: 5, min: 90, span: 70, opacity: 0.65, step: 0},
];
const TRIANGLES = TRIANGLE_LAYERS.map(({count, min, span, opacity, step}, group) => range(count).map((i) => {
const x = (i * 211 + group * 97) % 1600;
const y = (i * 137 + group * 263) % 900;
const size = min + ((i * 53 + group * 29) % span);
return {
points: `${x},${y - size} ${x - size * 0.87},${y + size / 2} ${x + size * 0.87},${y + size / 2}`,
rotate: `rotate(${i * 40} ${x} ${y})`,
opacity: opacity + (i % 4) * step,
};
}));
// One period is 1600 units; two periods make the band 2 widths wide, so a move of one width loops without a jump.
const WAVE = 'M0 100 C200 30 600 30 800 100 C1000 170 1400 170 1600 100 C1800 30 2200 30 2400 100 '
+ 'C2600 170 3000 170 3200 100 L3200 200 L0 200 Z';
// Glint spots per engine: [x %, y %, delay s].
const GLINTS = {
triangles: [[18, 20, 0], [44, 60, -0.8], [72, 28, -1.6], [88, 70, -2.4]],
starfield: [[12, 20, 0], [33, 62, -0.4], [51, 14, -0.8], [68, 48, -1.2], [84, 26, -1.6], [92, 78, -2], [22, 84, -2.4], [60, 90, -2.8]],
pixels: [[20, 30, 0], [55, 60, -1], [82, 22, -2]],
mesh: [[22, 25, 0], [75, 35, -1], [48, 75, -2]],
grid: [[10, 8, 0], [25, 28, -0.5], [42, 14, -1], [58, 30, -1.5], [76, 10, -2], [90, 26, -2.5]],
waves: [[14, 68, 0], [32, 64, -0.6], [50, 70, -1.2], [68, 64, -1.8], [86, 68, -2.4]],
bokeh: [[16, 30, 0], [52, 70, -1], [84, 24, -2]],
synthwave: [[10, 8, 0], [28, 22, -0.6], [64, 12, -1.2], [80, 28, -1.8], [92, 10, -2.4]],
nebula: [[18, 22, 0], [78, 18, -0.9], [24, 78, -1.8], [86, 72, -2.7]],
};
// [smallest, spread] of a bokeh circle in cqmin, per tier: far, mid, near.
const BOKEH_SIZES = [[6, 7], [12, 13], [26, 15]];
const [violet, blue, teal] = LOGO_COLORS.map(([name, hex]) => `var(${name}, ${hex})`);
const GLINT_COLORS = [violet, blue, teal, 'var(--primary)'];
const COLORS = ['var(--primary)', teal, violet, blue];
const color = (i) => COLORS[(i + Math.floor(i / 4)) % 4];
const glints = (effect, colored = false) => GLINTS[effect].map(([x, y, d], i) => (
<span key={`g${i}`} className="fx-glint"
style={{'--x': `${x}%`, '--y': `${y}%`, '--d': `${d}s`, ...(colored && {'--glint': GLINT_COLORS[i % 4]})}}/>
));
/**
* The markup of an animated page or banner background (CSS in effects.css). Fills its parent: pass `absolute inset-0`
* or `fixed inset-0`. Nothing here is read by a screen reader. Its layers follow the mouse and the phone tilt unless
* `parallax` is false or motion is reduced.
*
* @param effect one of EFFECTS; anything else renders nothing
*/
export default function EffectBackground({effect, className, parallax = true}) {
const ref = useRef(null);
const reduce = useReducedMotion();
const gradientId = useId().replace(/:/g, '');
useEffect(() => (parallax && !reduce && ref.current ? watchParallax(ref.current) : undefined), [parallax, reduce]);
if (!EFFECTS.includes(effect)) return null;
return (
<div ref={ref} aria-hidden="true" data-effect={effect}
className={cn('fx fx-bg pointer-events-none overflow-hidden', `fx-bg-${effect}`, className)}>
{shapes(effect, gradientId)}
</div>
);
}
// Wanderers (fireflies) and fallers (snow, sakura) are <span><i/></span>; every value comes from (i * prime) % n.
const wanderer = (i) => (
<span key={i} style={{
'--x': (i * 29 + 7) % 90, '--y': (i * 41 + 12) % 80, '--c': color(i),
'--dur': `${7 + ((i * 3) % 7)}s`, '--dur2': `${5 + ((i * 5) % 7)}s`, '--blink': `${1.8 + ((i * 7) % 12) / 5}s`,
'--delay': `${-i * 1.7}s`,
}}><i/></span>
);
const faller = (slow) => (i) => (
<span key={i} style={{
'--x': (i * 37 + 5) % 97, '--y': (i * 53 + 7) % 100,
'--dur': `${[26, 18, 12][i % 3] * slow + (i % 4)}s`, '--sway': `${3 + ((i * 5) % 4)}s`,
'--delay': `${-((i * 7.3) % 26)}s`,
}}><i/></span>
);
function shapes(effect, gradientId) {
switch (effect) {
case 'triangles':
return (
<>
{TRIANGLES.map((triangles, layer) => (
<svg key={layer} className="fx-bleed" viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice">
{triangles.map((triangle) => (
<polygon key={triangle.points} points={triangle.points} transform={triangle.rotate}
opacity={triangle.opacity}/>
))}
</svg>
))}
{glints(effect)}
</>
);
case 'starfield':
return (
<>
{range(3).map((i) => <span key={i} className="fx-bleed fx-tile"/>)}
{glints(effect, true)}
<span className="fx-shoot"/>
</>
);
case 'pixels':
return (
<>
{range(18).map((i) => (
<span key={i} className="fx-px" style={{
'--x': (i * 37) % 100, '--y': (i * 53 + 11) % 90,
'--dur': `${[40, 30, 20][i % 3] + ((i * 7) % 5)}s`, '--delay': `${-i * 3.1}s`,
}}/>
))}
{glints(effect)}
</>
);
case 'aurora':
return (
<>
<span className="fx-bleed fx-tile"/>
{range(3).map((i) => <div key={i} className="fx-ribbon"><div/></div>)}
</>
);
case 'mesh':
return (
<>
{range(5).map((i) => <div key={i} className="fx-blob"/>)}
{glints(effect)}
</>
);
case 'grid':
return (
<>
<div className="fx-sky"/>
<div className="fx-floor"/>
<div className="fx-horizon"/>
{glints(effect)}
</>
);
case 'waves':
return (
<>
{range(4).map((i) => (
<div key={i} className="fx-band">
<svg viewBox="0 0 3200 200" preserveAspectRatio="none">
<defs>
<linearGradient id={`${gradientId}w${i}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="currentColor" stopOpacity="0.5"/>
<stop offset="1" stopColor="currentColor" stopOpacity="0.2"/>
</linearGradient>
</defs>
<path d={WAVE} fill={`url(#${gradientId}w${i})`}/>
</svg>
</div>
))}
{glints(effect)}
</>
);
case 'equalizer':
return range(16).map((i) => <span key={i}/>);
case 'bokeh':
return (
<>
{range(16).map((i) => (
<span key={i} className={cn('fx-bk', i % 4 === 3 && 'fx-ring')} style={{
'--x': (i * 29 + 7) % 90, '--y': (i * 47 + 10) % 90,
'--s': BOKEH_SIZES[i % 3][0] + ((i * 13) % BOKEH_SIZES[i % 3][1]),
'--c': color(i),
'--dur': `${30 + ((i * 5) % 21)}s`, '--delay': `${-i * 4.3}s`,
}}/>
))}
{glints(effect)}
</>
);
case 'synthwave':
return (
<>
<div className="fx-sky"/>
<div className="fx-halo"/>
<div className="fx-sun"/>
<div className="fx-floor"/>
{glints(effect)}
</>
);
case 'fireflies':
return range(14).map(wanderer);
case 'snow':
return range(24).map(faller(1));
case 'sakura':
return range(24).map(faller(1.25));
case 'nebula':
return (
<>
<span className="fx-bleed fx-tile"/>
<div className="fx-disc"/>
<div className="fx-disc"/>
<div className="fx-core"/>
{glints(effect)}
</>
);
case 'matrix':
case 'constellation':
return <EffectCanvas effect={effect}/>;
}
}
+86
View File
@@ -0,0 +1,86 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, render} from '@testing-library/react';
import EffectBackground, {EFFECTS} from './EffectBackground.jsx';
// jsdom has no canvas: the canvas engines render, draw nothing.
beforeEach(() => vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
document.documentElement.removeAttribute('data-reduce-motion');
});
const pointerListeners = (spy) => spy.mock.calls.filter(([type]) => type === 'pointermove');
describe('EffectBackground', () => {
it.each(EFFECTS)('renders %s hidden from screen readers with its engine class and shapes', (effect) => {
const {container} = render(<EffectBackground effect={effect} className="absolute inset-0"/>);
const root = container.querySelector('[data-effect]');
expect(root.dataset.effect).toBe(effect);
expect(root.getAttribute('aria-hidden')).toBe('true');
expect(root.classList.contains(`fx-bg-${effect}`)).toBe(true);
expect(root.classList.contains('fx')).toBe(true);
expect(root.classList.contains('absolute')).toBe(true);
expect(root.children.length).toBeGreaterThan(0);
});
it('renders nothing for an unknown effect', () => {
const {container} = render(<EffectBackground effect="plasma"/>);
expect(container.firstChild).toBeNull();
});
it('attaches one pointermove listener for the mounted effect and removes it on unmount', () => {
const add = vi.spyOn(window, 'addEventListener');
const remove = vi.spyOn(window, 'removeEventListener');
const {unmount} = render(<EffectBackground effect="snow"/>);
expect(pointerListeners(add)).toHaveLength(1);
unmount();
expect(pointerListeners(remove)).toHaveLength(1);
});
it('attaches nothing with parallax off', () => {
const add = vi.spyOn(window, 'addEventListener');
render(<EffectBackground effect="snow" parallax={false}/>);
expect(pointerListeners(add)).toHaveLength(0);
});
it('attaches nothing while the system asks for reduced motion', () => {
vi.stubGlobal('matchMedia', () => ({matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn()}));
const add = vi.spyOn(window, 'addEventListener');
render(<EffectBackground effect="snow"/>);
expect(pointerListeners(add)).toHaveLength(0);
});
it('detaches when the reduce animations setting turns on while mounted', async () => {
const remove = vi.spyOn(window, 'removeEventListener');
render(<EffectBackground effect="snow"/>);
await act(async () => {
document.documentElement.setAttribute('data-reduce-motion', '');
await Promise.resolve();
});
expect(pointerListeners(remove)).toHaveLength(1);
});
// REVIEW-FINDING: COLORS/GLINT_COLORS pass var(--logo-*) without a fallback as inline --c/--glint; with the default
// violet accent no --logo-* is set, so 12/16 bokeh circles, 10/14 fireflies and 6/8 starfield glints render nothing.
it.each(['bokeh', 'fireflies', 'starfield'])('reviewFinding_%sInlineLogoColorsHaveAFallback', (effect) => {
const {container} = render(<EffectBackground effect={effect}/>);
const colors = [...container.querySelectorAll('[style]')]
.flatMap((el) => [el.style.getPropertyValue('--c'), el.style.getPropertyValue('--glint')])
.filter((value) => value.includes('--logo-'));
expect(colors.length).toBeGreaterThan(0);
expect(colors.filter((value) => !/var\(--logo-[a-z]+,\s*#/.test(value))).toEqual([]);
});
});
+75
View File
@@ -0,0 +1,75 @@
import {useEffect, useRef} from 'react';
import useInView from '../hooks/useInView.js';
import useReducedMotion from '../hooks/useReducedMotion.js';
import {CANVAS_EFFECTS, readPalette, seededRandom} from '../utils/canvasEffects.js';
/** Canvas background engine. Animates only on screen and with motion allowed, else shows one static frame. */
export default function EffectCanvas({effect}) {
const ref = useRef(null);
const reduce = useReducedMotion();
const inView = useInView(ref, true);
// Setup runs per effect only; the simulation state lives in a ref so scrolling or a motion change keeps it.
const run = useRef(null);
useEffect(() => {
const canvas = ref.current;
const context = canvas.getContext?.('2d');
const engine = CANVAS_EFFECTS[effect];
if (!context || !engine) return undefined;
const random = seededRandom(7);
const current = {engine, random, state: undefined, palette: readPalette(canvas)};
const paint = () => engine.draw(context, current.state, current.palette);
current.paint = paint;
run.current = current;
const size = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
const width = canvas.clientWidth;
const height = canvas.clientHeight;
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
context.setTransform(dpr, 0, 0, dpr, 0, 0);
current.state = engine.create(width, height, random);
paint();
};
size();
const resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(size);
resizeObserver?.observe(canvas);
const themeObserver = new MutationObserver(() => {
current.palette = readPalette(canvas);
paint();
});
themeObserver.observe(document.documentElement, {
attributes: true, attributeFilter: ['class', 'data-accent', 'data-skin'],
});
return () => {
run.current = null;
resizeObserver?.disconnect();
themeObserver.disconnect();
};
}, [effect]);
useEffect(() => {
const current = run.current;
if (!current || reduce || !inView) return undefined;
const {engine, random, paint} = current;
let handle = 0;
let last = performance.now();
const frame = (now) => {
if (now - last >= 1000 / engine.fps) {
engine.step(current.state, Math.min((now - last) / 1000, 0.1), random);
paint();
last = now;
}
handle = requestAnimationFrame(frame);
};
handle = requestAnimationFrame(frame);
return () => cancelAnimationFrame(handle);
}, [effect, reduce, inView]);
return <canvas ref={ref} className="fx-bleed" style={{'--depth': 0.5}}/>;
}
+78
View File
@@ -0,0 +1,78 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, render} from '@testing-library/react';
import EffectCanvas from './EffectCanvas.jsx';
const context = new Proxy({}, {get: (target, name) => (target[name] ??= vi.fn()), set: () => true});
let callbacks;
let raf;
let cancel;
beforeEach(() => {
callbacks = [];
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
raf = vi.fn(() => 1);
cancel = vi.fn();
vi.stubGlobal('requestAnimationFrame', raf);
vi.stubGlobal('cancelAnimationFrame', cancel);
vi.stubGlobal('IntersectionObserver', class {
constructor(callback) { callbacks.push(callback); }
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
});
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
for (const name of Object.keys(context)) delete context[name];
});
const reduced = () => vi.stubGlobal('matchMedia', () => ({matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn()}));
describe('EffectCanvas', () => {
it('requests animation frames while motion is allowed', () => {
render(<EffectCanvas effect="constellation"/>);
expect(raf).toHaveBeenCalledTimes(1);
expect(context.clearRect).toHaveBeenCalled();
});
it('draws one static frame and runs no loop under reduced motion', () => {
reduced();
render(<EffectCanvas effect="matrix"/>);
expect(context.clearRect).toHaveBeenCalled();
expect(raf).not.toHaveBeenCalled();
});
it('stops the loop when the canvas leaves the screen', () => {
const {container} = render(<EffectCanvas effect="matrix"/>);
expect(raf).toHaveBeenCalledTimes(1);
act(() => callbacks[0]([{target: container.querySelector('canvas'), isIntersecting: false}]));
expect(cancel).toHaveBeenCalled();
expect(raf).toHaveBeenCalledTimes(1);
});
it('cancels the loop on unmount', () => {
const {unmount} = render(<EffectCanvas effect="matrix"/>);
cancel.mockClear();
unmount();
expect(cancel).toHaveBeenCalled();
});
it('renders the canvas without a 2d context', () => {
HTMLCanvasElement.prototype.getContext.mockReturnValue(null);
const {container} = render(<EffectCanvas effect="matrix"/>);
expect(container.querySelector('canvas')).not.toBeNull();
expect(raf).not.toHaveBeenCalled();
});
});
+35
View File
@@ -0,0 +1,35 @@
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Library} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import HintText from './HintText.jsx';
/**
* The dashboard's answer to an empty library.
*
* @param reason 'NO_SOURCE' (nothing linked yet) or 'STEAM_PRIVATE' (Steam is linked and synced, but found no games)
*/
export default function EmptyLibraryCard({reason}) {
const {t} = useTranslation();
return (
<Card className="items-center gap-3 p-6 text-center">
<Library className="size-10 text-muted-foreground"/>
<h2 className="font-semibold">{t('DASHBOARD.EMPTY.TITLE')}</h2>
<p className="max-w-md text-sm text-muted-foreground">
{reason === 'STEAM_PRIVATE'
? <HintText text={t('LIBRARY.EMPTY_PRIVATE')}/>
: t('DASHBOARD.EMPTY.NO_SOURCE')}
</p>
<div className="flex flex-wrap justify-center gap-2">
{reason === 'NO_SOURCE' && (
<Button asChild><Link to="/settings?tab=connections">{t('DASHBOARD.EMPTY.LINK')}</Link></Button>
)}
<Button asChild variant={reason === 'NO_SOURCE' ? 'outline' : 'default'}>
<Link to="/import">{t('DASHBOARD.EMPTY.IMPORT')}</Link>
</Button>
</div>
</Card>
);
}
+34
View File
@@ -0,0 +1,34 @@
// @vitest-environment jsdom
import {readFileSync} from 'node:fs';
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import EmptyLibraryCard from './EmptyLibraryCard.jsx';
// The real English text, so the test sees the privacy link.
const texts = JSON.parse(readFileSync('public/i18n/en.json', 'utf-8'));
vi.mock('react-i18next', () => ({
useTranslation: () => ({t: (key) => key.split('.').reduce((node, part) => node?.[part], texts) ?? key}),
}));
const renderCard = (reason) => render(<MemoryRouter><EmptyLibraryCard reason={reason}/></MemoryRouter>);
describe('EmptyLibraryCard', () => {
afterEach(cleanup);
it('NO_SOURCE offers to link a platform and to import', () => {
renderCard('NO_SOURCE');
expect(screen.getByRole('link', {name: 'Link Steam / PlayStation'}).getAttribute('href')).toBe('/settings?tab=connections');
expect(screen.getByRole('link', {name: 'Import games'}).getAttribute('href')).toBe('/import');
});
it('STEAM_PRIVATE links the Steam privacy page and still offers the import', () => {
renderCard('STEAM_PRIVATE');
const link = screen.getByRole('link', {name: 'Steam privacy settings'});
expect(link.getAttribute('href')).toBe('https://steamcommunity.com/my/edit/settings');
expect(screen.getByRole('link', {name: 'Import games'})).toBeTruthy();
expect(screen.queryByRole('link', {name: 'Link Steam / PlayStation'})).toBeNull();
});
});
+68
View File
@@ -0,0 +1,68 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {Flag, Loader2} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import useSetStatus from '../hooks/useSetStatus.js';
import {dismissFinishedHint} from '../utils/backlog.js';
import {formatPlaytime} from '../utils/games.js';
/**
* Core's guess that a game is finished: the ending achievement is unlocked, or the playtime passed the normal time
* to beat. Only a question; the user decides.
*
* @param onChanged gets the saved backlog entry after "Mark as finished"
*/
export default function FinishedHint({game, onChanged, onDismissed}) {
const {t} = useTranslation();
const {setStatus, busy: saving} = useSetStatus(game.appid, {onChanged});
const [dismissing, setDismissing] = useState(false);
const busy = saving || dismissing;
const hint = game.finishedHint;
// A status saved elsewhere on the page answers the question too.
if (!hint || (game.status !== 'BACKLOG' && game.status !== 'PLAYING')) return null;
const notYet = async () => {
setDismissing(true);
try {
const response = await dismissFinishedHint(game.appid, hint.type);
if (response.status === 401) return;
if (!response.ok) {
toast.error(t('BACKLOG.SAVE_FAILED'));
return;
}
onDismissed();
} catch (err) {
console.error('finished hint error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setDismissing(false);
}
};
const text = hint.type === 'ENDING'
? t('GAME_DETAIL.FINISHED_ENDING', {achievement: hint.achievement})
: t('GAME_DETAIL.FINISHED_PLAYTIME', {
played: formatPlaytime(game.playtimeMinutes),
normal: formatPlaytime(game.timeToBeat?.normallyMinutes ?? 0),
});
return (
<Card className="gap-3 border-emerald-500/40 bg-emerald-500/5 p-4 sm:flex-row sm:items-center sm:gap-4">
<div className="flex flex-1 items-start gap-3 sm:items-center">
<Flag className="size-5 shrink-0 text-emerald-500"/>
<p className="text-sm">{text}</p>
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button size="sm" variant="outline" onClick={notYet} disabled={busy}>{t('GAME_DETAIL.NOT_YET')}</Button>
<Button size="sm" onClick={() => setStatus('FINISHED')} disabled={busy}>
{busy && <Loader2 className="animate-spin"/>}
{t('GAME_DETAIL.SET_FINISHED')}
</Button>
</div>
</Card>
);
}
+100
View File
@@ -0,0 +1,100 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import RequireSession from './RequireSession.jsx';
import FinishedHint from './FinishedHint.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
vi.mock('../hooks/usePointsCheck.js', () => ({default: () => vi.fn()}));
const ending = {appid: 322110, status: 'PLAYING', playtimeMinutes: 600, timeToBeat: {normallyMinutes: 500},
finishedHint: {type: 'ENDING', achievement: 'Survivor'}};
const renderHint = (props) => render(
<MemoryRouter initialEntries={['/library/322110']}>
<Routes>
<Route element={<RequireSession/>}>
<Route path="/library/:appid" element={<FinishedHint game={ending} onChanged={() => {}} onDismissed={() => {}} {...props}/>}/>
</Route>
<Route path="/login" element={<p>login page</p>}/>
</Routes>
</MemoryRouter>);
const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT');
describe('FinishedHint', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
fetchRoutes({
'PUT /api/library/games/322110/status': (url, init) =>
json(200, {...JSON.parse(init.body), rating: null, notes: null, priority: null}),
'PUT /api/library/games/322110/finished-hint': json(204),
});
});
afterEach(() => {
cleanup();
localStorage.clear();
});
it('shows nothing without a hint', () => {
renderHint({game: {...ending, finishedHint: null}});
expect(screen.queryByRole('button')).toBeNull();
});
it.each(['FINISHED', 'COMPLETED', 'DROPPED'])('shows nothing once the status is %s', (status) => {
renderHint({game: {...ending, status}});
expect(screen.queryByRole('button')).toBeNull();
});
it('names the ending achievement', () => {
renderHint();
expect(screen.getByText('GAME_DETAIL.FINISHED_ENDING')).toBeTruthy();
});
it('explains the playtime hint', () => {
renderHint({game: {...ending, finishedHint: {type: 'PLAYTIME', achievement: null}}});
expect(screen.getByText('GAME_DETAIL.FINISHED_PLAYTIME')).toBeTruthy();
});
it('marks the game as finished', async () => {
const onChanged = vi.fn();
renderHint({onChanged});
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.SET_FINISHED'}));
await waitFor(() => expect(onChanged).toHaveBeenCalledWith(expect.objectContaining({status: 'FINISHED'})));
const [url, init] = puts()[0];
expect(url).toContain('/api/library/games/322110/status');
expect(JSON.parse(init.body)).toEqual({status: 'FINISHED'});
});
it('sends "Not yet" with the type of the hint', async () => {
const onDismissed = vi.fn();
renderHint({game: {...ending, finishedHint: {type: 'PLAYTIME', achievement: null}}, onDismissed});
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.NOT_YET'}));
await waitFor(() => expect(onDismissed).toHaveBeenCalled());
const [url, init] = puts()[0];
expect(url).toContain('/api/library/games/322110/finished-hint');
expect(JSON.parse(init.body)).toEqual({dismissed: 'PLAYTIME'});
});
it('ends the session on a 401', async () => {
fetchRoutes({
'PUT /api/library/games/322110/finished-hint': json(401, {}),
'POST /api/auth/refresh': json(401, {}),
});
renderHint();
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.NOT_YET'}));
expect(await screen.findByText('login page')).toBeTruthy();
});
});
+18
View File
@@ -0,0 +1,18 @@
import {cn} from '@/lib/utils';
const WRAP_ON_DESKTOP = 'md:whitespace-normal md:overflow-visible md:[font-size:1em] md:break-words';
/**
* A name that shrinks to 0.875rem with its length, then ends with "...". The parent sets the font size, so keep
* text-* classes off this element; the nearest `@container` ancestor sets the width. Put @container only on an element
* whose width does not come from its content, or it collapses to 0.
* @param wrapOnDesktop from md up: full size and wrapping, no shrink or "..."
*/
export default function FitText({text, wrapOnDesktop, className}) {
return (
<span className={cn('fit-text', wrapOnDesktop && WRAP_ON_DESKTOP, className)}
style={{'--fit-chars': Math.max(text?.length ?? 0, 1)}} title={text || undefined}>
{text}
</span>
);
}
+25
View File
@@ -0,0 +1,25 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import FitText from './FitText.jsx';
afterEach(cleanup);
describe('FitText', () => {
it('shows the text, the full text as title, and its length for the CSS', () => {
render(<FitText text="Hollow Knight"/>);
const element = screen.getByText('Hollow Knight');
expect(element.title).toBe('Hollow Knight');
expect(element.style.getPropertyValue('--fit-chars')).toBe('13');
expect(element.className).toContain('fit-text');
});
it('renders an empty span without title for missing text', () => {
const {container} = render(<FitText/>);
const element = container.querySelector('span');
expect(element.hasAttribute('title')).toBe(false);
expect(element.style.getPropertyValue('--fit-chars')).toBe('1');
});
});
+36
View File
@@ -0,0 +1,36 @@
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ArrowRight, UserCheck} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import FollowingList from './people/FollowingList.jsx';
import useFollowing from '../hooks/useFollowing.js';
/**
* Dashboard tile: the first people of the following list. Loads its own data, so a dashboard without the tile pays nothing.
*
* @param limit how many people the tile size shows
*/
export default function FollowingCard({limit}) {
const {t} = useTranslation();
const {following, failed, unfollow} = useFollowing();
return (
<Card className="gap-0 py-0">
<div className="flex flex-wrap items-center justify-between gap-x-3 border-b px-5 py-4">
<h2 className="flex items-center gap-2 font-semibold">
<UserCheck className="size-4 text-primary"/>
{t('FOLLOW.TITLE')}
</h2>
<Button asChild variant="ghost" size="sm">
<Link to="/community?tab=following">{t('FOLLOW.SHOW_ALL')}<ArrowRight/></Link>
</Button>
</div>
<div className="px-5 py-2">
{failed && <p className="py-3 text-sm text-destructive">{t('FOLLOW.LOAD_ERROR')}</p>}
{following && <FollowingList entries={following} limit={limit} onUnfollow={unfollow} compact/>}
</div>
</Card>
);
}
+32
View File
@@ -0,0 +1,32 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, screen} from '@testing-library/react';
import FollowingCard from './FollowingCard.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
afterEach(cleanup);
describe('FollowingCard', () => {
it('showsAtMostTheLimitOfPeopleAndLinksToTheFollowingPage', async () => {
const following = ['A', 'B', 'C', 'D'].map((displayName, i) => ({id: i, displayName, lastActivityAt: null}));
fetchRoutes({'GET /api/follows': json(200, {followers: [], following})});
renderWithProviders(<FollowingCard limit={3}/>);
await screen.findByRole('link', {name: 'A'});
expect(screen.queryByRole('link', {name: 'D'})).toBeNull();
expect(screen.getAllByRole('button', {name: 'FOLLOW.UNFOLLOW'})).toHaveLength(3);
expect(screen.getByRole('link', {name: /FOLLOW.SHOW_ALL/}).getAttribute('href')).toBe('/community?tab=following');
});
it('showsTheEmptyStateWhenNobodyIsFollowed', async () => {
fetchRoutes({'GET /api/follows': json(200, {followers: [], following: []})});
renderWithProviders(<FollowingCard limit={3}/>);
expect(await screen.findByText('FOLLOW.NO_FOLLOWING')).toBeTruthy();
});
});
+4 -2
View File
@@ -2,13 +2,15 @@ import {Label} from '@/components/ui/label';
/**
* Label, input and the translated validation error of one form field.
* `error` is a react-hook-form field error whose message is an i18n key.
* `error` is a react-hook-form field error whose message is an i18n key. `description` is helper text under the input;
* the input points to it with aria-describedby="<id>-description".
*/
export default function FormField({id, label, error, t, children}) {
export default function FormField({id, label, error, description, t, children}) {
return (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
{children}
{description && <p id={`${id}-description`} className="text-xs text-muted-foreground">{description}</p>}
{error && <p className="text-sm text-destructive">{t(error.message)}</p>}
</div>
);
+184
View File
@@ -0,0 +1,184 @@
import {useMemo, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {ShieldOff, Trophy} from 'lucide-react';
import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
import {formatDate} from '../utils/format.js';
const STEAM_SET = 'steam';
const TROPHY_COLORS = {
BRONZE: 'text-amber-700',
SILVER: 'text-slate-400',
GOLD: 'text-yellow-500',
PLATINUM: 'text-cyan-400',
};
const formatUnlockedAt = (instant, locale) => {
if (!instant) return '';
try {
return formatDate(instant, locale, {year: 'numeric', month: 'short', day: 'numeric'});
} catch {
return '';
}
};
// Only Steam icons can be repaired: the broken-image report goes to the Steam icon store.
function AchievementIcon({appid, src, unlocked, steam}) {
const [failed, setFailed] = useState(false);
if (!src || failed) return <div className="size-12 rounded-md bg-muted"/>;
return (
<img src={src} alt="" className={cn('size-12 rounded-md', !unlocked && 'grayscale')}
onError={() => {
if (steam) reportBrokenImage(appid, ACHIEVEMENT_ICONS);
setFailed(true);
}}/>
);
}
// The base game (no group) first, then the DLC groups in their order; inside a group the earned ones first.
function sortAchievements(achievements) {
return [...achievements].sort((a, b) => {
if ((a.group ?? '') !== (b.group ?? '')) return (a.group ?? '').localeCompare(b.group ?? '');
if (a.unlocked !== b.unlocked) return a.unlocked ? -1 : 1;
if (a.unlocked && b.unlocked) {
return (b.unlockedAt ?? '').localeCompare(a.unlockedAt ?? '');
}
return (a.displayName ?? a.apiName).localeCompare(b.displayName ?? b.apiName);
});
}
function SetHeader({set}) {
const {t} = useTranslation();
const steam = set.id === STEAM_SET;
return (
<div className="flex items-baseline justify-between gap-3">
<h2 className="flex min-w-0 items-center gap-2 text-lg font-semibold">
<Trophy className="size-4 shrink-0 text-amber-500"/>
<span className="truncate">
{steam ? t('GAME_DETAIL.ACHIEVEMENTS') : t('GAME_DETAIL.TROPHIES', {platform: set.platform})}
</span>
</h2>
<span className="text-sm text-muted-foreground tabular-nums">{set.unlocked} / {set.total}</span>
</div>
);
}
function SetSummary({set}) {
const {t} = useTranslation();
const percent = set.perfect ? 100 : set.total === 0 ? 0 : Math.round((set.unlocked * 1000) / set.total) / 10;
const stats = [
[t('GAME_DETAIL.UNLOCKED'), set.unlocked],
[t('GAME_DETAIL.REMAINING'), Math.max(0, set.total - set.unlocked)],
[t('GAME_DETAIL.COMPLETION'), `${percent}%`],
];
return (
<>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-all"
style={{width: `${Math.min(100, percent)}%`}}/>
</div>
<div className="grid grid-cols-3 gap-3 text-center">
{stats.map(([label, value]) => (
<div key={label} className="rounded-lg bg-muted/50 p-3">
<div className="text-2xl font-bold tabular-nums">{value}</div>
<div className="text-xs text-muted-foreground">{label}</div>
</div>
))}
</div>
</>
);
}
function AchievementItem({game, achievement}) {
const {t, i18n} = useTranslation();
const steam = achievement.kind !== 'PSN';
const icon = achievement.unlocked ? achievement.iconUrl : achievement.iconGrayUrl;
const name = achievement.displayName
?? (achievement.hidden && !steam ? t('GAME_DETAIL.HIDDEN_TROPHY') : achievement.apiName);
const trophyLabel = achievement.trophyType && t(`GAME_DETAIL.TROPHY_TYPE.${achievement.trophyType}`);
return (
<li className={cn('flex items-start gap-3 rounded-xl border bg-card p-3',
achievement.unlocked ? 'border-primary/40' : 'opacity-60')}>
<AchievementIcon key={icon} appid={game.appid} src={icon} unlocked={achievement.unlocked} steam={steam}/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
{achievement.trophyType && (
<Trophy className={cn('size-3.5 shrink-0', TROPHY_COLORS[achievement.trophyType])}
aria-label={trophyLabel}>
<title>{trophyLabel}</title>
</Trophy>
)}
<div className="truncate font-medium">{name}</div>
{achievement.group && (
<span className="shrink-0 rounded bg-muted px-1.5 text-[10px] font-semibold">
{t('GAME_DETAIL.DLC')}
</span>
)}
</div>
{achievement.description && (
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{achievement.description}</div>
)}
{achievement.rarity != null && (
<div className="mt-1 text-xs text-muted-foreground tabular-nums">
{t('GAME_DETAIL.RARITY', {percent: Math.round(achievement.rarity * 10) / 10})}
</div>
)}
{achievement.unlocked && achievement.unlockedAt && (
<div className="mt-1 text-xs text-primary tabular-nums">
{formatUnlockedAt(achievement.unlockedAt, i18n.language)}
</div>
)}
</div>
</li>
);
}
function AchievementSet({game, set, achievements}) {
const {t} = useTranslation();
const sorted = useMemo(() => sortAchievements(achievements), [achievements]);
const steam = set.id === STEAM_SET;
if (steam && !set.visible) {
return (
<Card className="items-center py-10 text-center text-sm text-muted-foreground">
<ShieldOff className="size-8"/>
{t('GAME_DETAIL.STATS_HIDDEN')}
</Card>
);
}
return (
<>
<Card className="p-6 gap-5">
<SetHeader set={set}/>
{!steam && set.name && <p className="-mt-3 truncate text-sm text-muted-foreground">{set.name}</p>}
<SetSummary set={set}/>
{!set.listed && <p className="text-sm text-muted-foreground">{t('GAME_DETAIL.TROPHIES_PENDING')}</p>}
</Card>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{sorted.map((achievement) => (
<AchievementItem key={`${achievement.setId ?? STEAM_SET}:${achievement.apiName}`} game={game}
achievement={achievement}/>
))}
</ul>
</>
);
}
export default function GameAchievements({game}) {
const bySet = useMemo(() => {
const groups = new Map();
for (const a of game.achievements ?? []) {
const id = a.setId ?? STEAM_SET;
if (!groups.has(id)) groups.set(id, []);
groups.get(id).push(a);
}
return groups;
}, [game.achievements]);
return (game.achievementSets ?? []).map((set) => (
<AchievementSet key={set.id} game={game} set={set} achievements={bySet.get(set.id) ?? []}/>
));
}
+113
View File
@@ -0,0 +1,113 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
import GameAchievements from './GameAchievements.jsx';
import {reportBrokenImage} from '../utils/brokenImages.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('../utils/brokenImages.js', () => ({ACHIEVEMENT_ICONS: 'ACHIEVEMENT_ICONS', reportBrokenImage: vi.fn()}));
const set = (id, extra = {}) => ({id, kind: id === 'steam' ? 'STEAM' : 'PSN', platform: null, name: null, total: 2,
unlocked: 1, perfect: false, visible: true, listed: true, ...extra});
const steam = (apiName, extra = {}) => ({apiName, displayName: `Steam ${apiName}`, unlocked: false, kind: 'STEAM',
setId: null, iconUrl: 'https://cdn.steamstatic.com/a.jpg', iconGrayUrl: 'https://cdn.steamstatic.com/b.jpg', ...extra});
const trophy = (setId, apiName, extra = {}) => ({apiName, displayName: `Trophy ${setId} ${apiName}`, unlocked: false,
kind: 'PSN', setId, trophyType: 'BRONZE', iconUrl: 'https://psnobj.prod.dl.playstation.net/t.png',
iconGrayUrl: 'https://psnobj.prod.dl.playstation.net/t.png', ...extra});
const renderGame = (game) => render(<GameAchievements game={{appid: 220, ...game}}/>);
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('GameAchievements', () => {
it('shows one card per set in the order of achievementSets', () => {
renderGame({
achievementSets: [set('steam'), set('NPWR1_00', {platform: 'PS5'}), set('NPWR2_00', {platform: 'PS4'})],
achievements: [trophy('NPWR2_00', '1'), steam('A'), trophy('NPWR1_00', '1')],
});
const headings = screen.getAllByRole('heading', {level: 2}).map((heading) => heading.textContent);
expect(headings).toEqual(['GAME_DETAIL.ACHIEVEMENTS', 'GAME_DETAIL.TROPHIES', 'GAME_DETAIL.TROPHIES']);
const names = screen.getAllByRole('listitem').map((item) => item.textContent);
expect(names[0]).toContain('Steam A');
expect(names[1]).toContain('Trophy NPWR1_00 1');
expect(names[2]).toContain('Trophy NPWR2_00 1');
});
it('lists the base game first and the DLC groups after it, whatever the earned state', () => {
renderGame({
achievementSets: [set('NPWR1_00', {platform: 'PS5', total: 3, unlocked: 1})],
achievements: [trophy('NPWR1_00', '1', {group: '002', unlocked: true, unlockedAt: '2026-01-02T00:00:00Z'}),
trophy('NPWR1_00', '2', {group: '001'}), trophy('NPWR1_00', '3', {group: null})],
});
expect(screen.getAllByRole('listitem').map((item) => item.textContent)).toEqual([
expect.stringContaining('Trophy NPWR1_00 3'), expect.stringContaining('Trophy NPWR1_00 2'),
expect.stringContaining('Trophy NPWR1_00 1')]);
});
it('titles the trophy type and marks DLC trophies and the rarity', () => {
renderGame({
achievementSets: [set('NPWR1_00', {platform: 'PS5', total: 1, unlocked: 0})],
achievements: [trophy('NPWR1_00', '1', {trophyType: 'GOLD', group: '001', rarity: 12.34})],
});
expect(screen.getByTitle('GAME_DETAIL.TROPHY_TYPE.GOLD')).toBeTruthy();
expect(screen.getByText('GAME_DETAIL.DLC')).toBeTruthy();
expect(screen.getByText('GAME_DETAIL.RARITY')).toBeTruthy();
});
it('shows a hidden trophy that is not earned without a name', () => {
renderGame({
achievementSets: [set('NPWR1_00', {platform: 'PS5', total: 1, unlocked: 0})],
achievements: [trophy('NPWR1_00', '1', {displayName: null, description: null, hidden: true})],
});
expect(screen.getByText('GAME_DETAIL.HIDDEN_TROPHY')).toBeTruthy();
});
it('shows the hidden-stats text for a Steam set Steam hides, and still lists the PlayStation set', () => {
renderGame({
achievementSets: [set('steam', {visible: false, unlocked: 0}), set('NPWR1_00', {platform: 'PS5'})],
achievements: [steam('A'), trophy('NPWR1_00', '1')],
});
expect(screen.getByText('GAME_DETAIL.STATS_HIDDEN')).toBeTruthy();
expect(screen.queryByText('Steam A')).toBeNull();
expect(screen.getByText('Trophy NPWR1_00 1')).toBeTruthy();
});
it('shows the pending text for a trophy set whose list is not stored yet', () => {
renderGame({achievementSets: [set('NPWR1_00', {platform: 'PS4', listed: false})], achievements: []});
expect(screen.getByText('GAME_DETAIL.TROPHIES_PENDING')).toBeTruthy();
});
it('counts a perfect set as 100 percent, also with trophies missing', () => {
renderGame({
achievementSets: [set('NPWR1_00', {platform: 'PS5', total: 50, unlocked: 30, perfect: true})],
achievements: [],
});
expect(screen.getByText('100%')).toBeTruthy();
expect(screen.getByText('30 / 50')).toBeTruthy();
});
it('reports a broken Steam icon but not a broken PlayStation icon', () => {
const {container} = renderGame({
achievementSets: [set('steam', {total: 1, unlocked: 0}), set('NPWR1_00', {platform: 'PS5', total: 1, unlocked: 0})],
achievements: [steam('A'), trophy('NPWR1_00', '1')],
});
const steamItem = screen.getByText('Steam A').closest('li');
const psnItem = screen.getByText('Trophy NPWR1_00 1').closest('li');
fireEvent.error(psnItem.querySelector('img'));
expect(reportBrokenImage).not.toHaveBeenCalled();
fireEvent.error(steamItem.querySelector('img'));
expect(reportBrokenImage).toHaveBeenCalledTimes(1);
expect(container.querySelectorAll('img')).toHaveLength(0);
});
});
+205
View File
@@ -0,0 +1,205 @@
import {useEffect, useRef, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Loader2, RotateCcw, Save} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '../utils/corrections.js';
import {genreKey} from '../utils/stats.js';
import {MAX_CORRECTION_GENRES, MAX_CORRECTION_LENGTH, MAX_TIME_TO_BEAT_MINUTES} from '../utils/limits.js';
const REFUSAL_KEY = {INVALID_CORRECTION: 'CORRECTION.INVALID'};
const ENDLESS_CHOICES = [[null, 'UNKNOWN'], [true, 'YES'], [false, 'NO']];
const toHoursAndMinutes = (minutes) => (minutes ? {h: String(Math.floor(minutes / 60)), m: String(minutes % 60)} : {h: '', m: ''});
const toMinutes = ({h, m}) => (h.trim() === '' && m.trim() === '' ? null
: Math.round(Number(h || 0) * 60 + Number(m || 0)));
const hasNegativePart = ({normally, completely}) => [normally.h, normally.m, completely.h, completely.m].some((part) => Number(part) < 0);
const initialValues = (game) => ({
name: game.name ?? '',
image: game.imageUrl ?? '',
speedrun: game.speedrunUrl ?? '',
moods: game.moods ?? [],
endless: game.endless ?? null,
genres: game.genres ?? [],
time_to_beat: {
normally: toHoursAndMinutes(game.timeToBeat?.normallyMinutes),
completely: toHoursAndMinutes(game.timeToBeat?.completelyMinutes),
},
});
// An empty text field means "none": the Steam header, or no speedrun.
const toValue = (field, value) => {
if (field === 'time_to_beat') {
return {normallyMinutes: toMinutes(value.normally), completelyMinutes: toMinutes(value.completely)};
}
return typeof value === 'string' ? value.trim() || null : value;
};
const toggled = (list, item) => (list.includes(item) ? list.filter((each) => each !== item) : [...list, item]);
/**
* AUDITOR or higher: corrects the shared data of one game for every user. Each field saves on its own;
* onChanged loads the game again.
*/
export default function GameCorrectionDialog({game, open, onOpenChange, onChanged}) {
const {t} = useTranslation();
const [values, setValues] = useState(() => initialValues(game));
const [busy, setBusy] = useState(null);
// The field whose save or reset the next game from GameDetail answers; the other fields keep their edits.
const sentField = useRef(null);
useEffect(() => {
if (open) setValues(initialValues(game));
// Only when the dialog opens; a reload after a save is handled below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
useEffect(() => {
const field = sentField.current;
if (field) {
sentField.current = null;
setValues((v) => ({...v, [field]: initialValues(game)[field]}));
}
}, [game]);
const corrected = new Set((game.corrections ?? []).map((c) => c.field.toLowerCase()));
const set = (field, value) => setValues((v) => ({...v, [field]: value}));
const send = async (field, request) => {
setBusy(field);
try {
const response = await request();
if (response.status === 401) return;
if (response.ok) {
toast.success(t('CORRECTION.SAVED'));
sentField.current = field;
onChanged?.();
return;
}
const data = await response.json().catch(() => ({}));
// Core's message is English and for developers, so it goes to the console only.
if (data.message) console.warn('Correction refused:', data.code, data.message);
toast.error(t(REFUSAL_KEY[data.code] ?? (response.status === 403 ? 'CORRECTION.NO_ACCESS' : 'CORRECTION.ERROR')));
} catch (err) {
console.error('Correction error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(null);
}
};
const save = (field) => {
if (field === 'time_to_beat' && hasNegativePart(values.time_to_beat)) {
toast.error(t('CORRECTION.NEGATIVE'));
return;
}
send(field, () => saveCorrection(game.appid, field, toValue(field, values[field])));
};
const row = (field, input) => (
<div key={field} className="space-y-2 border-b pb-4 last:border-b-0">
<div className="flex items-center gap-2">
<Label htmlFor={`correction-${field}`}>{t(`CORRECTION.FIELD.${field.toUpperCase()}`)}</Label>
{corrected.has(field) && <Badge variant="outline">{t('CORRECTION.CORRECTED')}</Badge>}
</div>
{input}
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" disabled={busy !== null}
onClick={() => save(field)}>
{busy === field ? <Loader2 className="animate-spin"/> : <Save/>}{t('CORRECTION.SAVE')}
</Button>
{corrected.has(field) && (
<Button type="button" size="sm" variant="outline" disabled={busy !== null}
onClick={() => send(field, () => resetCorrection(game.appid, field))}>
<RotateCcw/>{t('CORRECTION.BACK_TO_SOURCE')}
</Button>
)}
</div>
</div>
);
const choices = (field, options, label, max = Infinity) => (
<div id={`correction-${field}`} className="flex flex-wrap gap-2">
{options.map((option) => (
<Button key={option} type="button" size="sm" aria-pressed={values[field].includes(option)}
disabled={!values[field].includes(option) && values[field].length >= max}
variant={values[field].includes(option) ? 'default' : 'outline'}
onClick={() => set(field, toggled(values[field], option))}>
{label(option)}
</Button>
))}
</div>
);
const timeInputs = (kind, label) => {
const setPart = (part, text) => set('time_to_beat', {
...values.time_to_beat, [kind]: {...values.time_to_beat[kind], [part]: text},
});
return (
<div className="flex flex-wrap items-center gap-2">
<span className="w-20 text-sm">{label}</span>
<Input type="number" min="0" max={MAX_TIME_TO_BEAT_MINUTES / 60} step="1" className="w-24"
aria-label={`${label} ${t('CORRECTION.HOURS')}`} value={values.time_to_beat[kind].h}
onChange={(e) => setPart('h', e.target.value)}/>
<span className="text-sm text-muted-foreground">{t('CORRECTION.HOURS')}</span>
<Input type="number" min="0" max="59" step="1" className="w-20"
aria-label={`${label} ${t('CORRECTION.MINUTES')}`} value={values.time_to_beat[kind].m}
onChange={(e) => setPart('m', e.target.value)}/>
<span className="text-sm text-muted-foreground">{t('CORRECTION.MINUTES')}</span>
</div>
);
};
// A genre IGDB added later still shows, so it can be removed.
const genreOptions = [...IGDB_GENRES, ...values.genres.filter((g) => !IGDB_GENRES.includes(g))];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('CORRECTION.TITLE')}</DialogTitle>
<DialogDescription>{t('CORRECTION.TEXT')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{row('name', <Input id="correction-name" value={values.name} maxLength={MAX_CORRECTION_LENGTH}
onChange={(e) => set('name', e.target.value)}/>)}
{row('image', <Input id="correction-image" value={values.image} maxLength={MAX_CORRECTION_LENGTH}
placeholder={t('CORRECTION.IMAGE_HINT')}
onChange={(e) => set('image', e.target.value)}/>)}
{row('speedrun', <Input id="correction-speedrun" value={values.speedrun} maxLength={MAX_CORRECTION_LENGTH}
placeholder={t('CORRECTION.SPEEDRUN_HINT')}
onChange={(e) => set('speedrun', e.target.value)}/>)}
{row('moods', choices('moods', CORRECTABLE_MOODS, (mood) => t(`PLAY_NEXT.MOOD.${mood}`)))}
{row('endless', (
<div id="correction-endless" className="flex flex-wrap gap-2">
{ENDLESS_CHOICES.map(([choice, key]) => (
<Button key={key} type="button" size="sm" aria-pressed={values.endless === choice}
variant={values.endless === choice ? 'default' : 'outline'}
onClick={() => set('endless', choice)}>
{t(`CORRECTION.ENDLESS.${key}`)}
</Button>
))}
</div>
))}
{row('genres', choices('genres', genreOptions,
(genre) => t(`STATS.GENRE.${genreKey(genre)}`, {defaultValue: genre}), MAX_CORRECTION_GENRES))}
{row('time_to_beat', (
<div id="correction-time_to_beat" className="space-y-2">
{timeInputs('normally', t('GAME_DETAIL.TTB_NORMALLY'))}
{timeInputs('completely', t('GAME_DETAIL.TTB_COMPLETELY'))}
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,175 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
import GameCorrectionDialog from './GameCorrectionDialog.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const game = {
appid: 620, name: 'Portal 2', imageUrl: null, speedrunUrl: 'https://www.speedrun.com/portal_2',
moods: ['STORY'], endless: null, genres: ['Puzzle'],
corrections: [{field: 'NAME', correctedAt: '2026-09-28T10:00:00Z'}],
timeToBeat: {hastilyMinutes: 300, normallyMinutes: 750, completelyMinutes: null},
};
const GAME_URL = '/api/library/games/620/corrections';
const corrections = (answer) => fetchRoutes({
[`PUT ${GAME_URL}/name`]: answer, [`PUT ${GAME_URL}/image`]: answer, [`PUT ${GAME_URL}/speedrun`]: answer,
[`PUT ${GAME_URL}/moods`]: answer, [`PUT ${GAME_URL}/time_to_beat`]: answer, [`DELETE ${GAME_URL}/name`]: answer,
});
const renderDialog = (onChanged = vi.fn()) => {
render(<MemoryRouter><GameCorrectionDialog game={game} open onOpenChange={vi.fn()} onChanged={onChanged}/></MemoryRouter>);
return onChanged;
};
// The rows in order: name, image, speedrun, moods, endless, genres, time_to_beat.
const saveButton = (row) => screen.getAllByRole('button', {name: 'CORRECTION.SAVE'})[row];
const sent = () => fetch.mock.calls.map(([url, init]) => ({url, method: init?.method, body: init?.body && JSON.parse(init.body)}));
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
corrections(json(204, {}));
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('GameCorrectionDialog', () => {
it('saves one field and loads the game again', async () => {
const onChanged = renderDialog();
fireEvent.change(screen.getByLabelText('CORRECTION.FIELD.NAME'), {target: {value: ' Portal Two '}});
fireEvent.click(saveButton(0));
await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(sent()).toEqual([{url: expect.stringContaining('/api/library/games/620/corrections/name'), method: 'PUT',
body: {value: 'Portal Two'}}]);
expect(toast.success).toHaveBeenCalledWith('CORRECTION.SAVED');
});
it('sends an empty link as "no speedrun" and the chosen moods as a list', async () => {
renderDialog();
fireEvent.change(screen.getByLabelText('CORRECTION.FIELD.SPEEDRUN'), {target: {value: ' '}});
fireEvent.click(saveButton(2));
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.COOP'}));
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.STORY'}));
fireEvent.click(saveButton(3));
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
expect(sent().map((call) => call.body)).toEqual([{value: null}, {value: ['COOP']}]);
});
it('shows the time to beat as hours and minutes and sends minutes, empty as null', async () => {
renderDialog();
expect(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.HOURS').value).toBe('12');
expect(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES').value).toBe('30');
expect(screen.getByLabelText('GAME_DETAIL.TTB_COMPLETELY CORRECTION.HOURS').value).toBe('');
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES'), {target: {value: ''}});
fireEvent.click(saveButton(6));
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect(sent()[0].body).toEqual({value: {normallyMinutes: 720, completelyMinutes: null}});
});
it('sendsFractionalHoursAsExactMinutes', async () => {
renderDialog();
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.HOURS'), {target: {value: '1.5'}});
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES'), {target: {value: ''}});
fireEvent.click(saveButton(6));
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect(sent()[0].body.value.normallyMinutes).toBe(90);
});
it('refusesANegativeTimePartBeforeSaving', () => {
renderDialog();
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.HOURS'), {target: {value: '1'}});
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES'), {target: {value: '-5'}});
fireEvent.click(saveButton(6));
expect(toast.error).toHaveBeenCalledWith('CORRECTION.NEGATIVE');
expect(fetch).not.toHaveBeenCalled();
});
it('offers back to source for a corrected time to beat', () => {
render(<MemoryRouter><GameCorrectionDialog open onOpenChange={vi.fn()} onChanged={vi.fn()}
game={{...game, corrections: [{field: 'TIME_TO_BEAT', correctedAt: '2026-09-28T10:00:00Z'}]}}/></MemoryRouter>);
expect(screen.getAllByText('CORRECTION.CORRECTED')).toHaveLength(1);
expect(screen.getByRole('button', {name: 'CORRECTION.BACK_TO_SOURCE'})).toBeTruthy();
});
it('marks a corrected field and goes back to the source', async () => {
const onChanged = renderDialog();
expect(screen.getAllByText('CORRECTION.CORRECTED')).toHaveLength(1);
fireEvent.click(screen.getByRole('button', {name: 'CORRECTION.BACK_TO_SOURCE'}));
await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(sent()).toEqual([{url: expect.stringContaining('/corrections/name'), method: 'DELETE', body: undefined}]);
});
it('says Core refused a value and logs its message instead of showing it', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
corrections(json(400, {code: 'INVALID_CORRECTION', message: 'image must be https'}));
const onChanged = renderDialog();
fireEvent.change(screen.getByLabelText('CORRECTION.FIELD.IMAGE'), {target: {value: 'http://x'}});
fireEvent.click(saveButton(1));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('CORRECTION.INVALID'));
expect(warn).toHaveBeenCalledWith('Correction refused:', 'INVALID_CORRECTION', 'image must be https');
expect(onChanged).not.toHaveBeenCalled();
});
it('shows the generic error for an unknown code', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
corrections(json(400, {code: 'SOMETHING_NEW', message: 'raw text'}));
renderDialog();
fireEvent.click(saveButton(1));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('CORRECTION.ERROR'));
});
it('keeps an unsaved edit of another field after the reload', async () => {
const onChanged = vi.fn();
const {rerender} = render(<MemoryRouter><GameCorrectionDialog game={game} open onOpenChange={vi.fn()}
onChanged={onChanged}/></MemoryRouter>);
fireEvent.change(screen.getByLabelText('CORRECTION.FIELD.SPEEDRUN'), {target: {value: 'https://www.speedrun.com/portal'}});
fireEvent.change(screen.getByLabelText('CORRECTION.FIELD.NAME'), {target: {value: 'Portal Two'}});
fireEvent.click(saveButton(0));
await waitFor(() => expect(onChanged).toHaveBeenCalled());
// GameDetail loads the game again and passes the new object.
rerender(<MemoryRouter><GameCorrectionDialog game={{...game, name: 'Portal Two'}} open onOpenChange={vi.fn()}
onChanged={onChanged}/></MemoryRouter>);
expect(screen.getByLabelText('CORRECTION.FIELD.SPEEDRUN').value).toBe('https://www.speedrun.com/portal');
expect(screen.getByLabelText('CORRECTION.FIELD.NAME').value).toBe('Portal Two');
});
it('offers no eleventh genre', () => {
renderDialog();
const genres = ['Adventure', 'Arcade', 'Fighting', 'Indie', 'MOBA', 'Music', 'Pinball', 'Platform', 'Racing'];
genres.forEach((genre) => fireEvent.click(screen.getByRole('button', {name: `STATS.GENRE.${genre.toUpperCase()}`})));
expect(screen.getByRole('button', {name: 'STATS.GENRE.SHOOTER'}).disabled).toBe(true);
expect(screen.getByRole('button', {name: 'STATS.GENRE.PUZZLE'}).disabled).toBe(false);
});
});
+19 -7
View File
@@ -1,30 +1,42 @@
import {useState} from 'react';
import {useRef, useState} from 'react';
import {Gamepad2} from 'lucide-react';
import {cn} from '@/lib/utils';
import {coverUrl} from '@/utils/games.js';
import {COVER, reportBrokenImage} from '@/utils/brokenImages.js';
import useInView from '../hooks/useInView.js';
/**
* Steam store header (460x215). Falls back to a neutral tile when the app has no header image.
*
* @param imageUrl picture for a game that has no Steam art (added by hand from IGDB); wins over the Steam header
* @param imageUrl a game added by hand from IGDB, or a Steam header Core repaired; wins over the fixed Steam header
* @param fx the classes of a completed effect (utils/rewards.js coverEffect); it holds still while the cover is off screen
*/
export default function GameCover({appid, imageUrl, className}) {
const [failed, setFailed] = useState(false);
export default function GameCover({appid, imageUrl, fx, className}) {
const src = imageUrl ?? coverUrl(appid);
// Per URL: a reused cover (e.g. the next suggestion) loads and reports its own picture.
const [failedSrc, setFailedSrc] = useState(null);
const failed = failedSrc === src;
const ref = useRef(null);
const inView = useInView(ref, !!fx);
return (
<div className={cn('aspect-[460/215] overflow-hidden rounded-md bg-muted', className)}>
<div ref={ref}
className={cn('relative aspect-[460/215] overflow-hidden rounded-md bg-muted', fx, fx && !inView && 'fx-paused', className)}>
{failed ? (
<div className="flex size-full items-center justify-center text-muted-foreground">
<Gamepad2 className="size-1/3 max-h-8"/>
</div>
) : (
<img
src={imageUrl ?? coverUrl(appid)}
src={src}
alt=""
loading="lazy"
className="size-full object-cover"
onError={() => setFailed(true)}
onError={() => {
reportBrokenImage(appid, COVER);
setFailedSrc(src);
}}
/>
)}
</div>
+107
View File
@@ -0,0 +1,107 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render} from '@testing-library/react';
import GameCover from './GameCover.jsx';
import {reportBrokenImage} from '../utils/brokenImages.js';
vi.mock('../utils/brokenImages.js', () => ({COVER: 'COVER', reportBrokenImage: vi.fn()}));
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe('GameCover', () => {
it('prefers the stored picture over the Steam header', () => {
const {container} = render(<GameCover appid={620} imageUrl="https://shared.akamai.steamstatic.com/x/header.jpg"/>);
expect(container.querySelector('img').getAttribute('src')).toBe('https://shared.akamai.steamstatic.com/x/header.jpg');
});
it('reports a picture that does not load and shows the placeholder', () => {
const {container} = render(<GameCover appid={620}/>);
fireEvent.error(container.querySelector('img'));
expect(reportBrokenImage).toHaveBeenCalledWith(620, 'COVER');
expect(container.querySelector('img')).toBeNull();
expect(container.querySelector('svg')).toBeTruthy();
});
it('loads the picture of the next game after a failed one', () => {
const {container, rerender} = render(<GameCover appid={620}/>);
fireEvent.error(container.querySelector('img'));
rerender(<GameCover appid={220}/>);
expect(container.querySelector('img').getAttribute('src')).toContain('/220/header.jpg');
fireEvent.error(container.querySelector('img'));
expect(reportBrokenImage).toHaveBeenCalledWith(220, 'COVER');
});
describe('completed effect', () => {
const FX = 'fx fx-cover-gold-shine';
let callbacks;
let observers;
beforeEach(() => {
callbacks = [];
observers = [];
vi.stubGlobal('IntersectionObserver', class {
constructor(callback) {
callbacks.push(callback);
observers.push(this);
}
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
});
});
const tell = (target, isIntersecting) => act(() => callbacks[0]([{target, isIntersecting}]));
it('animates on screen, holds its frame off screen and goes on when it comes back', () => {
const {container} = render(<GameCover appid={620} fx={FX}/>);
const root = container.firstChild;
expect(root.classList.contains('fx-cover-gold-shine')).toBe(true);
expect(root.classList.contains('fx-paused')).toBe(false);
tell(root, false);
expect(root.classList.contains('fx-paused')).toBe(true);
tell(root, true);
expect(root.classList.contains('fx-paused')).toBe(false);
});
it('watches every cover with one shared observer', () => {
const {container} = render(<><GameCover appid={620} fx={FX}/><GameCover appid={220} fx={FX}/></>);
expect(observers).toHaveLength(1);
expect(observers[0].observe).toHaveBeenCalledTimes(2);
expect(observers[0].observe).toHaveBeenCalledWith(container.children[0]);
});
it('stops watching when the cover goes', () => {
const {unmount} = render(<GameCover appid={620} fx={FX}/>);
unmount();
expect(observers[0].unobserve).toHaveBeenCalledTimes(1);
expect(observers[0].disconnect).toHaveBeenCalledTimes(1);
});
it('does not watch a cover without an effect', () => {
render(<GameCover appid={620}/>);
expect(observers).toHaveLength(0);
});
it('animates where the browser has no IntersectionObserver', () => {
vi.stubGlobal('IntersectionObserver', undefined);
const {container} = render(<GameCover appid={620} fx={FX}/>);
expect(container.firstChild.classList.contains('fx-paused')).toBe(false);
});
});
});
+180
View File
@@ -0,0 +1,180 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Clock, Ellipsis, ExternalLink, Eye, EyeOff, Gamepad2, Image as ImageIcon, ImageOff, Merge, PanelTop, PencilLine, RotateCcw, Timer} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {cn} from '@/lib/utils';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import ComingBadge from './ComingBadge.jsx';
import ProfileArtItems from './ProfileArtItems.jsx';
import RatingStars from './RatingStars.jsx';
import StatusMenu from './StatusMenu.jsx';
import TimeToBeat from './TimeToBeat.jsx';
import useSaveAppearance from '../hooks/useSaveAppearance.js';
import useSaveBacklogField from '../hooks/useSaveBacklogField.js';
import useSetStatus from '../hooks/useSetStatus.js';
import {usePreferences} from '../context/PreferencesContext.jsx';
import {UNDO_MS} from '../utils/backlog.js';
import {formatPlaytime} from '../utils/games.js';
import {setGameHidden} from '../utils/hiddenGames.js';
import {isSteamKey} from '../utils/manualGames.js';
import {platformLabel} from '../utils/platforms.js';
import {coverEffect, detailEffect} from '../utils/rewards.js';
const SPEEDRUN_PREFIX = 'https://www.speedrun.com/';
// The spans of each completed effect decoration; effects.css places them.
const DETAIL_ELEMENTS = {'fx-detail-gold-shine': 8, 'fx-detail-neon-pulse': 1, 'fx-detail-holo': 1, 'fx-detail-ember': 6, 'fx-detail-platinum': 6};
/**
* Status and rating are edited here and save at once. A status change needs no points check: GameDetail's
* GamePointsLine sees the new status and does it.
*
* @param onSaved gets the saved backlog entry after a status or rating change
*/
export default function GameHeroCard({game, auditor, profileStyle, onCorrect, onMerge, onHiddenChanged, onProfileStyleSaved, onSaved}) {
const {t} = useTranslation();
const {appearance, equipped} = usePreferences();
const saveAppearance = useSaveAppearance();
const [hiding, setHiding] = useState(false);
const {setStatus, busy: statusBusy} = useSetStatus(game.appid, {onChanged: onSaved});
const {save, pending, busy: ratingBusy} = useSaveBacklogField(game.appid, {onSaved});
const onSteam = isSteamKey(game.appid);
// Core checks the link too; a link from the API must never run script.
const speedrunUrl = typeof game.speedrunUrl === 'string' && game.speedrunUrl.startsWith(SPEEDRUN_PREFIX)
? game.speedrunUrl : null;
const isBackground = appearance.backgroundAppid === game.appid;
const isBanner = appearance.showBanner && appearance.bannerAppid === game.appid;
const detailFx = detailEffect(equipped, game.status);
const applyHidden = async (hidden, {undoable = false} = {}) => {
setHiding(true);
try {
const response = await setGameHidden(game.appid, hidden);
if (response.status === 401) return;
if (!response.ok) {
toast.error(t('GAME_DETAIL.HIDE_ERROR'));
return;
}
onHiddenChanged(hidden);
const message = t(hidden ? 'GAME_DETAIL.HIDDEN_TOAST' : 'GAME_DETAIL.SHOWN_TOAST');
if (undoable) {
toast.success(message, {duration: UNDO_MS, action: {label: t('MANUAL_GAME.UNDO'), onClick: () => applyHidden(!hidden)}});
} else {
toast.success(message);
}
} catch (err) {
console.error('Hide game error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setHiding(false);
}
};
return (
<Card className="relative overflow-hidden p-0 gap-0 md:flex-row">
{detailFx && (
<div aria-hidden="true" className={cn('fx pointer-events-none absolute inset-0', detailFx)}>
{Array.from({length: DETAIL_ELEMENTS[detailFx]}, (_, i) => <span key={i}/>)}
</div>
)}
<GameCover appid={game.appid} imageUrl={game.imageUrl} fx={coverEffect(equipped, game.status)}
className="w-full rounded-none md:w-96 md:shrink-0"/>
<div className="relative flex flex-1 flex-col justify-between gap-4 p-6">
<div>
<h1 className="@container text-2xl md:text-3xl font-bold tracking-tight">
<FitText wrapOnDesktop text={game.name ?? `App ${game.appid}`}/>
</h1>
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant="secondary" className="tabular-nums">
<Clock className="size-3"/>
{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(game.totalPlaytimeMinutes ?? game.playtimeMinutes)})}
</Badge>
<ComingBadge game={game}/>
<StatusMenu status={game.status} busy={statusBusy}
onSelect={(status) => setStatus(status, {previous: game.status})}/>
{platformLabel(game) && (
<Badge variant="outline"><Gamepad2 className="size-3"/>{platformLabel(game)}</Badge>
)}
</div>
<div className="mt-3 flex items-center gap-2">
<span id="hero-rating" className="sr-only">{t('BACKLOG.RATING_LABEL')}</span>
<RatingStars value={pending?.field === 'rating' ? pending.value : (game.rating ?? 0)}
onChange={(value) => save('rating', value)} labelledBy="hero-rating" disabled={ratingBusy}/>
</div>
<div className="mt-3">
<TimeToBeat time={game.timeToBeat}/>
</div>
{speedrunUrl && (
<Button asChild variant="outline" size="sm" className="mt-3">
<a href={speedrunUrl} target="_blank" rel="noopener noreferrer">
<Timer/>{t('GAME_DETAIL.OPEN_IN_SPEEDRUN')}<ExternalLink/>
</a>
</Button>
)}
</div>
{/* Background, banner and profile art need Steam art, so a game off Steam has none of them. */}
<div className="flex flex-wrap gap-2">
{onSteam && (
<Button asChild variant="outline" size="sm">
<a href={`https://store.steampowered.com/app/${game.appid}`} target="_blank" rel="noopener noreferrer">
{t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/>
</a>
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon-sm" aria-label={t('GAME_DETAIL.MORE')}><Ellipsis/></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{onSteam && (<>
{isBackground ? (
<DropdownMenuItem onSelect={() => saveAppearance({backgroundAppid: null})}>
<ImageOff/>{t('GAME_DETAIL.REMOVE_BACKGROUND')}
</DropdownMenuItem>
) : (
<DropdownMenuItem onSelect={() => saveAppearance({backgroundAppid: game.appid})}>
<ImageIcon/>{t('GAME_DETAIL.SET_BACKGROUND')}
</DropdownMenuItem>
)}
{isBanner ? (
<DropdownMenuItem onSelect={() => saveAppearance({bannerAppid: null})}>
<RotateCcw/>{t('SETTINGS.BANNER_AUTO')}
</DropdownMenuItem>
) : (
<DropdownMenuItem onSelect={() => saveAppearance({bannerAppid: game.appid, showBanner: true})}>
<PanelTop/>{t('GAME_DETAIL.SET_BANNER')}
</DropdownMenuItem>
)}
{profileStyle && (
<ProfileArtItems appid={game.appid} style={profileStyle} onSaved={onProfileStyleSaved}/>
)}
<DropdownMenuSeparator/>
</>)}
<DropdownMenuItem disabled={hiding} onSelect={() => applyHidden(!game.hidden, {undoable: true})}>
{game.hidden ? <Eye/> : <EyeOff/>}
{t(game.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')}
</DropdownMenuItem>
{auditor && (<>
<DropdownMenuSeparator/>
<DropdownMenuItem onSelect={onCorrect}><PencilLine/>{t('CORRECTION.OPEN')}</DropdownMenuItem>
<DropdownMenuItem onSelect={onMerge}><Merge/>{t('MERGE.OPEN')}</DropdownMenuItem>
</>)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Card>
);
}
+109
View File
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import {toast} from 'sonner';
import GameHeroCard from './GameHeroCard.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const game = {appid: 220, name: 'Half-Life 2', status: 'BACKLOG', rating: null, hidden: false, playtimeMinutes: 10};
const routes = (extra = {}) => fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'PUT /api/library/games/220/status': (url, init) => json(200, {...game, ...JSON.parse(init.body)}),
'PATCH /api/library/games/220/backlog': (url, init) => json(200, {...game, ...JSON.parse(init.body)}),
'PUT /api/library/games/220/hidden': (url, init) => json(200, JSON.parse(init.body)),
...extra,
});
const renderCard = (props = {}) => renderWithProviders(
<GameHeroCard game={game} auditor={false} onCorrect={vi.fn()} onMerge={vi.fn()} onHiddenChanged={vi.fn()}
onProfileStyleSaved={vi.fn()} onSaved={vi.fn()} {...props}/>);
const openMore = async () => fireEvent.keyDown(await screen.findByRole('button', {name: 'GAME_DETAIL.MORE'}), {key: 'Enter'});
const calls = (method) => fetch.mock.calls.filter(([, init]) => init?.method === method);
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
localStorage.clear();
});
describe('GameHeroCard', () => {
it('keepsTheMoreMenuForArtHideAndAuditorTools', async () => {
routes();
renderCard({auditor: true, profileStyle: {}});
expect(screen.getByRole('link', {name: /GAME_DETAIL.OPEN_IN_STEAM/})).toBeTruthy();
await openMore();
const names = (await screen.findAllByRole('menuitem')).map((item) => item.textContent);
expect(names).toEqual(['GAME_DETAIL.SET_BACKGROUND', 'GAME_DETAIL.SET_BANNER', 'GAME_DETAIL.HIDE', 'CORRECTION.OPEN', 'MERGE.OPEN']);
expect(screen.getAllByRole('menuitemcheckbox')).toHaveLength(2);
expect(screen.queryByRole('button', {name: 'CORRECTION.OPEN'})).toBeNull();
});
it('offersNoAuditorToolsToAUser', async () => {
routes();
renderCard();
await openMore();
const names = (await screen.findAllByRole('menuitem')).map((item) => item.textContent);
expect(names).not.toContain('CORRECTION.OPEN');
expect(names).not.toContain('MERGE.OPEN');
});
it('hidesWithAnUndoThatShowsTheGameAgain', async () => {
routes();
const onHiddenChanged = vi.fn();
renderCard({onHiddenChanged});
await openMore();
fireEvent.click(await screen.findByRole('menuitem', {name: 'GAME_DETAIL.HIDE'}));
await waitFor(() => expect(onHiddenChanged).toHaveBeenCalledWith(true));
const [message, options] = toast.success.mock.calls.at(-1);
expect(message).toBe('GAME_DETAIL.HIDDEN_TOAST');
expect(options.action.label).toBe('MANUAL_GAME.UNDO');
await act(async () => options.action.onClick());
expect(onHiddenChanged).toHaveBeenLastCalledWith(false);
expect(calls('PUT').map(([, init]) => JSON.parse(init.body))).toEqual([{hidden: true}, {hidden: false}]);
// The undo is a plain toast.
expect(toast.success.mock.calls.at(-1)).toEqual(['GAME_DETAIL.SHOWN_TOAST']);
});
it('savesTheStatusFromTheMenuWithoutAskingThePointsItself', async () => {
routes();
const onSaved = vi.fn();
renderCard({onSaved});
fireEvent.keyDown(screen.getByRole('button', {name: /BACKLOG.STATUS_LABEL/}), {key: 'Enter'});
fireEvent.click(await screen.findByRole('menuitemradio', {name: 'BACKLOG.STATUS.PLAYING'}));
await waitFor(() => expect(onSaved).toHaveBeenCalledWith(expect.objectContaining({status: 'PLAYING'})));
expect(JSON.parse(calls('PUT')[0][1].body)).toEqual({status: 'PLAYING'});
// GameDetail's GamePointsLine does the points check when the status changes; none here (no route would fail).
expect(toast.success.mock.calls.at(-1)[1].action.label).toBe('MANUAL_GAME.UNDO');
});
it('savesTheRatingAtOnce', async () => {
routes();
const onSaved = vi.fn();
renderCard({onSaved});
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:4'}));
await waitFor(() => expect(onSaved).toHaveBeenCalled());
expect(calls('PATCH').map(([, init]) => JSON.parse(init.body))).toEqual([{rating: 4}]);
});
});
+83
View File
@@ -0,0 +1,83 @@
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {ArrowLeft, Loader2, Merge} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog';
import GameSearch from './GameSearch.jsx';
import {mergeGame} from '../utils/corrections.js';
import {isSteamKey} from '../utils/manualGames.js';
const KNOWN_ERRORS = new Set(['SAME_GAME', 'STEAM_GAMES', 'GAME_NOT_FOUND']);
/**
* AUDITOR or higher: merges this game with another key of the same game, for every user. onMerged gets the key
* the game lives under now.
*/
export default function GameMergeDialog({game, open, onOpenChange, onMerged}) {
const {t} = useTranslation();
const [query, setQuery] = useState('');
const [picked, setPicked] = useState(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (open) {
setQuery('');
setPicked(null);
}
}, [open]);
const merge = async () => {
setBusy(true);
try {
const response = await mergeGame(game.appid, picked.appid);
if (response.status === 401) return;
const data = await response.json().catch(() => ({}));
if (response.ok) {
toast.success(t('MERGE.DONE'));
onOpenChange(false);
onMerged(data.appid);
return;
}
toast.error(KNOWN_ERRORS.has(data.code) ? t(`MERGE.ERROR.${data.code}`)
: response.status === 403 ? t('MERGE.NO_ACCESS') : t('MERGE.ERROR.OTHER'));
} catch (err) {
console.error('Merge error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('MERGE.TITLE')}</DialogTitle>
<DialogDescription>{t('MERGE.HINT', {name: game.name})}</DialogDescription>
</DialogHeader>
{picked ? (
<div className="space-y-4">
<p className="text-sm">{t('MERGE.CONFIRM', {name: game.name, other: picked.name})}</p>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" disabled={busy} onClick={() => setPicked(null)}>
<ArrowLeft/>{t('MERGE.BACK')}
</Button>
<Button type="button" variant="destructive" disabled={busy} onClick={merge}>
{busy ? <Loader2 className="animate-spin"/> : <Merge/>}{t('MERGE.CONFIRM_BUTTON')}
</Button>
</div>
</div>
) : (
<div className="space-y-3">
{/* Core merges only games it knows and refuses two Steam games, so those are not offered. */}
<GameSearch query={query} onQueryChange={setQuery} onPick={setPicked}
hide={(hit) => !hit.inCatalog || hit.appid === game.appid
|| (isSteamKey(hit.appid) && isSteamKey(game.appid))}/>
</div>
)}
</DialogContent>
</Dialog>
);
}
+83
View File
@@ -0,0 +1,83 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
import GameMergeDialog from './GameMergeDialog.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const hades = {appid: 1145360, name: 'Hades'};
const hit = (appid, name) => ({appid, igdbId: Math.abs(appid), name, year: 2020, imageUrl: null,
platforms: ['Switch'], inLibrary: false, inCatalog: true});
function renderDialog(game, post, results) {
fetchRoutes({
'GET /api/library/game-search': json(200, {available: true, results}),
[`POST /api/library/games/${game.appid}/merge`]: post,
});
const onMerged = vi.fn();
render(<MemoryRouter><GameMergeDialog game={game} open onOpenChange={vi.fn()} onMerged={onMerged}/></MemoryRouter>);
return onMerged;
}
const search = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}});
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('GameMergeDialog', () => {
it('merges the picked game after the confirm step', async () => {
const onMerged = renderDialog(hades, json(200, {appid: 1145360}),
[hit(1145360, 'Hades'), hit(-113112, 'Hades (Switch)'), hit(1145350, 'Hades II'),
{...hit(-4711, 'Hades Fan Remake'), inCatalog: false}]);
search('hades');
const offered = await screen.findByRole('button', {name: /Hades \(Switch\)/});
// The page's own game, other Steam games and games not in ToBePlayed are not offered.
expect(screen.queryByRole('button', {name: /^Hades \(2020\)/})).toBeNull();
expect(screen.queryByRole('button', {name: /Hades II/})).toBeNull();
expect(screen.queryByRole('button', {name: /Fan Remake/})).toBeNull();
fireEvent.click(offered);
expect(fetch).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', {name: 'MERGE.CONFIRM_BUTTON'}));
await waitFor(() => expect(onMerged).toHaveBeenCalledWith(1145360));
const [url, init] = fetch.mock.calls[1];
expect(url).toContain('/api/library/games/1145360/merge');
expect(JSON.parse(init.body)).toEqual({otherAppid: -113112});
expect(toast.success).toHaveBeenCalledWith('MERGE.DONE');
});
it('goes back from the confirm step without merging', async () => {
renderDialog(hades, json(200, {}), [hit(-113112, 'Hades (Switch)')]);
search('hades');
fireEvent.click(await screen.findByRole('button', {name: /Hades \(Switch\)/}));
fireEvent.click(screen.getByRole('button', {name: 'MERGE.BACK'}));
expect(await screen.findByRole('searchbox')).toBeTruthy();
expect(fetch).toHaveBeenCalledTimes(1);
});
it('says why Core refused the merge', async () => {
const onMerged = renderDialog({appid: -113112, name: 'Hades (Switch)'},
json(404, {code: 'GAME_NOT_FOUND'}), [hit(1145360, 'Hades')]);
search('hades');
fireEvent.click(await screen.findByRole('button', {name: /Hades \(2020\)/}));
fireEvent.click(screen.getByRole('button', {name: 'MERGE.CONFIRM_BUTTON'}));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('MERGE.ERROR.GAME_NOT_FOUND'));
expect(onMerged).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,40 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import GamePointsLine from './GamePointsLine.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
// t() shows its options, so the test sees what Core's earlySince becomes.
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key, options) => (options ? `${key}|${Object.values(options).join('|')}` : key),
i18n: {language: 'en-GB'},
}),
}));
vi.mock('sonner', () => ({toast: Object.assign(vi.fn(), {success: vi.fn(), error: vi.fn()})}));
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('GamePointsLine date', () => {
it('puts the early-since day of Core into the reason text', async () => {
fetchRoutes({
'GET /api/rewards/games/220': json(200, {
balance: 15, paid: [], earlySince: '2026-09-10', missing: {COMPLETED: 'COMPLETION_TOO_OLD'},
}),
'GET /api/rewards/wallet': json(200, {balance: 15, catalog: [], owned: [], equipped: {}, history: []}),
});
render(<PreferencesProvider><GamePointsLine game={{appid: 220, status: 'COMPLETED'}}/></PreferencesProvider>);
expect(await screen.findByText(/POINTS\.REASONS\.COMPLETION_TOO_OLD\|10 Sept(ember)? 2026/)).toBeTruthy();
});
});
+49
View File
@@ -0,0 +1,49 @@
import {useEffect, useRef, useState} from 'react';
import {useTranslation} from 'react-i18next';
import usePointsCheck from '../hooks/usePointsCheck.js';
import {formatDate, formatDay} from '../utils/format.js';
import {STATUS_NOT_REACHED} from '../utils/rewards.js';
const WAITING = {NO_PROOF: 'POINTS.GAME.WAITING', NO_PLAY_AFTER_START: 'POINTS.GAME.WAITING_START'};
// The rest of an early finish waits for Steam play, with or without playtime on record.
const WAITING_TOPUP = ['NO_PROOF', 'NO_PLAYTIME'];
const waitingKey = (kind, reason) => (kind === 'FINISHED_TOPUP' ? WAITING_TOPUP.includes(reason) && 'POINTS.GAME.WAITING_TOPUP' : WAITING[reason]);
/**
* What the game earned: one line per paid row and one per kind that is missing, with the reason from Core. Loading it
* settles the points; a status change after the first load also shows the points toast. `refreshes` loads it again
* after the Steam refresh of the game page, which may have stored an unlock that settles points.
*/
export default function GamePointsLine({game, refreshes = 0}) {
const {t, i18n} = useTranslation();
const check = usePointsCheck();
const [points, setPoints] = useState(null);
const lastStatus = useRef(null);
useEffect(() => {
const changed = lastStatus.current !== null && lastStatus.current !== game.status;
lastStatus.current = game.status;
let stale = false;
check(game.appid, {status: game.status, tell: changed}).then((data) => {
if (!stale && data) setPoints(data);
});
return () => {
stale = true;
};
}, [check, game.appid, game.status, refreshes]);
if (!points) return null;
const earlySinceText = points.earlySince && formatDay(points.earlySince, i18n.language, {dateStyle: 'medium'});
const kindText = (kind) => t(`POINTS.KINDS.${kind}`);
const lines = [
...points.paid.map((row) => t('POINTS.GAME.PAID', {
kind: kindText(row.kind), points: row.points,
date: formatDate(row.paidAt, i18n.language, {dateStyle: 'medium'}),
}) + (row.appid === game.appid ? '' : ` ${t('POINTS.GAME.OTHER_EDITION')}`)),
...Object.entries(points.missing).filter(([, reason]) => !STATUS_NOT_REACHED.includes(reason)).map(([kind, reason]) => (
waitingKey(kind, reason) ? t(waitingKey(kind, reason))
: t('POINTS.GAME.NONE', {kind: kindText(kind), reason: t(`POINTS.REASONS.${reason}`, {date: earlySinceText})}))),
];
return lines.map((line, i) => <p key={i} className="text-sm text-muted-foreground">{line}</p>);
}
+118
View File
@@ -0,0 +1,118 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen, waitFor} from '@testing-library/react';
import {toast} from 'sonner';
import GamePointsLine from './GamePointsLine.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: Object.assign(vi.fn(), {success: vi.fn(), error: vi.fn()})}));
const row = (kind, extra = {}) => ({kind, appid: 220, points: 30, paidAt: '2026-10-01T10:00:00Z', bookedNow: false, ...extra});
const wallet = (balance) => json(200, {balance, catalog: [], owned: [], equipped: {}, history: []});
const renderLine = (status, points) => {
const answers = Array.isArray(points) ? [...points] : [points];
fetchRoutes({
'GET /api/rewards/games/220': () => json(200, answers.length > 1 ? answers.shift() : answers[0]),
'GET /api/rewards/wallet': wallet(30),
});
const tree = (s) => <PreferencesProvider><GamePointsLine game={{appid: 220, status: s}}/></PreferencesProvider>;
const view = render(tree(status));
return {view, tree};
};
const pointsCalls = () => globalThis.fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/games/'));
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('GamePointsLine', () => {
it('shows one line per paid row with its date and notes another edition', async () => {
renderLine('FINISHED', {balance: 35, missing: {}, paid: [row('STARTED', {points: 5}), row('FINISHED', {appid: 221})]});
expect(await screen.findAllByText(/^POINTS.GAME.PAID/)).toHaveLength(2);
expect(screen.getAllByText(/POINTS.GAME.OTHER_EDITION/)).toHaveLength(1);
});
it('shows the waiting line for missing proof and a reason line for another missing kind', async () => {
renderLine('COMPLETED', {balance: 30, paid: [], missing: {FINISHED: 'NO_PROOF', COMPLETED: 'NEEDS_PROVEN_FINISH'}});
expect(await screen.findByText('POINTS.GAME.WAITING')).toBeTruthy();
expect(screen.getAllByText(/^POINTS.GAME.NONE/)).toHaveLength(1);
});
it('shows the early row and the waiting line for the rest of the finish', async () => {
renderLine('FINISHED', {balance: 10, paid: [row('FINISHED_EARLY', {points: 10})], missing: {FINISHED_TOPUP: 'NO_PROOF'}});
expect(await screen.findByText('POINTS.GAME.WAITING_TOPUP')).toBeTruthy();
expect(screen.getAllByText(/^POINTS.GAME.PAID/)).toHaveLength(1);
expect(screen.queryByText(/^POINTS.GAME.NONE/)).toBeNull();
});
it('shows the waiting line for the rest when there is no play time on record', async () => {
renderLine('FINISHED', {balance: 10, paid: [row('FINISHED_EARLY', {points: 10})], missing: {FINISHED_TOPUP: 'NO_PLAYTIME'}});
expect(await screen.findByText('POINTS.GAME.WAITING_TOPUP')).toBeTruthy();
});
it('shows a reason line for a hidden game that waits for the rest', async () => {
renderLine('FINISHED', {balance: 10, paid: [row('FINISHED_EARLY', {points: 10})], missing: {FINISHED_TOPUP: 'HIDDEN'}});
expect(await screen.findByText(/^POINTS.GAME.NONE/)).toBeTruthy();
});
it('shows the top-up row with its date', async () => {
renderLine('FINISHED', {balance: 30, paid: [row('FINISHED_EARLY', {points: 10}), row('FINISHED_TOPUP', {points: 20})], missing: {}});
expect(await screen.findAllByText(/^POINTS.GAME.PAID/)).toHaveLength(2);
});
it('shows no line for a status the game never reached', async () => {
renderLine('BACKLOG', {balance: 30, paid: [], missing: {STARTED: 'NOT_STARTED', FINISHED: 'NOT_FINISHED', COMPLETED: 'NOT_COMPLETED'}});
await waitFor(() => expect(pointsCalls()).toHaveLength(1));
expect(document.body.textContent).toBe('');
});
it('asks again with a toast after a status change, not on the first load', async () => {
const {view, tree} = renderLine('PLAYING', [
{balance: 0, paid: [], missing: {STARTED: 'NO_PLAY_AFTER_START'}},
{balance: 30, paid: [row('FINISHED', {bookedNow: true})], missing: {}},
]);
await screen.findByText('POINTS.GAME.WAITING_START');
expect(toast).not.toHaveBeenCalled();
expect(toast.success).not.toHaveBeenCalled();
view.rerender(tree('FINISHED'));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('POINTS.GAME.TOAST_BOOKED:30'));
expect(pointsCalls()).toHaveLength(2);
expect(await screen.findByText(/^POINTS.GAME.PAID/)).toBeTruthy();
});
it('reloads the wallet when the balance changed, so the header updates', async () => {
renderLine('FINISHED', {balance: 30, paid: [row('FINISHED')], missing: {}});
await screen.findByText(/^POINTS.GAME.PAID/);
await waitFor(() => expect(globalThis.fetch.mock.calls.some(([url]) => url.includes('/api/rewards/wallet'))).toBe(true));
});
it('shows nothing and no toast when the request fails', async () => {
fetchRoutes({'GET /api/rewards/games/220': json(404, {code: 'GAME_NOT_FOUND'})});
render(<PreferencesProvider><GamePointsLine game={{appid: 220, status: 'FINISHED'}}/></PreferencesProvider>);
await waitFor(() => expect(pointsCalls()).toHaveLength(1));
expect(document.body.textContent).toBe('');
expect(toast).not.toHaveBeenCalled();
});
});
+120
View File
@@ -0,0 +1,120 @@
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Loader2, Search} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Input} from '@/components/ui/input';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import {hitTitle, searchGames} from '../utils/manualGames.js';
import {MAX_GAME_SEARCH_LENGTH, MIN_GAME_SEARCH_LENGTH} from '../utils/limits.js';
const SEARCH_DEBOUNCE_MS = 400;
/**
* The search field and the hits of Core's game search. The parent keeps the query, so it stays when the user goes
* back.
*
* @param hide hits not offered at all
* @param blockInLibrary hits already in the own library are shown, but cannot be picked
* @param markInLibrary hits in the own library get the "In library" badge; defaults to blockInLibrary
* @param selected with onToggle: igdb ids checked for a pick of several games; each hit gets a checkbox
*/
export default function GameSearch({query, onQueryChange, onPick, hide = () => false, blockInLibrary = false,
markInLibrary = blockInLibrary,
selected, onToggle}) {
const {t} = useTranslation();
const {searchState, results} = useGameSearch(query);
const hits = results.filter((hit) => !hide(hit));
return (
<>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
<Input type="search" autoFocus value={query} maxLength={MAX_GAME_SEARCH_LENGTH} className="pl-8"
placeholder={t('MANUAL_GAME.SEARCH_PLACEHOLDER')}
aria-label={t('MANUAL_GAME.SEARCH_PLACEHOLDER')}
onChange={(e) => onQueryChange(e.target.value)}/>
</div>
{searchState === 'loading' && (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin"/>{t('MANUAL_GAME.SEARCHING')}
</p>
)}
{searchState === 'unavailable' && <p className="text-sm text-muted-foreground">{t('MANUAL_GAME.UNAVAILABLE')}</p>}
{searchState === 'failed' && <p className="text-sm text-destructive">{t('MANUAL_GAME.SEARCH_FAILED')}</p>}
{searchState === 'done' && hits.length === 0 && (
<p className="text-sm text-muted-foreground">{t('MANUAL_GAME.NO_HITS')}</p>
)}
{searchState === 'done' && hits.length > 0 && (
<ul className="divide-y rounded-md border">
{hits.map((hit) => {
const blocked = blockInLibrary && hit.inLibrary;
return (
<li key={hit.appid} className="flex items-center">
{onToggle && (
<input type="checkbox" className="ml-3 size-4 shrink-0 accent-primary"
aria-label={t('MANUAL_GAME.SELECT', {name: hit.name})}
checked={selected.has(hit.igdbId)} disabled={!hit.igdbId || blocked}
onChange={() => onToggle(hit)}/>
)}
<button type="button" disabled={blocked} onClick={() => onPick(hit)}
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-60">
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-20 shrink-0"/>
<span className="@container min-w-0 flex-1">
<span className="block font-medium">
<FitText text={hitTitle(hit)}/>
</span>
<span className="block truncate text-xs text-muted-foreground">
{hit.platforms.join(' · ')}
</span>
</span>
{markInLibrary && hit.inLibrary && <Badge variant="secondary">{t('MANUAL_GAME.IN_LIBRARY')}</Badge>}
</button>
</li>
);
})}
</ul>
)}
</>
);
}
// Asks Core 400 ms after the last key press; searchState is idle, loading, done, unavailable or failed.
function useGameSearch(query) {
const [searchState, setSearchState] = useState('idle');
const [results, setResults] = useState([]);
useEffect(() => {
const trimmed = query.trim();
if (trimmed.length < MIN_GAME_SEARCH_LENGTH) {
setSearchState('idle');
setResults([]);
return undefined;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
setSearchState('loading');
try {
const response = await searchGames(trimmed, controller.signal);
if (response.status === 401) return;
if (!response.ok) {
setSearchState('failed');
return;
}
const data = await response.json();
setResults(data.results ?? []);
setSearchState(data.available ? 'done' : 'unavailable');
} catch (err) {
if (err.name !== 'AbortError') setSearchState('failed');
}
}, SEARCH_DEBOUNCE_MS);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query]);
return {searchState, results};
}
+5 -5
View File
@@ -5,11 +5,11 @@ import {Badge} from '@/components/ui/badge';
import {cn} from '@/lib/utils';
export const STATUS_META = {
BACKLOG: {icon: Inbox, text: 'text-muted-foreground', badge: 'bg-secondary text-secondary-foreground', bar: 'bg-zinc-400 dark:bg-zinc-500'},
PLAYING: {icon: Gamepad2, text: 'text-primary', badge: 'bg-primary/15 text-primary', bar: 'bg-primary'},
FINISHED: {icon: CircleCheck, text: 'text-emerald-600 dark:text-emerald-400', badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400', bar: 'bg-emerald-500'},
COMPLETED: {icon: Trophy, text: 'text-amber-600 dark:text-amber-400', badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400', bar: 'bg-amber-500'},
DROPPED: {icon: Ban, text: 'text-rose-600 dark:text-rose-400', badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400', bar: 'bg-rose-500'},
BACKLOG: {icon: Inbox, color: 'var(--color-zinc-400)', text: 'text-muted-foreground', badge: 'bg-secondary text-secondary-foreground', bar: 'bg-zinc-400 dark:bg-zinc-500'},
PLAYING: {icon: Gamepad2, color: 'var(--primary)', text: 'text-primary', badge: 'bg-primary/15 text-primary', bar: 'bg-primary'},
FINISHED: {icon: CircleCheck, color: 'var(--color-emerald-500)', text: 'text-emerald-600 dark:text-emerald-400', badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400', bar: 'bg-emerald-500'},
COMPLETED: {icon: Trophy, color: 'var(--color-amber-500)', text: 'text-amber-600 dark:text-amber-400', badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400', bar: 'bg-amber-500'},
DROPPED: {icon: Ban, color: 'var(--color-rose-500)', text: 'text-rose-600 dark:text-rose-400', badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400', bar: 'bg-rose-500'},
};
/**
+7
View File
@@ -27,6 +27,13 @@ describe('PageBackground', () => {
expect(container.innerHTML).toBe('');
});
it('shows a worn animated background instead of the art', () => {
const {container} = render(<PageBackground appid={620} effect="aurora"/>);
expect(container.querySelector('[data-effect="aurora"]')).not.toBeNull();
expect(container.querySelector('img')).toBeNull();
});
it('starts with the library art again for another game', () => {
const {container, rerender} = render(<PageBackground appid={620}/>);
fireEvent.error(container.querySelector('img'));
+22
View File
@@ -0,0 +1,22 @@
import {cn} from '@/lib/utils';
import EffectBackground from './EffectBackground.jsx';
import HeroArt from './HeroArt.jsx';
/**
* The art of a game behind a card's content, or the accent gradient without one. Put it first in a card with
* `relative overflow-hidden`; the gradient keeps the text readable.
*
* @param effect a worn banner background (utils/rewards.js bannerEffect); it replaces the art
*/
export default function HeroBanner({appid, effect}) {
return (
<>
{effect
? <EffectBackground effect={effect} className="absolute inset-0"/>
: <HeroArt key={appid} appid={appid} className="pointer-events-none absolute inset-0 size-full object-cover opacity-60"/>}
<div className={cn('pointer-events-none absolute inset-0', effect || appid
? 'bg-gradient-to-r from-card via-card/85 to-card/60'
: 'bg-gradient-to-br from-primary/15 via-transparent to-transparent')}/>
</>
);
}
+42
View File
@@ -0,0 +1,42 @@
import {useId} from 'react';
import {useTranslation} from 'react-i18next';
import {Megaphone, X} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import HintText from './HintText.jsx';
import {hintText} from '../utils/hints.js';
export default function HintCard({hint, hideForever, onHideForeverChange, onClose, onAccept, className}) {
const {t, i18n} = useTranslation();
const checkboxId = useId();
return (
<Card role="status" className={cn('gap-3 p-4 shadow-lg', className)}>
<div className="flex items-start gap-3">
<Megaphone className="mt-0.5 size-5 shrink-0 text-primary"/>
<p className="flex-1 whitespace-pre-line break-words text-sm">
<HintText text={hint.text ?? hintText(hint, i18n.language)}/>
</p>
{hint.kind === 'DISMISS' && (
<Button type="button" variant="ghost" size="icon" className="-mr-2 -mt-2 size-8"
aria-label={t('HINT.CLOSE')} onClick={onClose}>
<X/>
</Button>
)}
</div>
<div className="flex items-center justify-end gap-2">
{hint.kind === 'DISMISS' ? (
<label htmlFor={checkboxId} className="flex items-center gap-2 text-xs text-muted-foreground">
<input id={checkboxId} type="checkbox" className="accent-primary" checked={hideForever}
onChange={(event) => onHideForeverChange(event.target.checked)}/>
{t('HINT.DO_NOT_SHOW_AGAIN')}
</label>
) : (
<Button type="button" size="sm" onClick={onAccept}>{t('HINT.OK')}</Button>
)}
</div>
</Card>
);
}
+42
View File
@@ -0,0 +1,42 @@
import {Link} from 'react-router-dom';
// **bold** and [text](url) only. No nesting: the link text is plain.
const TOKEN = /\*\*([^*\n]+)\*\*|\[([^\]\n]+)\]\(([^)\s]+)\)/g;
// An app path (not //host, and no backslash: browsers read /\host as //host), or https. Case-sensitive on purpose.
const APP_PATH = /^\/(?![/\\])[^\s\\]*$/;
const HTTPS = /^https:\/\/[^\s\\]+$/;
/**
* @returns {{to: string} | {href: string} | null} null: the url is not safe, so the link stays plain text
*/
export const safeLink = (url) => {
if (APP_PATH.test(url)) return {to: url};
if (HTTPS.test(url)) return {href: url};
return null;
};
/**
* Hint and news text with **bold** and [text](url). Only React elements, never HTML: React escapes every string.
*/
export default function HintText({text}) {
const parts = [];
let last = 0;
for (const match of text.matchAll(TOKEN)) {
const [whole, bold, label, url] = match;
if (match.index > last) parts.push(text.slice(last, match.index));
last = match.index + whole.length;
const link = url && safeLink(url);
if (bold !== undefined) {
parts.push(<strong key={match.index}>{bold}</strong>);
} else if (link?.to) {
parts.push(<Link key={match.index} to={link.to} className="underline">{label}</Link>);
} else if (link) {
parts.push(<a key={match.index} href={link.href} target="_blank" rel="noopener noreferrer"
className="underline">{label}</a>);
} else {
parts.push(whole);
}
}
parts.push(text.slice(last));
return <>{parts}</>;
}
+57
View File
@@ -0,0 +1,57 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest';
import {cleanup, render} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import HintText, {safeLink} from './HintText.jsx';
const renderText = (text) => render(<MemoryRouter><p>{<HintText text={text}/>}</p></MemoryRouter>).container;
const UNSAFE = [
'javascript:alert(1)', 'JavaScript:alert(1)', 'data:text/html,<b>x</b>', 'http://example.com', '//evil.example',
'/\\evil.example', 'HTTPS://x', 'vbscript:x',
];
describe('HintText', () => {
afterEach(cleanup);
it('renders bold and links', () => {
const container = renderText('A **bold** word, [Import](/import) and [site](https://example.com).');
expect(container.querySelector('strong').textContent).toBe('bold');
expect(container.querySelector('a[href="/import"]').textContent).toBe('Import');
const external = container.querySelector('a[href="https://example.com"]');
expect(external.getAttribute('target')).toBe('_blank');
expect(external.getAttribute('rel')).toBe('noopener noreferrer');
expect(container.textContent).toBe('A bold word, Import and site.');
});
it.each(UNSAFE)('shows a link to %s as plain text', (url) => {
const text = `[click](${url})`;
const container = renderText(text);
expect(container.querySelector('a')).toBeNull();
expect(container.textContent).toBe(text);
});
it('shows HTML as text', () => {
const container = renderText('<img src=x onerror=alert(1)> <script>alert(1)</script>');
expect(container.querySelector('img, script')).toBeNull();
expect(container.textContent).toContain('<img src=x onerror=alert(1)>');
});
it('keeps unclosed markup and line breaks as they are', () => {
const container = renderText('**bold\n[text](');
expect(container.querySelector('strong, a')).toBeNull();
expect(container.textContent).toBe('**bold\n[text](');
});
});
describe('safeLink', () => {
it('allows app paths and https only', () => {
expect(safeLink('/import')).toEqual({to: '/import'});
expect(safeLink('https://example.com/a')).toEqual({href: 'https://example.com/a'});
UNSAFE.forEach((url) => expect(safeLink(url)).toBeNull());
});
});
+6 -21
View File
@@ -1,32 +1,17 @@
import {NavLink} from 'react-router-dom';
import {useLocation} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Gift, Library} from 'lucide-react';
import {Gift, Library, ListOrdered} from 'lucide-react';
import {cn} from '@/lib/utils';
import TabNav from './TabNav.jsx';
const TABS = [
{to: '/library', labelKey: 'LIBRARY.TITLE', icon: Library},
{to: '/backlog', labelKey: 'BACKLOG_ORDER.TITLE', icon: ListOrdered},
{to: '/wishlist', labelKey: 'WISHLIST.TITLE', icon: Gift},
];
export default function LibraryTabs() {
const {t} = useTranslation();
return (
<nav aria-label={t('LIBRARY.TITLE')} className="inline-flex rounded-lg bg-muted p-1 text-sm">
{TABS.map(({to, labelKey, icon: Icon}) => (
<NavLink
key={to}
to={to}
end
className={({isActive}) => cn(
'flex items-center gap-1.5 rounded-md px-3 py-1.5 font-medium transition-colors',
isActive ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
)}
>
<Icon className="size-4"/>
{t(labelKey)}
</NavLink>
))}
</nav>
);
const {pathname} = useLocation();
return <TabNav label={t('LIBRARY.TITLE')} tabs={TABS.map((tab) => ({...tab, active: pathname === tab.to}))}/>;
}
+130
View File
@@ -0,0 +1,130 @@
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {ArrowLeft, Link2, Loader2, Search, Send} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog';
import {Input} from '@/components/ui/input';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import {linkGames, requestLink, searchOwnGames} from '../utils/links.js';
import {MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
import {platformLabel} from '../utils/platforms.js';
const SEARCH_DEBOUNCE_MS = 300;
const KNOWN_ERRORS = new Set(['SAME_GAME', 'ALREADY_LINKED', 'PAIR_DISMISSED', 'TOO_MANY_REQUESTS', 'GAME_NOT_FOUND']);
/**
* Picks a game of the own library that is the same game as this one. A user sends a request to the auditors; an
* auditor links at once.
*/
export default function LinkGameDialog({game, open, onOpenChange, auditor, onDone}) {
const {t} = useTranslation();
const [query, setQuery] = useState('');
const [hits, setHits] = useState([]);
const [picked, setPicked] = useState(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (open) {
setQuery('');
setHits([]);
setPicked(null);
}
}, [open]);
useEffect(() => {
if (!open || !query.trim()) {
setHits([]);
return undefined;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
const {ok, data} = await searchOwnGames(query.trim(), controller.signal);
if (ok && data) setHits(data.content);
}, SEARCH_DEBOUNCE_MS);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [open, query]);
const send = async () => {
setBusy(true);
try {
const response = await (auditor ? linkGames : requestLink)(game.appid, picked.appid);
if (response.status === 401) return;
if (response.ok) {
toast.success(t(auditor ? 'LINKS.LINKED' : 'LINKS.REQUESTED'));
onOpenChange(false);
if (auditor) onDone();
return;
}
const data = await response.json().catch(() => ({}));
toast.error(t(KNOWN_ERRORS.has(data.code) ? `LINKS.ERROR_CODES.${data.code}` : 'LINKS.ERROR'));
} catch (err) {
console.error('Link error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
const offered = hits.filter((hit) => hit.appid !== game.appid);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('LINKS.SAME_AS')}</DialogTitle>
<DialogDescription>{t('LINKS.DIALOG_HINT', {name: game.name})}</DialogDescription>
</DialogHeader>
{picked ? (
<div className="space-y-4">
<p className="text-sm">
{t(auditor ? 'LINKS.LINK_CONFIRM' : 'LINKS.REQUEST_CONFIRM', {name: game.name, other: picked.name})}
</p>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" disabled={busy} onClick={() => setPicked(null)}>
<ArrowLeft/>{t('MERGE.BACK')}
</Button>
<Button type="button" disabled={busy} onClick={send}>
{busy ? <Loader2 className="animate-spin"/> : auditor ? <Link2/> : <Send/>}
{t(auditor ? 'LINKS.LINK' : 'LINKS.REQUEST')}
</Button>
</div>
</div>
) : (
<div className="space-y-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
<Input type="search" autoFocus value={query} maxLength={MAX_LIBRARY_SEARCH_LENGTH} className="pl-8"
placeholder={t('LINKS.SEARCH_PLACEHOLDER')} aria-label={t('LINKS.SEARCH_PLACEHOLDER')}
onChange={(e) => setQuery(e.target.value)}/>
</div>
{query.trim() && offered.length === 0 && (
<p className="text-sm text-muted-foreground">{t('LINKS.NO_HITS')}</p>
)}
{offered.length > 0 && (
<ul className="divide-y rounded-md border">
{offered.map((hit) => (
<li key={hit.appid}>
<button type="button" onClick={() => setPicked(hit)}
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50">
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-16 shrink-0"/>
<span className="@container min-w-0 flex-1 text-sm font-medium"><FitText text={hit.name}/></span>
{platformLabel(hit) && (
<span className="text-xs text-muted-foreground">{platformLabel(hit)}</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
)}
</DialogContent>
</Dialog>
);
}
+79
View File
@@ -0,0 +1,79 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
import LinkGameDialog from './LinkGameDialog.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const hades = {appid: 1145360, name: 'Hades'};
const owned = [
{appid: 1145360, name: 'Hades', source: 'STEAM'},
{appid: -113112, name: 'Hades (Switch)', source: 'MANUAL', platform: 'Switch'},
];
function renderDialog({auditor = false, post}) {
fetchRoutes({
'GET /api/library/games': json(200, {content: owned}),
'POST /api/library/games/1145360/link-requests': post,
'POST /api/library/games/1145360/link': post,
});
const onDone = vi.fn();
render(<MemoryRouter><LinkGameDialog game={hades} open onOpenChange={vi.fn()} auditor={auditor} onDone={onDone}/></MemoryRouter>);
return onDone;
}
const pickSwitch = async () => {
fireEvent.change(screen.getByRole('searchbox'), {target: {value: 'hades'}});
// The own game is not offered.
fireEvent.click(await screen.findByRole('button', {name: /Hades \(Switch\)/}));
expect(screen.queryByRole('button', {name: /^Hades$/})).toBeNull();
};
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('LinkGameDialog', () => {
it('sendsARequestAfterTheConfirmStep', async () => {
renderDialog({post: json(204)});
await pickSwitch();
expect(screen.getByText('LINKS.REQUEST_CONFIRM')).toBeTruthy();
fireEvent.click(screen.getByRole('button', {name: 'LINKS.REQUEST'}));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('LINKS.REQUESTED'));
const [url, init] = fetch.mock.calls.at(-1);
expect(url).toContain('/api/library/games/1145360/link-requests');
expect(JSON.parse(init.body)).toEqual({otherAppid: -113112});
});
it('linksAtOnceForAnAuditorAndCallsOnDone', async () => {
const onDone = renderDialog({auditor: true, post: json(204)});
await pickSwitch();
fireEvent.click(screen.getByRole('button', {name: 'LINKS.LINK'}));
await waitFor(() => expect(onDone).toHaveBeenCalled());
expect(fetch.mock.calls.at(-1)[0]).toMatch(/\/games\/1145360\/link$/);
expect(toast.success).toHaveBeenCalledWith('LINKS.LINKED');
});
it('showsTheTooManyRequestsText', async () => {
renderDialog({post: json(429, {code: 'TOO_MANY_REQUESTS'})});
await pickSwitch();
fireEvent.click(screen.getByRole('button', {name: 'LINKS.REQUEST'}));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('LINKS.ERROR_CODES.TOO_MANY_REQUESTS'));
});
});
+8 -5
View File
@@ -2,11 +2,14 @@ import {useId} from 'react';
import {BODY, CHECKLIST, DPAD, OUTLINE, PROGRESS_BAR} from './logoPaths.js';
// Each color is [themed CSS, fixed hex]. The hex is the logo as designed; it also stands in where a browser
// cannot read color-mix, which would otherwise make that stop black.
const violet = ['var(--logo-violet, #7467f9)', '#7467f9'];
const blue = ['var(--logo-blue, #0192ef)', '#0192ef'];
const teal = ['var(--logo-teal, #17c7a4)', '#17c7a4'];
/**
* The three logo colors as [CSS variable, hex as designed]; style.css sets the variables per accent.
*/
export const LOGO_COLORS = [['--logo-violet', '#7467f9'], ['--logo-blue', '#0192ef'], ['--logo-teal', '#17c7a4']];
// Each color is [themed CSS, fixed hex]. The hex also stands in where a browser cannot read color-mix, which
// would otherwise make that stop black.
const [violet, blue, teal] = LOGO_COLORS.map(([name, hex]) => [`var(${name}, ${hex})`, hex]);
const mix = ([a], [b], percentOfB, hex) => [`color-mix(in oklch, ${a}, ${b} ${percentOfB}%)`, hex];
function Gradient({id, from, to, stops}) {
+24 -14
View File
@@ -8,11 +8,10 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import useSessionExpired from '../hooks/useSessionExpired.js';
import {hoursToMinutes, removeGame, updateManualGame} from '../utils/manualGames.js';
// Two decimals: then hoursToMinutes gives back the same minute, so an unchanged form is not dirty.
const toHours = (minutes) => (minutes > 0 ? String(Math.round((minutes / 60) * 100) / 100) : '');
import {hoursToMinutes, removeGame, restoreGame, toHours, updateManualGame} from '../utils/manualGames.js';
import {UNDO_MS} from '../utils/backlog.js';
import {MAX_PLATFORM_LENGTH} from '../utils/limits.js';
import useUnsavedChanges, {discardChanges} from '../hooks/useUnsavedChanges.js';
/**
* Platform and hours of a game added by hand, and removing it. Steam games get both from the sync.
@@ -22,23 +21,22 @@ const toHours = (minutes) => (minutes > 0 ? String(Math.round((minutes / 60) * 1
export default function ManualGameCard({game, onSaved}) {
const {t} = useTranslation();
const navigate = useNavigate();
const expireSession = useSessionExpired();
const [platform, setPlatform] = useState(game.platform ?? '');
const [hours, setHours] = useState(toHours(game.playtimeMinutes));
const [busy, setBusy] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false);
const playtimeMinutes = hoursToMinutes(hours);
// PlayStation owns the playtime of its rows: Core ignores a value sent for one.
const psn = game.source === 'PSN';
const playtimeMinutes = psn ? game.playtimeMinutes : hoursToMinutes(hours);
const dirty = platform.trim() !== (game.platform ?? '') || playtimeMinutes !== game.playtimeMinutes;
useUnsavedChanges(dirty);
const run = async (request, onOk) => {
setBusy(true);
try {
const response = await request();
if (response.status === 401) {
expireSession();
return;
}
if (response.status === 401) return;
if (!response.ok) {
toast.error(t('BACKLOG.SAVE_FAILED'));
return;
@@ -57,8 +55,18 @@ export default function ManualGameCard({game, onSaved}) {
toast.success(t('BACKLOG.SAVED'));
});
const undoRemove = () => run(() => restoreGame(game.appid), () => {
toast.success(t('REMOVED_GAMES.RESTORED'));
navigate(`/library/${game.appid}`);
});
const remove = () => run(() => removeGame(game.appid), () => {
toast.success(t('MANUAL_GAME.REMOVED'));
toast.success(t('MANUAL_GAME.REMOVED'), {
duration: UNDO_MS,
action: {label: t('MANUAL_GAME.UNDO'), onClick: undoRemove},
});
// The game is gone, so are its unsaved edits on this page.
discardChanges();
navigate('/library', {replace: true});
});
@@ -68,19 +76,21 @@ export default function ManualGameCard({game, onSaved}) {
<Hand className="size-4 text-primary"/>
{t('MANUAL_GAME.TITLE')}
</h2>
<p className="text-sm text-muted-foreground">{t('MANUAL_GAME.HINT')}</p>
{!psn && <p className="text-sm text-muted-foreground">{t('MANUAL_GAME.HINT')}</p>}
<div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="manual-platform">{t('MANUAL_GAME.PLATFORM')}</Label>
<Input id="manual-platform" value={platform} maxLength={32}
<Input id="manual-platform" value={platform} maxLength={MAX_PLATFORM_LENGTH}
onChange={(e) => setPlatform(e.target.value)}/>
</div>
{!psn && (
<div className="space-y-2">
<Label htmlFor="manual-hours">{t('MANUAL_GAME.HOURS')}</Label>
<Input id="manual-hours" type="number" min="0" step="0.5" inputMode="decimal" value={hours}
onChange={(e) => setHours(e.target.value)}/>
</div>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
+130
View File
@@ -0,0 +1,130 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import {toast} from 'sonner';
import ManualGameCard from './ManualGameCard.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const game = {appid: -1025, platform: 'PS5', playtimeMinutes: 90};
const answer = (routes) => fetchRoutes({'GET /api/users/me': json(200, {settings: {}}), ...routes});
const renderCard = (onSaved = vi.fn()) => renderWithProviders(<ManualGameCard game={game} onSaved={onSaved}/>,
{path: '/library/:appid', entries: ['/library/-1025'], routes: {'/library': <p>library page</p>}});
const saveButton = () => screen.getByRole('button', {name: 'BACKLOG.SAVE'});
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('ManualGameCard', () => {
it('saves the trimmed platform and the minutes, then reports them', async () => {
answer({'PUT /api/library/games/-1025/manual': json(200, {})});
const onSaved = vi.fn();
renderCard(onSaved);
fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: ' Switch '}});
fireEvent.change(screen.getByLabelText('MANUAL_GAME.HOURS'), {target: {value: '2'}});
fireEvent.click(saveButton());
await waitFor(() => expect(onSaved).toHaveBeenCalledWith({platform: 'Switch', playtimeMinutes: 120}));
const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
expect(url).toContain('/api/library/games/-1025/manual');
expect(JSON.parse(init.body)).toEqual({platform: 'Switch', playtimeMinutes: 120});
});
it('keeps the save button disabled while nothing changed', () => {
answer({});
renderCard();
expect(saveButton().disabled).toBe(true);
fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: 'PS5 '}});
expect(saveButton().disabled).toBe(true);
});
it('removes the game only after a confirm and opens the library', async () => {
answer({'DELETE /api/library/games/-1025': json(204, null)});
const {router} = renderCard();
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE'}));
expect(fetch.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false);
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE_YES'}));
await waitFor(() => expect(router.state.location.pathname).toBe('/library'));
});
it('offers an undo after the remove that restores the game and opens it', async () => {
answer({
'DELETE /api/library/games/-1025': json(204, null),
'POST /api/library/games/-1025/restore': json(204, null),
});
const {router} = renderCard();
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE'}));
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE_YES'}));
await waitFor(() => expect(router.state.location.pathname).toBe('/library'));
const [message, options] = toast.success.mock.calls.find(([text]) => text === 'MANUAL_GAME.REMOVED');
expect(message).toBe('MANUAL_GAME.REMOVED');
expect(options.action.label).toBe('MANUAL_GAME.UNDO');
await options.action.onClick();
const [url] = fetch.mock.calls.find(([, init]) => init?.method === 'POST');
expect(url).toContain('/api/library/games/-1025/restore');
await waitFor(() => expect(router.state.location.pathname).toBe('/library/-1025'));
expect(toast.success).toHaveBeenCalledWith('REMOVED_GAMES.RESTORED');
});
it('shows an error when the undo fails', async () => {
answer({
'DELETE /api/library/games/-1025': json(204, null),
'POST /api/library/games/-1025/restore': json(500, {}),
});
const {router} = renderCard();
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE'}));
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE_YES'}));
await waitFor(() => expect(router.state.location.pathname).toBe('/library'));
await toast.success.mock.calls.find(([text]) => text === 'MANUAL_GAME.REMOVED')[1].action.onClick();
expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED');
expect(router.state.location.pathname).toBe('/library');
});
it('shows an error when the save fails', async () => {
answer({'PUT /api/library/games/-1025/manual': json(500, {})});
const onSaved = vi.fn();
renderCard(onSaved);
fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: 'Switch'}});
fireEvent.click(saveButton());
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED'));
expect(onSaved).not.toHaveBeenCalled();
});
it('shows no toast on a 401', async () => {
answer({'PUT /api/library/games/-1025/manual': json(401, {}), 'POST /api/auth/refresh': json(401, {})});
const onSaved = vi.fn();
renderCard(onSaved);
fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: 'Switch'}});
fireEvent.click(saveButton());
await waitFor(() => expect(fetch.mock.calls.some(([url]) => url.endsWith('/api/auth/refresh'))).toBe(true));
await waitFor(() => expect(saveButton().disabled).toBe(false));
expect(toast.error).not.toHaveBeenCalled();
expect(toast.success).not.toHaveBeenCalled();
expect(onSaved).not.toHaveBeenCalled();
});
});
+81
View File
@@ -0,0 +1,81 @@
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ArrowRight, Trophy} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import ChartPicker from './stats/ChartPicker.jsx';
import GamePlayChart from './stats/GamePlayChart.jsx';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import {formatPlaytime} from '../utils/games.js';
import {coverEffect} from '../utils/rewards.js';
import {playChart} from '../utils/statCharts.js';
const total = (g) => g.totalPlaytimeMinutes ?? g.playtimeMinutes;
/**
* @param chart LIST, BARS, PIE or DONUT
* @param onChart `(form)`: shows the picker
*/
export default function MostPlayedCard({games, syncing, equipped, chart = 'LIST', onChart}) {
const {t} = useTranslation();
const mapped = games.map((g) => ({appid: g.appid, name: g.name, minutes: total(g)}));
const form = playChart(chart, mapped);
const maxMinutes = mapped[0]?.minutes || 1;
return (
<Card className="gap-0 py-0">
<div className="flex flex-wrap items-center justify-between gap-x-3 px-5 py-4 border-b">
<h2 className="flex items-center gap-2 font-semibold">
<Trophy className="size-4 text-amber-500"/>
{t('DASHBOARD.MOST_PLAYED')}
</h2>
<div className="flex flex-wrap items-center gap-x-3">
{onChart && <ChartPicker group="TOP_GAMES" value={chart} onChange={onChart}/>}
<Button asChild variant="ghost" size="sm">
<Link to="/library">{t('DASHBOARD.SHOW_ALL')}<ArrowRight/></Link>
</Button>
</div>
</div>
{games.length === 0 ? (
<p className="px-5 py-10 text-center text-sm text-muted-foreground">
{syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
</p>
) : form !== 'LIST' ? (
<div className="px-5 py-4"><GamePlayChart games={mapped} chart={form} linked/></div>
) : (
<ol className="divide-y">
{games.map((g, index) => (
<li key={g.appid}>
<Link
to={`/library/${g.appid}`}
className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50"
>
<span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">
{index + 1}
</span>
<GameCover appid={g.appid} imageUrl={g.imageUrl} fx={coverEffect(equipped, g.status)}
className="w-16 sm:w-24 shrink-0"/>
<div className="@container min-w-0 flex-1">
<div className="font-medium group-hover:text-primary transition-colors">
<FitText text={g.name ?? `App ${g.appid}`}/>
</div>
<div className="mt-2 h-1.5 rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary"
style={{width: `${Math.max(2, (total(g) / maxMinutes) * 100)}%`}}
/>
</div>
</div>
<span className="w-12 sm:w-14 text-right text-sm font-medium text-muted-foreground tabular-nums">
{formatPlaytime(total(g))}
</span>
</Link>
</li>
))}
</ol>
)}
</Card>
);
}
Loaded 100 of 423 files, more files were not shown because too many files have changed in this diff. Show more