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.
This commit is contained in:
lobo committed 2026-10-21 14:54:48 +02:00
1 parent 45f5087227
commit 3712e3905f
80 files changed
+2179 -1131

No files matched your search

+31 -19
View File
@@ -20,6 +20,12 @@ To Be Played is split into four repositories:
## Features ## Features
- **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 - **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 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 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
@@ -30,15 +36,15 @@ To Be Played is split into four repositories:
- **Coming games**: a game that is not released yet (and nobody played) shows a "Coming <date>" badge in the library, on - **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 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. `pileGames`, so coming games are not on it; Core never picks them.
- **What to play next** (`/play-next`): seven rows of up to 3 backlog games each: up next (your own backlog order), - **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 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 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 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. 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. On a phone each row scrolls sideways.
- **Backlog order** (`/backlog`, third library tab): all backlog games in your own order. Move a game by drag (pointer - **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 events, no library, so it works with touch), by the up and down buttons, or "move to top / bottom" in the menu. Each
move saves the whole list. The "Up next" dashboard tile shows the first games; the picker's "Up next" row shows the first games. 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 - **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; 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. trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform.
@@ -48,7 +54,7 @@ To Be Played is split into four repositories:
opens the games behind it in a dialog (a bottom sheet on phones), 40 per page with "Load more". In your own 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), 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. averages, rates and the moon trips. The list always uses the same rules as the number.
- **Pile Points** (`/points`): points for playing games off your pile (start, finish by time to beat, 100 %, month and - **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 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), 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 shown on the dashboard and the public profile. Twelve more items are unlock-only: they show locked with a progress bar
@@ -94,7 +100,7 @@ flowchart LR
- **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit, - **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit,
a platform and optional hours. Works without a Steam ID. Games already in ToBePlayed come first. Check several 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. hits to add them at once through the import review.
The **Search** page (`/search`, nav item "Search") finds games by name: results with cover, year, platforms and an 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". "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 - **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 matches the lines with IGDB and saves them in the background; the page shows the steps (queued with the imports
@@ -142,10 +148,11 @@ flowchart LR
the proved Steam ID, never a typed-in one. A Steam ID typed in before shows as "not verified" with a button to 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), 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. 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, with perfect games and average completion. - **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 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 "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. ("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. - **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. 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 - **Language**: kept in the browser and saved in Auth; after the login Auth's language wins when one is saved. Dark
@@ -189,10 +196,13 @@ flowchart LR
Admins turn each on or off on `/admin/hints`. Hint and news text may use `**bold**` and `[text](url)` (an app 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. 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 - **Follow users** (#27): a logged-in visitor follows the owner of a public profile with "Follow" on `/u/<name>`; the
settings page lists who you follow ("Unfollow") and who follows you ("Remove", a private follower shows as "Private Community page (`/community`) has the tabs "Following" ("Unfollow"), "Followers" ("Remove", a private follower shows as
user"). Core writes one inbox note per followed user and batch (new games, achievements, status changes, wishlist "Private user"), "Find users" (search public profiles by name, `GET /api/follows/players?q=` in Core, 30 searches per
adds; what the profile hides is left out). `/inbox` (bell in the header, with the unread count) lists the newest 50 notes 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
and marks them read; a digest note opens inline (click) and lists its games, achievements, status changes and 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 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 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 PlayStation link (`PSN_EXPIRING` about 2 days before it ends, `PSN_EXPIRED` when it has ended), with a link to the
@@ -202,12 +212,11 @@ flowchart LR
game above the tiles (`ProfileGameCard`: status, playtime, achievements, only what the profile shares; one message 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 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 /api/library/games/<appid>/preview`) with an "Add to my library" button that opens the add search with the name filled
in. `/following` (nav item "Following", #51) lists the people you follow, newest feed message first, with an initial 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 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. 3, 5 or 10 of them and loads its own data.
- **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it" - **What's new**: unseen news entries show on `/inbox` (no dialog on the dashboard) and are marked seen in Auth when the
marks them seen in Auth; closing the dialog any other way shows them again on the next dashboard visit. `/whats-new` 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`.
(menu) lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`.
- **Broadcast mail**: admins write a mail in both languages on `/admin/broadcasts`, send it to all active users or to - **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. 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 - **Welcome page**: `/welcome` opens once after the first login (Auth `settings.welcomeSeenAt` is null; only the
@@ -313,11 +322,12 @@ flowchart LR
Dialog -->|"community row, login"| Owners["Core /api/stats/owners"] Dialog -->|"community row, login"| Owners["Core /api/stats/owners"]
Landing["Landing user tiles, login"] --> Users["Auth /api/stats/users"] Landing["Landing user tiles, login"] --> Users["Auth /api/stats/users"]
``` ```
- `/questions` (`QuestionsPage`, nav item with the open count; the badge is hidden on the page, whose title shows the - `/questions` (`QuestionsPage`, reached from the bell and the inbox row "N open questions"; the title shows the live
live count) lists one card per game with an open question (Start it? / Finished? / Dropped? / Rate it?). Core decides 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 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`. the Rate it? card back at once. `409` / `404` reload the list. `RatingStars` is shared with `GameHeroCard`.
Below the cards, a closed "Removed games" section (`RemovedGamesSection`, `GET /api/library/removed-games`) has a 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 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. manual game has an Undo that restores it the same way.
@@ -350,10 +360,12 @@ flowchart LR
```mermaid ```mermaid
flowchart LR flowchart LR
Header["AppHeader badge"] --> Count["Core /api/library/questions/count"] 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["QuestionsPage"] --> List["Core /api/library/questions"]
Page -->|"Yes / No / stars"| Answer["Core POST /api/library/questions/{appid}"] Page -->|"Yes / No / stars"| Answer["Core POST /api/library/questions/{appid}"]
Page -->|"Removed games, Restore"| Removed["Core /api/library/removed-games, POST /games/{appid}/restore"] 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 - 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 (a link to `/u/<name>`), everyone else as "+N private", the viewer as "You". The setting "show me in owner lists" is
+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 | „…“ | "…" |
+103 -74
View File
@@ -16,7 +16,7 @@
"ERROR_INVALID_CREDENTIALS": "E-Mail oder Passwort ist falsch.", "ERROR_INVALID_CREDENTIALS": "E-Mail oder Passwort ist falsch.",
"ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Versuche es in {{minutes}} Minuten noch einmal.", "ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Versuche es in {{minutes}} Minuten noch einmal.",
"ERROR_GENERIC": "Anmeldung fehlgeschlagen. Versuche es später noch einmal.", "ERROR_GENERIC": "Anmeldung fehlgeschlagen. Versuche es später noch einmal.",
"SESSION_EXPIRED": "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.", "SESSION_EXPIRED": "Deine Sitzung ist abgelaufen. Bitte melde dich noch einmal an.",
"ERROR_ACCOUNT_BLOCKED": "Dieses Konto ist gesperrt. Bitte wende dich an den Admin.", "ERROR_ACCOUNT_BLOCKED": "Dieses Konto ist gesperrt. Bitte wende dich an den Admin.",
"ERROR_ACCOUNT_PENDING": "Deine Registrierung wartet auf den Admin. Du bekommst eine Mail, sobald sie entschieden ist.", "ERROR_ACCOUNT_PENDING": "Deine Registrierung wartet auf den Admin. Du bekommst eine Mail, sobald sie entschieden ist.",
"NOT_CONFIRMED_HINT": "Noch nicht bestätigt? Lass dir den Link noch einmal senden.", "NOT_CONFIRMED_HINT": "Noch nicht bestätigt? Lass dir den Link noch einmal senden.",
@@ -41,7 +41,7 @@
"ERROR_DISPLAY_NAME_IN_USE": "Dieser Anzeigename ist schon vergeben.", "ERROR_DISPLAY_NAME_IN_USE": "Dieser Anzeigename ist schon vergeben.",
"ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Versuche es in {{minutes}} Minuten noch einmal.", "ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Versuche es in {{minutes}} Minuten noch einmal.",
"INTRO": "Du bekommst eine Mail mit einem Link: dort bestätigst du deine Adresse und wählst dein Passwort. Danach prüft ein Admin dein Konto und du bekommst eine Mail mit der Entscheidung.", "INTRO": "Du bekommst eine Mail mit einem Link: dort bestätigst du deine Adresse und wählst dein Passwort. Danach prüft ein Admin dein Konto und du bekommst eine Mail mit der Entscheidung.",
"ERROR_PENDING_LIMIT": "Gerade warten zu viele Registrierungen auf den Admin. Bitte versuch es später noch einmal." "ERROR_PENDING_LIMIT": "Gerade warten zu viele Registrierungen auf den Admin. Versuche es später noch einmal."
}, },
"RESET_PASSWORD": { "RESET_PASSWORD": {
"TITLE": "Passwort zurücksetzen", "TITLE": "Passwort zurücksetzen",
@@ -53,9 +53,9 @@
"DONE": "Dein Passwort ist geändert. Melde dich mit dem neuen an.", "DONE": "Dein Passwort ist geändert. Melde dich mit dem neuen an.",
"TO_LOGIN": "Anmelden", "TO_LOGIN": "Anmelden",
"INVALID": "Dieser Link ist unbekannt, schon benutzt oder älter als 1 Stunde. Fordere einen neuen an:", "INVALID": "Dieser Link ist unbekannt, schon benutzt oder älter als 1 Stunde. Fordere einen neuen an:",
"ERROR_REJECTED": "Der Server hat dieses Passwort nicht akzeptiert. Prüfe die Regeln und versuche es erneut.", "ERROR_REJECTED": "Der Server hat dieses Passwort nicht akzeptiert. Prüfe die Regeln und versuche es noch einmal.",
"ERROR_TOO_MANY": "Zu viele Versuche. Bitte versuche es in {{minutes}} Minuten noch einmal.", "ERROR_TOO_MANY": "Zu viele Versuche. Versuche es in {{minutes}} Minuten noch einmal.",
"ERROR_GENERIC": "Das hat nicht geklappt. Bitte versuche es noch einmal." "ERROR_GENERIC": "Das hat nicht geklappt. Versuche es noch einmal."
}, },
"CONFIRM_EMAIL": { "CONFIRM_EMAIL": {
"TITLE": "E-Mail-Adresse bestätigen", "TITLE": "E-Mail-Adresse bestätigen",
@@ -63,7 +63,7 @@
"SUBMIT": "Bestätigen", "SUBMIT": "Bestätigen",
"DONE": "Deine Adresse ist bestätigt. Du bekommst eine Mail, sobald ein Admin dein Konto freigegeben hat.", "DONE": "Deine Adresse ist bestätigt. Du bekommst eine Mail, sobald ein Admin dein Konto freigegeben hat.",
"INVALID": "Dieser Link ist unbekannt, schon benutzt oder älter als 24 Stunden. Fordere einen neuen an:", "INVALID": "Dieser Link ist unbekannt, schon benutzt oder älter als 24 Stunden. Fordere einen neuen an:",
"ERROR_REJECTED": "Der Server hat dieses Passwort nicht akzeptiert. Prüfe die Regeln und versuche es erneut." "ERROR_REJECTED": "Der Server hat dieses Passwort nicht akzeptiert. Prüfe die Regeln und versuche es noch einmal."
}, },
"SETTINGS": { "SETTINGS": {
"PROFILE_STYLE": "Aussehen des Profils", "PROFILE_STYLE": "Aussehen des Profils",
@@ -75,13 +75,13 @@
"TITLE": "Einstellungen", "TITLE": "Einstellungen",
"LOADING": "Einstellungen werden geladen …", "LOADING": "Einstellungen werden geladen …",
"ACCOUNT": "Konto", "ACCOUNT": "Konto",
"PREFERENCES": "Darstellung & Sprache", "PREFERENCES": "Sprache & Plattform",
"STEAM": "Steam", "STEAM": "Steam",
"EMAIL": "E-Mail", "EMAIL": "E-Mail",
"ROLE": "Rolle", "ROLE": "Rolle",
"CREATED_DATE": "Mitglied seit", "CREATED_DATE": "Mitglied seit",
"LAST_LOGIN": "Letzter Login", "LAST_LOGIN": "Letzter Login",
"OPEN_WELCOME": "Willkommensseite erneut anzeigen", "OPEN_WELCOME": "Willkommensseite noch einmal anzeigen",
"DARK_THEME": "Dunkles Design", "DARK_THEME": "Dunkles Design",
"LANGUAGE": "Sprache", "LANGUAGE": "Sprache",
"LANGUAGE_OPTIONS": { "LANGUAGE_OPTIONS": {
@@ -125,7 +125,7 @@
"REDUCE_MOTION_HINT": "Shop-Effekte und Konfetti stehen still, auch auf fremden Profilen.", "REDUCE_MOTION_HINT": "Shop-Effekte und Konfetti stehen still, auch auf fremden Profilen.",
"TILT": "Neigen bewegt Effekte", "TILT": "Neigen bewegt Effekte",
"TILT_HINT": "Hintergründe folgen, wenn du das Handy neigst. Das Handy fragt nach der Erlaubnis.", "TILT_HINT": "Hintergründe folgen, wenn du das Handy neigst. Das Handy fragt nach der Erlaubnis.",
"TILT_DENIED": "Erlaubnis abgelehnt. Erlaube den Bewegungszugriff in Safari und versuche es erneut.", "TILT_DENIED": "Erlaubnis abgelehnt. Erlaube den Bewegungszugriff in Safari und versuche es noch einmal.",
"BACKGROUND": "Hintergrund", "BACKGROUND": "Hintergrund",
"BACKGROUND_HINT": "Öffne ein Spiel in deiner Bibliothek und wähle „Als Hintergrund“.", "BACKGROUND_HINT": "Öffne ein Spiel in deiner Bibliothek und wähle „Als Hintergrund“.",
"BACKGROUND_REMOVE": "Hintergrund entfernen", "BACKGROUND_REMOVE": "Hintergrund entfernen",
@@ -148,7 +148,7 @@
"LINKED": "Steam-Konto verknüpft.", "LINKED": "Steam-Konto verknüpft.",
"IN_USE": "Dieses Steam-Konto ist schon mit einem anderen Konto verknüpft.", "IN_USE": "Dieses Steam-Konto ist schon mit einem anderen Konto verknüpft.",
"CANCELLED": "Steam-Verknüpfung abgebrochen.", "CANCELLED": "Steam-Verknüpfung abgebrochen.",
"FAILED": "Steam konnte die Verknüpfung nicht bestätigen. Bitte versuch es noch einmal.", "FAILED": "Steam konnte die Verknüpfung nicht bestätigen. Versuche es noch einmal.",
"ERROR": "Die Steam-Verknüpfung konnte nicht starten.", "ERROR": "Die Steam-Verknüpfung konnte nicht starten.",
"UNLINKED": "Steam-Konto getrennt." "UNLINKED": "Steam-Konto getrennt."
}, },
@@ -173,8 +173,8 @@
"UNLINK_CONFIRM": "Ja, trennen", "UNLINK_CONFIRM": "Ja, trennen",
"UNLINKED": "PlayStation-Konto getrennt. Importierte Spiele bleiben in deiner Bibliothek.", "UNLINKED": "PlayStation-Konto getrennt. Importierte Spiele bleiben in deiner Bibliothek.",
"INVALID": "PlayStation hat den Code nicht akzeptiert. Er ist vielleicht abgelaufen: melde dich neu an und kopiere einen neuen.", "INVALID": "PlayStation hat den Code nicht akzeptiert. Er ist vielleicht abgelaufen: melde dich neu an und kopiere einen neuen.",
"UNAVAILABLE": "PlayStation hat nicht geantwortet. Bitte versuche es später noch einmal.", "UNAVAILABLE": "PlayStation hat nicht geantwortet. Versuche es später noch einmal.",
"BUSY": "Eine andere PlayStation-Anfrage von dir läuft noch. Warte einen Moment und versuche es erneut.", "BUSY": "Eine andere PlayStation-Anfrage von dir läuft noch. Warte einen Moment und versuche es noch einmal.",
"ERROR": "Mit der PlayStation-Verknüpfung ist etwas schiefgelaufen." "ERROR": "Mit der PlayStation-Verknüpfung ist etwas schiefgelaufen."
}, },
"VERSION": "Version", "VERSION": "Version",
@@ -209,7 +209,14 @@
"FAVORITE_PLATFORM": "Lieblingsplattform", "FAVORITE_PLATFORM": "Lieblingsplattform",
"FAVORITE_PLATFORM_HINT": "Wird zuerst gewählt, wenn du ein Spiel für mehrere Plattformen hinzufügst.", "FAVORITE_PLATFORM_HINT": "Wird zuerst gewählt, wenn du ein Spiel für mehrere Plattformen hinzufügst.",
"FAVORITE_PLATFORM_NONE": "Keine", "FAVORITE_PLATFORM_NONE": "Keine",
"SKIN_OVERRIDES": "Dein Skin legt Farben und Ecken fest. Leg ihn auf der Seite Pile Points ab." "SKIN_OVERRIDES": "Dein Skin legt Farben und Ecken fest. Leg ihn auf der Seite Pile Points ab.",
"PROFILE_STYLE_PRIVATE": "Dein Profil ist privat. Schalte es oben öffentlich, dann kannst du das Aussehen ändern.",
"TAB_ACCOUNT": "Konto",
"TAB_APPEARANCE": "Aussehen",
"TAB_CONNECTIONS": "Verbindungen",
"TAB_PROFILE": "Profil",
"TAB_ABOUT": "Über",
"OPEN_COMMUNITY": "Wem du folgst und wer dir folgt, verwaltest du in der Community"
}, },
"VALIDATION": { "VALIDATION": {
"EMAIL_REQUIRED": "E-Mail ist erforderlich.", "EMAIL_REQUIRED": "E-Mail ist erforderlich.",
@@ -240,23 +247,23 @@
"WELCOME": "Willkommen", "WELCOME": "Willkommen",
"EMPTY": { "EMPTY": {
"TITLE": "Deine Bibliothek ist leer", "TITLE": "Deine Bibliothek ist leer",
"NO_SOURCE": "Verknüpfe eine Plattform, dann synchronisieren wir deine Spiele, oder importiere eine Liste.", "NO_SOURCE": "Verknüpfe eine Plattform, dann holt der Sync deine Spiele, oder importiere eine Liste.",
"LINK": "Steam / PlayStation verknüpfen", "LINK": "Steam / PlayStation verknüpfen",
"IMPORT": "Spiele importieren" "IMPORT": "Spiele importieren"
}, },
"CURRENTLY_PLAYING": "Spiele ich gerade", "CURRENTLY_PLAYING": "Spiele ich gerade",
"NOTHING_PLAYING": "Gerade nichts. Setze ein Spiel auf „Spiele ich“.", "NOTHING_PLAYING": "Gerade nichts. Setze ein Spiel auf „Spiele ich“.",
"UP_NEXT": "Als Nächstes", "UP_NEXT": "Warteschlange",
"UP_NEXT_EMPTY": "Dein Backlog ist leer. Schön!", "UP_NEXT_EMPTY": "Deine Warteschlange ist leer. Schön!",
"PILE_OF_SHAME": "Pile of Shame", "PILE_OF_SHAME": "Pile of Shame",
"PILE_SHARE": "{{percent}} % deiner Bibliothek", "PILE_SHARE": "{{percent}} % deiner Bibliothek",
"NEVER_PLAYED_one": "{{count}} Spiel nie gestartet", "NEVER_PLAYED_one": "{{count}} Spiel nie gestartet",
"NEVER_PLAYED_other": "{{count}} Spiele nie gestartet", "NEVER_PLAYED_other": "{{count}} Spiele nie gestartet",
"SUGGESTION": "Wie wäre es mit", "SUGGESTION": "Wie wäre es mit",
"OTHER_SUGGESTION": "Anderer Vorschlag", "OTHER_SUGGESTION": "Anderer Vorschlag",
"BACKLOG_EMPTY": "Dein Backlog ist leer. Stark!", "BACKLOG_EMPTY": "Dein Pile of Shame ist leer. Stark!",
"SUMMARY_FAILED": "Übersicht konnte nicht geladen werden.", "SUMMARY_FAILED": "Übersicht konnte nicht geladen werden.",
"RETRY": "Erneut versuchen", "RETRY": "Noch einmal versuchen",
"ACTIVITY": "Spielzeit", "ACTIVITY": "Spielzeit",
"ACTIVITY_TOTAL": "{{time}} in {{days}} Tagen", "ACTIVITY_TOTAL": "{{time}} in {{days}} Tagen",
"ACTIVITY_EMPTY": "Noch keine Spielzeit aufgezeichnet. Ab jetzt zählt jeder Sync mit.", "ACTIVITY_EMPTY": "Noch keine Spielzeit aufgezeichnet. Ab jetzt zählt jeder Sync mit.",
@@ -281,7 +288,7 @@
"TITLE": "Spielebibliothek", "TITLE": "Spielebibliothek",
"LOADING": "Lade Spiele …", "LOADING": "Lade Spiele …",
"EMPTY": "Noch keine Spiele. Verknüpfe eine Plattform, importiere eine Liste oder füge ein Spiel hinzu.", "EMPTY": "Noch keine Spiele. Verknüpfe eine Plattform, importiere eine Liste oder füge ein Spiel hinzu.",
"EMPTY_PRIVATE": "Der Sync hat keine Spiele gefunden. Bei Steam müssen die „Spieldetails“ öffentlich sein: [Steam-Datenschutzeinstellungen](https://steamcommunity.com/my/edit/settings). Danach erneut synchronisieren.", "EMPTY_PRIVATE": "Der Sync hat keine Spiele gefunden. Bei Steam müssen die „Spieldetails“ öffentlich sein: [Steam-Datenschutzeinstellungen](https://steamcommunity.com/my/edit/settings). Starte danach den Sync noch einmal.",
"ERROR_LOAD": "Spiele konnten nicht geladen werden.", "ERROR_LOAD": "Spiele konnten nicht geladen werden.",
"COMING": "Erscheint am {{date}}", "COMING": "Erscheint am {{date}}",
"COMING_SOON": "Erscheint bald", "COMING_SOON": "Erscheint bald",
@@ -298,19 +305,19 @@
"NEXT": "Weiter", "NEXT": "Weiter",
"PAGE_OF": "Seite {{current}} von {{total}}", "PAGE_OF": "Seite {{current}} von {{total}}",
"GAMES": "Spiele", "GAMES": "Spiele",
"RESYNC": "Jetzt synchronisieren", "RESYNC": "Sync starten",
"SYNC_STARTED": "Sync gestartet …", "SYNC_STARTED": "Sync gestartet …",
"SYNC_COOLDOWN": "In {{seconds}}s erneut versuchen", "SYNC_COOLDOWN": "In {{seconds}}s noch einmal versuchen",
"SYNCING": "Synchronisiere deine Bibliothek …", "SYNCING": "Sync läuft …",
"SYNCING_FIRST_TIME": "Erster Sync läuft. Deine Spiele erscheinen gleich.", "SYNCING_FIRST_TIME": "Erster Sync läuft. Deine Spiele erscheinen gleich.",
"SYNC_QUEUED": "Erster Sync ist eingereiht …", "SYNC_QUEUED": "Erster Sync ist eingereiht …",
"SYNC_DURATION_HINT": "Meist unter einer Minute, große Bibliotheken länger.", "SYNC_DURATION_HINT": "Meist unter einer Minute, große Bibliotheken länger.",
"SYNC_FAILED": "Sync fehlgeschlagen. Versuche es später noch einmal.", "SYNC_FAILED": "Sync fehlgeschlagen. Versuche es später noch einmal.",
"SYNC_STALE": "Der Sync läuft noch. Große Bibliotheken brauchen mehr Zeit. Schau später noch einmal vorbei.", "SYNC_STALE": "Der Sync läuft noch. Große Bibliotheken brauchen mehr Zeit. Schau später noch einmal vorbei.",
"SYNC_NOT_STARTED": "Der erste Sync ist nicht gestartet. Versuche „Jetzt synchronisieren“.", "SYNC_NOT_STARTED": "Der erste Sync ist nicht gestartet. Versuche „Sync starten“.",
"LAST_SYNCED": "Zuletzt synchronisiert {{when}}", "LAST_SYNCED": "Letzter Sync {{when}}",
"FILTER_ALL": "Alle", "FILTER_ALL": "Alle",
"SEARCH_PLACEHOLDER": "Spiel suchen …", "SEARCH_PLACEHOLDER": "In deiner Bibliothek suchen …",
"NO_MATCH": "Keine Spiele für diesen Filter.", "NO_MATCH": "Keine Spiele für diesen Filter.",
"SORT_RATING": "Beste Bewertung", "SORT_RATING": "Beste Bewertung",
"SORT_PRIORITY": "Höchste Priorität", "SORT_PRIORITY": "Höchste Priorität",
@@ -318,7 +325,10 @@
"FILTER_HIDDEN": "Ausgeblendet", "FILTER_HIDDEN": "Ausgeblendet",
"NO_HIDDEN": "Keine ausgeblendeten Spiele. Blende ein Spiel auf seiner Detailseite aus, z. B. einen Playtest oder ein Tool.", "NO_HIDDEN": "Keine ausgeblendeten Spiele. Blende ein Spiel auf seiner Detailseite aus, z. B. einen Playtest oder ein Tool.",
"ACHIEVEMENTS": "{{unlocked}} von {{total}} Erfolgen", "ACHIEVEMENTS": "{{unlocked}} von {{total}} Erfolgen",
"EDITIONS": "{{count}} Editionen in deiner Bibliothek" "EDITIONS": "{{count}} Editionen in deiner Bibliothek",
"FILTER_REMOVED": "Entfernt",
"NO_REMOVED": "Keine entfernten Spiele.",
"FIND_GAMES_INSTEAD": "Bei „Spiele finden“ suchen"
}, },
"GAME_DETAIL": { "GAME_DETAIL": {
"MORE": "Weitere Aktionen", "MORE": "Weitere Aktionen",
@@ -402,16 +412,14 @@
"NAV": { "NAV": {
"DASHBOARD": "Dashboard", "DASHBOARD": "Dashboard",
"LIBRARY": "Bibliothek", "LIBRARY": "Bibliothek",
"SEARCH": "Suche",
"SETTINGS": "Einstellungen", "SETTINGS": "Einstellungen",
"TO_DASHBOARD": "Zum Dashboard", "TO_DASHBOARD": "Zum Dashboard",
"MENU": "Menü", "MENU": "Menü",
"LANGUAGE": "Sprache", "LANGUAGE": "Sprache",
"DARK_MODE": "Dunkles Design", "DARK_MODE": "Dunkles Design",
"LIGHT_MODE": "Helles Design", "LIGHT_MODE": "Helles Design",
"PLAY_NEXT": "Nächstes Spiel", "PLAY_NEXT": "Als Nächstes",
"STATS": "Statistik", "STATS": "Statistik",
"FOLLOWING": "Folge ich",
"COMMUNITY": "Community", "COMMUNITY": "Community",
"POINTS": "Pile Points", "POINTS": "Pile Points",
"ADMIN": "Admin", "ADMIN": "Admin",
@@ -424,9 +432,6 @@
"INBOX": "Postfach", "INBOX": "Postfach",
"INBOX_UNREAD_one": "Postfach, {{count}} neue Nachricht", "INBOX_UNREAD_one": "Postfach, {{count}} neue Nachricht",
"INBOX_UNREAD_other": "Postfach, {{count}} neue Nachrichten", "INBOX_UNREAD_other": "Postfach, {{count}} neue Nachrichten",
"QUESTIONS": "Fragen",
"QUESTIONS_OPEN_one": "Fragen, {{count}} offen",
"QUESTIONS_OPEN_other": "Fragen, {{count}} offen",
"WHATS_NEW": "Neuigkeiten", "WHATS_NEW": "Neuigkeiten",
"ADMIN_NEWS": "Neuigkeiten verwalten", "ADMIN_NEWS": "Neuigkeiten verwalten",
"ADMIN_BROADCASTS": "Rundmail", "ADMIN_BROADCASTS": "Rundmail",
@@ -434,10 +439,11 @@
"PENDING_REQUESTS_other": "{{count}} Registrierungen warten", "PENDING_REQUESTS_other": "{{count}} Registrierungen warten",
"LINK_QUEUE": "Verknüpfungen", "LINK_QUEUE": "Verknüpfungen",
"AUDIT_LOG": "Protokoll", "AUDIT_LOG": "Protokoll",
"GAME_CATALOG": "Spielekatalog" "GAME_CATALOG": "Spielekatalog",
"MY_PROFILE": "Mein öffentliches Profil"
}, },
"BACKLOG": { "BACKLOG": {
"TITLE": "Mein Backlog", "TITLE": "Mein Fortschritt",
"STATUS_LABEL": "Status", "STATUS_LABEL": "Status",
"PRIORITY_LABEL": "Priorität", "PRIORITY_LABEL": "Priorität",
"RATING_LABEL": "Bewertung", "RATING_LABEL": "Bewertung",
@@ -464,10 +470,10 @@
} }
}, },
"BACKLOG_ORDER": { "BACKLOG_ORDER": {
"TITLE": "Backlog", "TITLE": "Warteschlange",
"SUBTITLE": "Dein Backlog in deiner eigenen Reihenfolge. Ziehe ein Spiel oder nutze die Pfeile und das Menü. Die ersten Spiele erscheinen unter „Nächstes Spiel“ und auf dem Dashboard.", "SUBTITLE": "Deine Backlog-Spiele in deiner eigenen Reihenfolge. Ziehe ein Spiel oder nutze die Pfeile und das Menü. Die ersten Spiele erscheinen unter „Als Nächstes“ und auf dem Dashboard.",
"EMPTY": "Dein Backlog ist leer. Schön!", "EMPTY": "Deine Warteschlange ist leer. Schön!",
"LOAD_FAILED": "Der Backlog konnte nicht geladen werden.", "LOAD_FAILED": "Die Warteschlange konnte nicht geladen werden.",
"SAVE_FAILED": "Die Reihenfolge konnte nicht gespeichert werden. Die Liste wird neu geladen.", "SAVE_FAILED": "Die Reihenfolge konnte nicht gespeichert werden. Die Liste wird neu geladen.",
"MOVE_UP": "Nach oben", "MOVE_UP": "Nach oben",
"MOVE_DOWN": "Nach unten", "MOVE_DOWN": "Nach unten",
@@ -499,11 +505,15 @@
"PERFECT_GAMES": "Perfekte Spiele", "PERFECT_GAMES": "Perfekte Spiele",
"AVERAGE_COMPLETION": "Ø Fortschritt", "AVERAGE_COMPLETION": "Ø Fortschritt",
"GAME_NOT_SHOWN": "Dieses Spiel wird in diesem Profil nicht angezeigt.", "GAME_NOT_SHOWN": "Dieses Spiel wird in diesem Profil nicht angezeigt.",
"GAME_ACHIEVEMENTS": "{{unlocked}} / {{total}} ({{percent}} %)" "GAME_ACHIEVEMENTS": "{{unlocked}} / {{total}} ({{percent}} %)",
"OWN_PRIVATE": "Dein Profil ist privat. Mach es in den Einstellungen öffentlich.",
"OPEN_SETTINGS": "Einstellungen öffnen",
"PUBLIC_CHIP": "Öffentlich",
"COPY_LINK": "Link kopieren"
}, },
"PLAY_NEXT": { "PLAY_NEXT": {
"TITLE": "Was spiele ich als Nächstes?", "TITLE": "Was spiele ich als Nächstes?",
"SUBTITLE": "Spiele aus deinem Backlog in sieben Reihen: als Nächstes in deiner Reihenfolge, der nächste Teil einer Reihe, die du gespielt hast, ähnlich wie Spiele, die du viel gespielt hast, von einem Studio, das du gespielt hast, was Leute gespielt haben, denen du folgst, am längsten wartend und nach Stimmung. Wähle eine Dauer für alle Reihen und eine Stimmung für die letzte.", "SUBTITLE": "Spiele aus deinem Backlog in sieben Reihen: deine Warteschlange, der nächste Teil einer Reihe, die du gespielt hast, ähnlich wie Spiele, die du viel gespielt hast, von einem Studio, das du gespielt hast, was Leute gespielt haben, denen du folgst, am längsten wartend und nach Stimmung. Wähle eine Dauer für alle Reihen und eine Stimmung für die letzte.",
"REASON": { "REASON": {
"SIMILAR": "Weil du {{name}} gespielt hast ({{playtime}})", "SIMILAR": "Weil du {{name}} gespielt hast ({{playtime}})",
"SERIES": "Reihe: {{name}}", "SERIES": "Reihe: {{name}}",
@@ -513,7 +523,7 @@
"LONGEST": "In deiner Bibliothek seit {{date}}" "LONGEST": "In deiner Bibliothek seit {{date}}"
}, },
"CATEGORY": { "CATEGORY": {
"NEXT_UP": "Als Nächstes", "NEXT_UP": "Warteschlange",
"SERIES": "Nächster Teil der Reihe", "SERIES": "Nächster Teil der Reihe",
"SIMILAR": "Weil du gespielt hast", "SIMILAR": "Weil du gespielt hast",
"STUDIO": "Vom selben Studio", "STUDIO": "Vom selben Studio",
@@ -550,7 +560,7 @@
"MOOD_HINT": "Nur Spiele, deren Genre IGDB kennt.", "MOOD_HINT": "Nur Spiele, deren Genre IGDB kennt.",
"MOOD_HINT_SPEEDRUN": "Nur Spiele mit bestätigten Runs auf speedrun.com. Der Backlog wird im Hintergrund geprüft, etwa ein Spiel alle paar Sekunden.", "MOOD_HINT_SPEEDRUN": "Nur Spiele mit bestätigten Runs auf speedrun.com. Der Backlog wird im Hintergrund geprüft, etwa ein Spiel alle paar Sekunden.",
"EMPTY_MOOD": "Kein Backlog-Spiel passt. Probier eine andere Stimmung oder Dauer. Neue Spiele bekommen ihre Stimmung kurz nach der ersten Auswahl.", "EMPTY_MOOD": "Kein Backlog-Spiel passt. Probier eine andere Stimmung oder Dauer. Neue Spiele bekommen ihre Stimmung kurz nach der ersten Auswahl.",
"EMPTY_SPEEDRUN": "Kein Backlog-Spiel mit Speedruns gefunden. Neue Spiele werden eventuell noch im Hintergrund geprüft; versuch es in ein paar Minuten noch einmal." "EMPTY_SPEEDRUN": "Kein Backlog-Spiel mit Speedruns gefunden. Neue Spiele werden eventuell noch im Hintergrund geprüft; versuche es in ein paar Minuten noch einmal."
}, },
"MANUAL_GAME": { "MANUAL_GAME": {
"ADD_BUTTON": "Spiel hinzufügen", "ADD_BUTTON": "Spiel hinzufügen",
@@ -596,7 +606,7 @@
}, },
"PILE": { "PILE": {
"TITLE": "Pile of Shame", "TITLE": "Pile of Shame",
"GAMES": "Spiele auf dem Stapel", "GAMES": "Spiele im Pile of Shame",
"NORMAL": "Zum Durchspielen", "NORMAL": "Zum Durchspielen",
"COMPLETIONIST": "Für 100 %", "COMPLETIONIST": "Für 100 %",
"KNOWN_one": "IGDB kennt {{count}} davon", "KNOWN_one": "IGDB kennt {{count}} davon",
@@ -608,10 +618,12 @@
"SINCE": "seit {{date}}" "SINCE": "seit {{date}}"
}, },
"TREND": { "TREND": {
"TITLE": "Stapel-Verlauf", "TITLE": "Verlauf des Pile of Shame",
"THIS_MONTH": "Diesen Monat:", "THIS_MONTH": "Diesen Monat:",
"THIS_MONTH_ADDED": "+{{added}} neu", "THIS_MONTH_ADDED": "+{{added}} neu",
"THIS_MONTH_DONE": "−{{done}} vom Stapel", "THIS_MONTH_DONE": "−{{done}} aus dem Pile of Shame",
"NET_one": "Netto: {{net}} Spiel in 90 Tagen",
"NET_other": "Netto: {{net}} Spiele in 90 Tagen",
"STARTS": "Der Verlauf beginnt heute. Morgen gibt es die erste Linie." "STARTS": "Der Verlauf beginnt heute. Morgen gibt es die erste Linie."
}, },
"TOP_GAMES": { "TOP_GAMES": {
@@ -695,9 +707,9 @@
"IN_LIBRARIES_one": "in {{count}} Bibliothek", "IN_LIBRARIES_one": "in {{count}} Bibliothek",
"IN_LIBRARIES_other": "in {{count}} Bibliotheken", "IN_LIBRARIES_other": "in {{count}} Bibliotheken",
"BEFORE_TRACKING": "Vor Beginn des Status-Verlaufs durchgespielt", "BEFORE_TRACKING": "Vor Beginn des Status-Verlaufs durchgespielt",
"TREND_MOVES": "Stapel-Änderungen der letzten 90 Tage", "TREND_MOVES": "Änderungen im Pile of Shame in den letzten 90 Tagen",
"TREND_ADDED": "Diesen Monat zum Stapel hinzugekommen", "TREND_ADDED": "Diesen Monat zum Pile of Shame hinzugekommen",
"TREND_DONE": "Diesen Monat vom Stapel gegangen", "TREND_DONE": "Diesen Monat aus dem Pile of Shame gegangen",
"NEW_IN_LIBRARY": "Neu", "NEW_IN_LIBRARY": "Neu",
"GAMES_one": "{{count}} Spiel", "GAMES_one": "{{count}} Spiel",
"GAMES_other": "{{count}} Spiele" "GAMES_other": "{{count}} Spiele"
@@ -934,12 +946,12 @@
"DO_NOT_SHOW_AGAIN": "Nicht mehr anzeigen", "DO_NOT_SHOW_AGAIN": "Nicht mehr anzeigen",
"OK": "OK", "OK": "OK",
"SAVE_FAILED": "Der Hinweis konnte nicht gespeichert werden.", "SAVE_FAILED": "Der Hinweis konnte nicht gespeichert werden.",
"AUTO_NO_STEAM": "[Verknüpfe dein Steam-Konto](/settings), damit deine Bibliothek abgeglichen wird.", "AUTO_NO_STEAM": "[Verknüpfe dein Steam-Konto](/settings?tab=connections), damit deine Bibliothek abgeglichen wird.",
"AUTO_EMPTY_LIBRARY": "Deine Bibliothek ist noch leer. [Importiere deine Spiele](/import).", "AUTO_EMPTY_LIBRARY": "Deine Bibliothek ist noch leer. [Importiere deine Spiele](/import).",
"AUTO_PILE_GREW_one": "Dein Stapel ist diesen Monat um **{{count}}** Spiel gewachsen. [Zum Verlauf](/stats).", "AUTO_PILE_GREW_one": "Dein Pile of Shame ist diesen Monat um **{{count}}** Spiel gewachsen. [Zum Verlauf](/stats).",
"AUTO_PILE_GREW_other": "Dein Stapel ist diesen Monat um **{{count}}** Spiele gewachsen. [Zum Verlauf](/stats).", "AUTO_PILE_GREW_other": "Dein Pile of Shame ist diesen Monat um **{{count}}** Spiele gewachsen. [Zum Verlauf](/stats).",
"AUTO_LINK_QUEUE_one": "**{{count}}** Paar wartet in der [Verknüpfungs-Warteschlange](/audit/links).", "AUTO_LINK_QUEUE_one": "**{{count}}** Paar wartet unter [Verknüpfungen](/audit/links).",
"AUTO_LINK_QUEUE_other": "**{{count}}** Paare warten in der [Verknüpfungs-Warteschlange](/audit/links)." "AUTO_LINK_QUEUE_other": "**{{count}}** Paare warten unter [Verknüpfungen](/audit/links)."
}, },
"ADMIN_HINTS": { "ADMIN_HINTS": {
"NEW": "Neuer Hinweis", "NEW": "Neuer Hinweis",
@@ -984,17 +996,26 @@
"AUTO_TITLE": "Automatische Hinweise", "AUTO_TITLE": "Automatische Hinweise",
"AUTO_NO_STEAM": "Kein Steam-Konto verknüpft", "AUTO_NO_STEAM": "Kein Steam-Konto verknüpft",
"AUTO_EMPTY_LIBRARY": "Leere Bibliothek", "AUTO_EMPTY_LIBRARY": "Leere Bibliothek",
"AUTO_PILE_GREW": "Der Stapel ist diesen Monat gewachsen", "AUTO_PILE_GREW": "Der Pile of Shame ist diesen Monat gewachsen",
"AUTO_LINK_QUEUE": "Offene Paare in der Verknüpfungs-Warteschlange (Auditoren)", "AUTO_LINK_QUEUE": "Offene Paare unter Verknüpfungen (Auditoren)",
"AUTO_SAVED": "Gespeichert." "AUTO_SAVED": "Gespeichert."
}, },
"COMMUNITY": {
"TAB_FOLLOWING": "Ich folge",
"TAB_FOLLOWERS": "Follower",
"TAB_PLAYERS": "Nutzer finden",
"TAB_STATS": "Community-Statistik",
"SEARCH_PLACEHOLDER": "Nutzer nach Name suchen",
"SEARCH_NONE": "Kein öffentliches Profil mit diesem Namen.",
"SEARCH_LIMITED": "Zu viele Suchen. Warte eine Minute.",
"SEARCH_FAILED": "Die Suche hat nicht geklappt. Versuche es noch einmal.",
"SEARCH_HINT": "Nur öffentliche Profile sind zu finden."
},
"FOLLOW": { "FOLLOW": {
"TITLE": "Folgen", "TITLE": "Ich folge",
"FOLLOW": "Folgen", "FOLLOW": "Folgen",
"UNFOLLOW": "Entfolgen", "UNFOLLOW": "Entfolgen",
"REMOVE": "Entfernen", "REMOVE": "Entfernen",
"FOLLOWING": "Personen, denen du folgst",
"FOLLOWERS": "Personen, die dir folgen",
"NO_FOLLOWING": "Du folgst noch niemandem. Öffne ein öffentliches Profil und tippe auf „Folgen“.", "NO_FOLLOWING": "Du folgst noch niemandem. Öffne ein öffentliches Profil und tippe auf „Folgen“.",
"NO_FOLLOWERS": "Dir folgt noch niemand.", "NO_FOLLOWERS": "Dir folgt noch niemand.",
"PRIVATE_USER": "Privater Nutzer", "PRIVATE_USER": "Privater Nutzer",
@@ -1004,7 +1025,8 @@
"LOAD_ERROR": "Die Liste konnte nicht geladen werden.", "LOAD_ERROR": "Die Liste konnte nicht geladen werden.",
"LAST_ACTIVITY": "Letzte Aktivität {{date}}", "LAST_ACTIVITY": "Letzte Aktivität {{date}}",
"NO_ACTIVITY": "Noch keine Aktivität", "NO_ACTIVITY": "Noch keine Aktivität",
"SHOW_ALL": "Alle anzeigen" "SHOW_ALL": "Alle anzeigen",
"FIND_PLAYERS": "Nutzer finden"
}, },
"INBOX": { "INBOX": {
"TITLE": "Postfach", "TITLE": "Postfach",
@@ -1031,12 +1053,13 @@
"DETAIL_WISHLIST": "Wunschliste", "DETAIL_WISHLIST": "Wunschliste",
"SHOW_ALL": "Alle {{count}} anzeigen", "SHOW_ALL": "Alle {{count}} anzeigen",
"DETAILS_GONE": "Keine Details mehr: du folgst dieser Person nicht mehr oder der Bereich ist jetzt privat.", "DETAILS_GONE": "Keine Details mehr: du folgst dieser Person nicht mehr oder der Bereich ist jetzt privat.",
"DETAILS_ERROR": "Die Details konnten nicht geladen werden." "DETAILS_ERROR": "Die Details konnten nicht geladen werden.",
"QUESTIONS_OPEN_one": "{{count}} offene Frage",
"QUESTIONS_OPEN_other": "{{count}} offene Fragen",
"ALL_NEWS": "Alle Neuigkeiten"
}, },
"NEWS": { "NEWS": {
"TITLE": "Neuigkeiten", "TITLE": "Neuigkeiten",
"GOT_IT": "Verstanden",
"SAVE_FAILED": "Konnte nicht speichern, dass du die Neuigkeiten gelesen hast.",
"LOAD_ERROR": "Die Neuigkeiten konnten nicht geladen werden.", "LOAD_ERROR": "Die Neuigkeiten konnten nicht geladen werden.",
"EMPTY": "Noch nichts Neues." "EMPTY": "Noch nichts Neues."
}, },
@@ -1048,7 +1071,7 @@
"FEATURE_STATUS": "Status", "FEATURE_STATUS": "Status",
"FEATURE_STATUS_TEXT": "Markiere ein Spiel als „Spiele ich“, „Durchgespielt“, „100 %“ oder „Abgebrochen“, mit Bewertung und Notizen.", "FEATURE_STATUS_TEXT": "Markiere ein Spiel als „Spiele ich“, „Durchgespielt“, „100 %“ oder „Abgebrochen“, mit Bewertung und Notizen.",
"FEATURE_STATS": "Deine Statistiken", "FEATURE_STATS": "Deine Statistiken",
"FEATURE_STATS_TEXT": "Summen, der Pile in Stunden, meistgespielte Spiele, Genres und durchgespielte Spiele pro Jahr.", "FEATURE_STATS_TEXT": "Summen, der Pile of Shame in Stunden, meistgespielte Spiele, Genres und durchgespielte Spiele pro Jahr.",
"FEATURE_PROFILE": "Öffentliches Profil", "FEATURE_PROFILE": "Öffentliches Profil",
"FEATURE_PROFILE_TEXT": "Teile deine Statistiken und was du spielst per Link. Aus, bis du es einschaltest.", "FEATURE_PROFILE_TEXT": "Teile deine Statistiken und was du spielst per Link. Aus, bis du es einschaltest.",
"FEATURE_IMPORT": "Import", "FEATURE_IMPORT": "Import",
@@ -1211,7 +1234,7 @@
"DONE": "Fertig: {{added}} Spiele hinzugefügt, {{skipped}} übersprungen.", "DONE": "Fertig: {{added}} Spiele hinzugefügt, {{skipped}} übersprungen.",
"FAILED_one": "Der Import ist nach {{count}} Versuch stehen geblieben.", "FAILED_one": "Der Import ist nach {{count}} Versuch stehen geblieben.",
"FAILED": "Der Import ist nach {{count}} Versuchen stehen geblieben.", "FAILED": "Der Import ist nach {{count}} Versuchen stehen geblieben.",
"RETRY": "Nochmal versuchen", "RETRY": "Noch einmal versuchen",
"RETRY_FAILED": "Der Import konnte nicht neu gestartet werden.", "RETRY_FAILED": "Der Import konnte nicht neu gestartet werden.",
"ATTEMPT": "Versuch {{attempt}}: der letzte Versuch kam nicht weiter.", "ATTEMPT": "Versuch {{attempt}}: der letzte Versuch kam nicht weiter.",
"SOURCE_GALAXY": "GOG Galaxy", "SOURCE_GALAXY": "GOG Galaxy",
@@ -1239,15 +1262,15 @@
"EQUIP": "Anlegen", "EQUIP": "Anlegen",
"UNEQUIP": "Ablegen", "UNEQUIP": "Ablegen",
"EQUIPPED": "Angelegt", "EQUIPPED": "Angelegt",
"EMPTY_HISTORY": "Noch keine Punkte. Spiel etwas von deinem Stapel!", "EMPTY_HISTORY": "Noch keine Punkte. Spiel etwas aus deinem Pile of Shame!",
"ERROR_NOT_ENOUGH": "Nicht genug Punkte.", "ERROR_NOT_ENOUGH": "Nicht genug Punkte.",
"HOW_TO": "So bekommst du Punkte", "HOW_TO": "So bekommst du Punkte",
"RULES": { "RULES": {
"STARTED": "5 Punkte, wenn du ein Spiel von deinem Stapel startest und spielst (Steam-Spiele, einmal pro Spiel).", "STARTED": "5 Punkte, wenn du ein Spiel aus deinem Pile of Shame startest und spielst (Steam-Spiele, einmal pro Spiel).",
"FINISHED": "30, 50 oder 80 Punkte (je nach Spieldauer), wenn du ein Steam-Spiel durchspielst und es einen Nachweis gibt, dass du es gespielt hast. Ohne Nachweis bekommst du sofort 10 Punkte und den Rest, sobald du das Spiel nach dem Durchspielen auf Steam spielst. Ist keine Spielzeit erfasst, gibt es auch erst 10 Punkte, den Rest nur mit Steam-Spielzeit. Einmal pro Spiel.", "FINISHED": "30, 50 oder 80 Punkte (je nach Spieldauer), wenn du ein Steam-Spiel durchspielst und es einen Nachweis gibt, dass du es gespielt hast. Ohne Nachweis bekommst du sofort 10 Punkte und den Rest, sobald du das Spiel nach dem Durchspielen auf Steam spielst. Ist keine Spielzeit erfasst, gibt es auch erst 10 Punkte, den Rest nur mit Steam-Spielzeit. Einmal pro Spiel.",
"COMPLETED": "+40 Punkte, wenn ein Spiel auf 100 % steht und du alle Erfolge freigeschaltet hast. Dafür braucht es ein nachgewiesenes Durchspielen auf Steam (die frühen 10 Punkte reichen erst, wenn der Nachweis da ist) oder Punkte fürs Durchspielen einer manuellen Edition des Spiels, wenn du die 100 % am {{date}} oder später erreicht hast; ein manuelles Spiel selbst bekommt diesen Bonus nie.", "COMPLETED": "+40 Punkte, wenn ein Spiel auf 100 % steht und du alle Erfolge freigeschaltet hast. Dafür braucht es ein nachgewiesenes Durchspielen auf Steam (die frühen 10 Punkte reichen erst, wenn der Nachweis da ist) oder Punkte fürs Durchspielen einer manuellen Edition des Spiels, wenn du die 100 % am {{date}} oder später erreicht hast; ein manuelles Spiel selbst bekommt diesen Bonus nie.",
"FINISHED_MANUAL": "15 Punkte, wenn du ein manuelles Spiel durchspielst. Höchstens 3 durchgespielte Spiele pro Woche (Montag bis Sonntag) bringen Punkte; manuelle Spiele und Steam-Spiele ohne Nachweis teilen sich dieses Limit. Ein 4. manuelles Spiel in dieser Woche bringt keine Punkte; ein 4. Steam-Spiel ohne Nachweis bekommt die vollen Punkte, sobald der Nachweis da ist.", "FINISHED_MANUAL": "15 Punkte, wenn du ein manuelles Spiel durchspielst. Höchstens 3 durchgespielte Spiele pro Woche (Montag bis Sonntag) bringen Punkte; manuelle Spiele und Steam-Spiele ohne Nachweis teilen sich dieses Limit. Ein 4. manuelles Spiel in dieser Woche bringt keine Punkte; ein 4. Steam-Spiel ohne Nachweis bekommt die vollen Punkte, sobald der Nachweis da ist.",
"MONTH": "40 Punkte, wenn dein Stapel am Monatsende kleiner ist als am Monatsanfang und du in dem Monat ein Spiel durchgespielt hast.", "MONTH": "40 Punkte, wenn dein Pile of Shame am Monatsende kleiner ist als am Monatsanfang und du in dem Monat ein Spiel durchgespielt hast.",
"STREAK": "20 Punkte für jede Woche, in der du ein Spiel durchspielst, wenn du auch in der Woche davor eins durchgespielt hast.", "STREAK": "20 Punkte für jede Woche, in der du ein Spiel durchspielst, wenn du auch in der Woche davor eins durchgespielt hast.",
"UNLOCK": "Gegenstände mit Schloss kann man nicht kaufen. Sie werden einmal und für immer freigeschaltet, wenn du ihr Ziel erreichst.", "UNLOCK": "Gegenstände mit Schloss kann man nicht kaufen. Sie werden einmal und für immer freigeschaltet, wenn du ihr Ziel erreichst.",
"PROOF": "Als Nachweis für ein Steam-Spiel zählt, dass du es kürzlich gespielt hast: der Erfolg für das Ende der Geschichte (höchstens 30 Tage vor dem Durchspielen freigeschaltet), Spielzeit ab 30 Tagen vor dem Durchspielen oder nach dem Durchspielen die halbe normale Spieldauer gespielt.", "PROOF": "Als Nachweis für ein Steam-Spiel zählt, dass du es kürzlich gespielt hast: der Erfolg für das Ende der Geschichte (höchstens 30 Tage vor dem Durchspielen freigeschaltet), Spielzeit ab 30 Tagen vor dem Durchspielen oder nach dem Durchspielen die halbe normale Spieldauer gespielt.",
@@ -1299,7 +1322,7 @@
"ITEMS": { "ITEMS": {
"BORDER_GOLD": "Goldener Avatarrahmen", "BORDER_GOLD": "Goldener Avatarrahmen",
"BORDER_NEON": "Neon-Avatarrahmen", "BORDER_NEON": "Neon-Avatarrahmen",
"BADGE_PILE_SLAYER": "Abzeichen Stapel-Bezwinger", "BADGE_PILE_SLAYER": "Abzeichen Pile Slayer",
"BADGE_COMPLETIONIST": "Abzeichen Komplettierer", "BADGE_COMPLETIONIST": "Abzeichen Komplettierer",
"ACCENT_GOLD": "Akzent Gold", "ACCENT_GOLD": "Akzent Gold",
"ACCENT_TEAL": "Akzent Türkis", "ACCENT_TEAL": "Akzent Türkis",
@@ -1362,7 +1385,7 @@
"FINISHED_EARLY": "Durchgespielt (frühe Punkte)", "FINISHED_EARLY": "Durchgespielt (frühe Punkte)",
"FINISHED_TOPUP": "Durchgespielt (Rest nach Nachweis)", "FINISHED_TOPUP": "Durchgespielt (Rest nach Nachweis)",
"COMPLETED": "100-%-Bonus", "COMPLETED": "100-%-Bonus",
"MONTH": "Kleinerer Stapel im", "MONTH": "Kleinerer Pile of Shame im",
"STREAK": "Serien-Woche", "STREAK": "Serien-Woche",
"STARTER": "Startpunkte", "STARTER": "Startpunkte",
"BUY": "Gekauft", "BUY": "Gekauft",
@@ -1373,9 +1396,12 @@
"GAMES_FINISHED": "{{count}} / {{target}} Spiele durchgespielt", "GAMES_FINISHED": "{{count}} / {{target}} Spiele durchgespielt",
"GAMES_COMPLETED": "{{count}} / {{target}} Spiele zu 100 %", "GAMES_COMPLETED": "{{count}} / {{target}} Spiele zu 100 %",
"FINISH_WEEKS": "{{count}} / {{target}} Wochen in Folge mit einem durchgespielten Spiel", "FINISH_WEEKS": "{{count}} / {{target}} Wochen in Folge mit einem durchgespielten Spiel",
"PILE_DOWN_MONTHS": "{{count}} / {{target}} Monate in Folge mit kleinerem Stapel", "PILE_DOWN_MONTHS": "{{count}} / {{target}} Monate in Folge mit kleinerem Pile of Shame",
"PLAY_MORE_MONTHS": "{{count}} / {{target}} Monate in Folge mit mehr Spielzeit als im Monat davor" "PLAY_MORE_MONTHS": "{{count}} / {{target}} Monate in Folge mit mehr Spielzeit als im Monat davor"
} },
"SUMMARY_START": "Spiel starten: +5",
"SUMMARY_FINISH": "Durchspielen: +30 bis +80, je nach Spieldauer",
"SUMMARY_COMPLETED": "100 %: +40. Mit Punkten kaufst du Looks."
}, },
"UNSAVED_CHANGES": { "UNSAVED_CHANGES": {
"CONFIRM": "Du hast Änderungen, die nicht gespeichert sind. Seite verlassen und die Änderungen verwerfen?" "CONFIRM": "Du hast Änderungen, die nicht gespeichert sind. Seite verlassen und die Änderungen verwerfen?"
@@ -1409,7 +1435,6 @@
} }
}, },
"REMOVED_GAMES": { "REMOVED_GAMES": {
"TITLE_COUNT": "Entfernte Spiele ({{count}})",
"RESTORE": "Wiederherstellen", "RESTORE": "Wiederherstellen",
"RESTORED": "Spiel wiederhergestellt.", "RESTORED": "Spiel wiederhergestellt.",
"HINT": "Wiederhergestellte Spiele behalten Status, Bewertung und Notizen." "HINT": "Wiederhergestellte Spiele behalten Status, Bewertung und Notizen."
@@ -1424,7 +1449,8 @@
"SKIP": "Überspringen", "SKIP": "Überspringen",
"START": { "START": {
"ASK": "Angefangen?", "ASK": "Angefangen?",
"REASON": "Du hast {{playtime}} gespielt, das Spiel liegt aber noch im Backlog." "REASON": "Du hast {{playtime}} gespielt, das Spiel liegt aber noch im Backlog.",
"POINTS": "+5 Punkte"
}, },
"FINISHED": { "FINISHED": {
"ASK": "Durchgespielt?", "ASK": "Durchgespielt?",
@@ -1439,6 +1465,9 @@
"RATE": { "RATE": {
"ASK": "Bewerten?", "ASK": "Bewerten?",
"REASON": "Du hast es durchgespielt. Wie war es?" "REASON": "Du hast es durchgespielt. Wie war es?"
} },
"INTRO": "Fragen entstehen aus deiner Spielzeit und deinen Status. Eine Antwort hält deine Bibliothek aktuell und bringt Punkte. Ein „Nein“ gilt nicht für immer: Die Frage zum Starten oder Durchspielen kommt nach 5 weiteren Spielstunden wieder, die Frage zum Abbrechen nach 90 Tagen ohne Spiel. Eine übersprungene Bewertung kommt nicht wieder.",
"POINTS_PENDING": "Punkte je nach Spieldauer",
"NO_POINTS": "Keine Punkte, aber deine Bibliothek bleibt aktuell."
} }
} }
+93 -64
View File
@@ -14,8 +14,8 @@
"ERROR_EMAIL": "Enter an email address with '@'.", "ERROR_EMAIL": "Enter an email address with '@'.",
"ERROR_PASSWORD": "The password must not be empty.", "ERROR_PASSWORD": "The password must not be empty.",
"ERROR_INVALID_CREDENTIALS": "Email or password is wrong.", "ERROR_INVALID_CREDENTIALS": "Email or password is wrong.",
"ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes.", "ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Try again in {{minutes}} minutes.",
"ERROR_GENERIC": "Login failed. Please try again later.", "ERROR_GENERIC": "Login failed. Try again later.",
"SESSION_EXPIRED": "Your session has expired. Please log in again.", "SESSION_EXPIRED": "Your session has expired. Please log in again.",
"ERROR_ACCOUNT_BLOCKED": "This account is blocked. Please contact the admin.", "ERROR_ACCOUNT_BLOCKED": "This account is blocked. Please contact the admin.",
"ERROR_ACCOUNT_PENDING": "Your registration is waiting for the admin. You will get an email once it is decided.", "ERROR_ACCOUNT_PENDING": "Your registration is waiting for the admin. You will get an email once it is decided.",
@@ -35,13 +35,13 @@
"RESEND": "Send the link again", "RESEND": "Send the link again",
"RESEND_SENT": "If this address has an unconfirmed registration, a new link is on its way.", "RESEND_SENT": "If this address has an unconfirmed registration, a new link is on its way.",
"HAVE_ACCOUNT": "Already have an account?", "HAVE_ACCOUNT": "Already have an account?",
"ERROR_GENERIC": "Registration failed. Please try again.", "ERROR_GENERIC": "Registration failed. Try again.",
"ERROR_EMAIL_IN_USE": "This email address is already registered.", "ERROR_EMAIL_IN_USE": "This email address is already registered.",
"DISPLAY_NAME": "Display name (optional)", "DISPLAY_NAME": "Display name (optional)",
"ERROR_DISPLAY_NAME_IN_USE": "This display name is already taken.", "ERROR_DISPLAY_NAME_IN_USE": "This display name is already taken.",
"ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes.", "ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Try again in {{minutes}} minutes.",
"INTRO": "You will get an email with a link: there you confirm your address and choose your password. Then an admin checks your account and emails you the decision.", "INTRO": "You will get an email with a link: there you confirm your address and choose your password. Then an admin checks your account and emails you the decision.",
"ERROR_PENDING_LIMIT": "Too many registrations are waiting for the admin right now. Please try again later." "ERROR_PENDING_LIMIT": "Too many registrations are waiting for the admin right now. Try again later."
}, },
"RESET_PASSWORD": { "RESET_PASSWORD": {
"TITLE": "Reset password", "TITLE": "Reset password",
@@ -54,8 +54,8 @@
"TO_LOGIN": "Log in", "TO_LOGIN": "Log in",
"INVALID": "This link is unknown, already used or older than 1 hour. Request a new one:", "INVALID": "This link is unknown, already used or older than 1 hour. Request a new one:",
"ERROR_REJECTED": "The server did not accept this password. Check the rules and try again.", "ERROR_REJECTED": "The server did not accept this password. Check the rules and try again.",
"ERROR_TOO_MANY": "Too many attempts. Please try again in {{minutes}} minutes.", "ERROR_TOO_MANY": "Too many attempts. Try again in {{minutes}} minutes.",
"ERROR_GENERIC": "That did not work. Please try again." "ERROR_GENERIC": "That did not work. Try again."
}, },
"CONFIRM_EMAIL": { "CONFIRM_EMAIL": {
"TITLE": "Confirm your email address", "TITLE": "Confirm your email address",
@@ -75,7 +75,7 @@
"TITLE": "Settings", "TITLE": "Settings",
"LOADING": "Loading settings …", "LOADING": "Loading settings …",
"ACCOUNT": "Account", "ACCOUNT": "Account",
"PREFERENCES": "Preferences", "PREFERENCES": "Language & platform",
"STEAM": "Steam", "STEAM": "Steam",
"EMAIL": "Email", "EMAIL": "Email",
"ROLE": "Role", "ROLE": "Role",
@@ -148,7 +148,7 @@
"LINKED": "Steam account linked.", "LINKED": "Steam account linked.",
"IN_USE": "This Steam account is already linked to another account.", "IN_USE": "This Steam account is already linked to another account.",
"CANCELLED": "Steam link cancelled.", "CANCELLED": "Steam link cancelled.",
"FAILED": "Steam could not confirm the link. Please try again.", "FAILED": "Steam could not confirm the link. Try again.",
"ERROR": "Could not start the Steam link.", "ERROR": "Could not start the Steam link.",
"UNLINKED": "Steam account unlinked." "UNLINKED": "Steam account unlinked."
}, },
@@ -173,7 +173,7 @@
"UNLINK_CONFIRM": "Yes, unlink", "UNLINK_CONFIRM": "Yes, unlink",
"UNLINKED": "PlayStation account unlinked. Imported games stay in your library.", "UNLINKED": "PlayStation account unlinked. Imported games stay in your library.",
"INVALID": "PlayStation did not accept the code. It may have expired: sign in again and copy a new one.", "INVALID": "PlayStation did not accept the code. It may have expired: sign in again and copy a new one.",
"UNAVAILABLE": "PlayStation did not answer. Please try again later.", "UNAVAILABLE": "PlayStation did not answer. Try again later.",
"BUSY": "Another of your PlayStation requests is still running. Wait a moment and try again.", "BUSY": "Another of your PlayStation requests is still running. Wait a moment and try again.",
"ERROR": "Something went wrong with the PlayStation link." "ERROR": "Something went wrong with the PlayStation link."
}, },
@@ -201,15 +201,22 @@
"CANCEL": "Cancel", "CANCEL": "Cancel",
"DONE": "Your account is deleted.", "DONE": "Your account is deleted.",
"WRONG_PASSWORD": "The password is wrong.", "WRONG_PASSWORD": "The password is wrong.",
"TOO_MANY": "Too many wrong passwords. Please try again in {{minutes}} minutes.", "TOO_MANY": "Too many wrong passwords. Try again in {{minutes}} minutes.",
"ERROR": "The account could not be deleted. Please try again later.", "ERROR": "The account could not be deleted. Try again later.",
"LAST_ADMIN": "You are the last admin. Make another user admin first." "LAST_ADMIN": "You are the last admin. Make another user admin first."
}, },
"PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\".", "PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\".",
"FAVORITE_PLATFORM": "Favorite platform", "FAVORITE_PLATFORM": "Favorite platform",
"FAVORITE_PLATFORM_HINT": "Selected first when you add a game on more than one platform.", "FAVORITE_PLATFORM_HINT": "Selected first when you add a game on more than one platform.",
"FAVORITE_PLATFORM_NONE": "None", "FAVORITE_PLATFORM_NONE": "None",
"SKIN_OVERRIDES": "Your skin sets colors and corners. Take it off on the Pile Points page." "SKIN_OVERRIDES": "Your skin sets colors and corners. Take it off on the Pile Points page.",
"PROFILE_STYLE_PRIVATE": "Your profile is private. Make it public above to change the look.",
"TAB_ACCOUNT": "Account",
"TAB_APPEARANCE": "Appearance",
"TAB_CONNECTIONS": "Connections",
"TAB_PROFILE": "Profile",
"TAB_ABOUT": "About",
"OPEN_COMMUNITY": "Manage who you follow on the Community page"
}, },
"VALIDATION": { "VALIDATION": {
"EMAIL_REQUIRED": "Email is required.", "EMAIL_REQUIRED": "Email is required.",
@@ -246,15 +253,15 @@
}, },
"CURRENTLY_PLAYING": "Currently playing", "CURRENTLY_PLAYING": "Currently playing",
"NOTHING_PLAYING": "Nothing right now. Set a game to \"Playing\".", "NOTHING_PLAYING": "Nothing right now. Set a game to \"Playing\".",
"UP_NEXT": "Up next", "UP_NEXT": "Queue",
"UP_NEXT_EMPTY": "Your backlog is empty. Nice!", "UP_NEXT_EMPTY": "Your queue is empty. Nice!",
"PILE_OF_SHAME": "Pile of Shame", "PILE_OF_SHAME": "Pile of Shame",
"PILE_SHARE": "{{percent}}% of your library", "PILE_SHARE": "{{percent}}% of your library",
"NEVER_PLAYED_one": "{{count}} game never started", "NEVER_PLAYED_one": "{{count}} game never started",
"NEVER_PLAYED_other": "{{count}} games never started", "NEVER_PLAYED_other": "{{count}} games never started",
"SUGGESTION": "How about", "SUGGESTION": "How about",
"OTHER_SUGGESTION": "Another pick", "OTHER_SUGGESTION": "Another pick",
"BACKLOG_EMPTY": "Your backlog is empty. Nice!", "BACKLOG_EMPTY": "Your Pile of Shame is empty. Nice!",
"SUMMARY_FAILED": "Could not load the overview.", "SUMMARY_FAILED": "Could not load the overview.",
"RETRY": "Try again", "RETRY": "Try again",
"ACTIVITY": "Playtime", "ACTIVITY": "Playtime",
@@ -301,16 +308,16 @@
"RESYNC": "Sync now", "RESYNC": "Sync now",
"SYNC_STARTED": "Sync started …", "SYNC_STARTED": "Sync started …",
"SYNC_COOLDOWN": "Try again in {{seconds}}s", "SYNC_COOLDOWN": "Try again in {{seconds}}s",
"SYNCING": "Syncing your library …", "SYNCING": "Syncing …",
"SYNCING_FIRST_TIME": "First sync in progress. Your games will appear here shortly.", "SYNCING_FIRST_TIME": "First sync in progress. Your games will appear here shortly.",
"SYNC_QUEUED": "First sync queued …", "SYNC_QUEUED": "First sync queued …",
"SYNC_DURATION_HINT": "Usually under a minute, large libraries longer.", "SYNC_DURATION_HINT": "Usually under a minute, large libraries longer.",
"SYNC_FAILED": "Sync failed. Please try again later.", "SYNC_FAILED": "Sync failed. Try again later.",
"SYNC_STALE": "The sync is still running. Large libraries need more time. Check back later.", "SYNC_STALE": "The sync is still running. Large libraries need more time. Check back later.",
"SYNC_NOT_STARTED": "The first sync did not start. Try \"Sync now\".", "SYNC_NOT_STARTED": "The first sync did not start. Try \"Sync now\".",
"LAST_SYNCED": "Last synced {{when}}", "LAST_SYNCED": "Last sync {{when}}",
"FILTER_ALL": "All", "FILTER_ALL": "All",
"SEARCH_PLACEHOLDER": "Search games …", "SEARCH_PLACEHOLDER": "Search your library …",
"NO_MATCH": "No games match this filter.", "NO_MATCH": "No games match this filter.",
"SORT_RATING": "Best rated", "SORT_RATING": "Best rated",
"SORT_PRIORITY": "Highest priority", "SORT_PRIORITY": "Highest priority",
@@ -318,7 +325,10 @@
"FILTER_HIDDEN": "Hidden", "FILTER_HIDDEN": "Hidden",
"NO_HIDDEN": "No hidden games. Hide a game on its detail page, e.g. a playtest or a tool.", "NO_HIDDEN": "No hidden games. Hide a game on its detail page, e.g. a playtest or a tool.",
"ACHIEVEMENTS": "{{unlocked}} of {{total}} achievements", "ACHIEVEMENTS": "{{unlocked}} of {{total}} achievements",
"EDITIONS": "{{count}} editions in your library" "EDITIONS": "{{count}} editions in your library",
"FILTER_REMOVED": "Removed",
"NO_REMOVED": "No removed games.",
"FIND_GAMES_INSTEAD": "Look in Find games"
}, },
"GAME_DETAIL": { "GAME_DETAIL": {
"MORE": "More actions", "MORE": "More actions",
@@ -393,7 +403,7 @@
}, },
"MAIN": { "MAIN": {
"WELCOME": "Control your game backlog", "WELCOME": "Control your game backlog",
"DESCRIPTION": "Organize your pile of shame. Track, manage, and conquer your gaming backlog like never before.", "DESCRIPTION": "Organize your Pile of Shame. Track, manage, and conquer your gaming backlog like never before.",
"GET_STARTED": "Get Started", "GET_STARTED": "Get Started",
"APPROVAL_HINT": "An admin approves every new account before the first login.", "APPROVAL_HINT": "An admin approves every new account before the first login.",
"LOGIN": "Log in", "LOGIN": "Log in",
@@ -402,7 +412,6 @@
"NAV": { "NAV": {
"DASHBOARD": "Dashboard", "DASHBOARD": "Dashboard",
"LIBRARY": "Library", "LIBRARY": "Library",
"SEARCH": "Search",
"SETTINGS": "Settings", "SETTINGS": "Settings",
"TO_DASHBOARD": "Go to dashboard", "TO_DASHBOARD": "Go to dashboard",
"MENU": "Menu", "MENU": "Menu",
@@ -411,7 +420,6 @@
"LIGHT_MODE": "Light mode", "LIGHT_MODE": "Light mode",
"PLAY_NEXT": "Play next", "PLAY_NEXT": "Play next",
"STATS": "Stats", "STATS": "Stats",
"FOLLOWING": "Following",
"COMMUNITY": "Community", "COMMUNITY": "Community",
"POINTS": "Pile Points", "POINTS": "Pile Points",
"ADMIN": "Admin", "ADMIN": "Admin",
@@ -424,9 +432,6 @@
"INBOX": "Inbox", "INBOX": "Inbox",
"INBOX_UNREAD_one": "Inbox, {{count}} new message", "INBOX_UNREAD_one": "Inbox, {{count}} new message",
"INBOX_UNREAD_other": "Inbox, {{count}} new messages", "INBOX_UNREAD_other": "Inbox, {{count}} new messages",
"QUESTIONS": "Questions",
"QUESTIONS_OPEN_one": "Questions, {{count}} open",
"QUESTIONS_OPEN_other": "Questions, {{count}} open",
"WHATS_NEW": "What's new", "WHATS_NEW": "What's new",
"ADMIN_NEWS": "Manage news", "ADMIN_NEWS": "Manage news",
"ADMIN_BROADCASTS": "Broadcast email", "ADMIN_BROADCASTS": "Broadcast email",
@@ -434,10 +439,11 @@
"PENDING_REQUESTS_other": "{{count}} registrations waiting", "PENDING_REQUESTS_other": "{{count}} registrations waiting",
"LINK_QUEUE": "Link queue", "LINK_QUEUE": "Link queue",
"AUDIT_LOG": "Audit log", "AUDIT_LOG": "Audit log",
"GAME_CATALOG": "Game catalog" "GAME_CATALOG": "Game catalog",
"MY_PROFILE": "My public profile"
}, },
"BACKLOG": { "BACKLOG": {
"TITLE": "My backlog", "TITLE": "My progress",
"STATUS_LABEL": "Status", "STATUS_LABEL": "Status",
"PRIORITY_LABEL": "Priority", "PRIORITY_LABEL": "Priority",
"RATING_LABEL": "Rating", "RATING_LABEL": "Rating",
@@ -448,7 +454,7 @@
"SAVE": "Save", "SAVE": "Save",
"RESET": "Discard", "RESET": "Discard",
"SAVED": "Saved", "SAVED": "Saved",
"SAVE_FAILED": "Saving failed. Please try again.", "SAVE_FAILED": "Saving failed. Try again.",
"STATUS": { "STATUS": {
"BACKLOG": "Backlog", "BACKLOG": "Backlog",
"PLAYING": "Playing", "PLAYING": "Playing",
@@ -464,10 +470,10 @@
} }
}, },
"BACKLOG_ORDER": { "BACKLOG_ORDER": {
"TITLE": "Backlog", "TITLE": "Queue",
"SUBTITLE": "Your backlog in your own order. Drag a game, or use the arrows and the menu. The first games show up under \"Play next\" and on the dashboard.", "SUBTITLE": "Your backlog games in your own order. Drag a game, or use the arrows and the menu. The first games show up under \"Play next\" and on the dashboard.",
"EMPTY": "Your backlog is empty. Nice!", "EMPTY": "Your queue is empty. Nice!",
"LOAD_FAILED": "Could not load the backlog.", "LOAD_FAILED": "Could not load the queue.",
"SAVE_FAILED": "Could not save the order. Reloading the list.", "SAVE_FAILED": "Could not save the order. Reloading the list.",
"MOVE_UP": "Move up", "MOVE_UP": "Move up",
"MOVE_DOWN": "Move down", "MOVE_DOWN": "Move down",
@@ -499,11 +505,15 @@
"PERFECT_GAMES": "Perfect games", "PERFECT_GAMES": "Perfect games",
"AVERAGE_COMPLETION": "Avg. completion", "AVERAGE_COMPLETION": "Avg. completion",
"GAME_NOT_SHOWN": "This game is not shown on this profile.", "GAME_NOT_SHOWN": "This game is not shown on this profile.",
"GAME_ACHIEVEMENTS": "{{unlocked}} / {{total}} ({{percent}}%)" "GAME_ACHIEVEMENTS": "{{unlocked}} / {{total}} ({{percent}}%)",
"OWN_PRIVATE": "Your profile is private. Make it public in Settings.",
"OPEN_SETTINGS": "Open settings",
"PUBLIC_CHIP": "Public",
"COPY_LINK": "Copy link"
}, },
"PLAY_NEXT": { "PLAY_NEXT": {
"TITLE": "What to play next", "TITLE": "What to play next",
"SUBTITLE": "Games from your backlog in seven rows: next in your order, the next part of a series you played, like games you played a lot, from a studio you played, what people you follow played, waiting longest, and by mood. Pick a length for all rows and a mood for the last one.", "SUBTITLE": "Games from your backlog in seven rows: your queue, the next part of a series you played, like games you played a lot, from a studio you played, what people you follow played, waiting longest, and by mood. Pick a length for all rows and a mood for the last one.",
"REASON": { "REASON": {
"SIMILAR": "Because you played {{name}} ({{playtime}})", "SIMILAR": "Because you played {{name}} ({{playtime}})",
"SERIES": "Series: {{name}}", "SERIES": "Series: {{name}}",
@@ -513,7 +523,7 @@
"LONGEST": "In your library since {{date}}" "LONGEST": "In your library since {{date}}"
}, },
"CATEGORY": { "CATEGORY": {
"NEXT_UP": "Up next", "NEXT_UP": "Queue",
"SERIES": "Next in series", "SERIES": "Next in series",
"SIMILAR": "Because you played", "SIMILAR": "Because you played",
"STUDIO": "Same studio", "STUDIO": "Same studio",
@@ -596,7 +606,7 @@
}, },
"PILE": { "PILE": {
"TITLE": "Pile of Shame", "TITLE": "Pile of Shame",
"GAMES": "Games on the pile", "GAMES": "Games in the Pile of Shame",
"NORMAL": "To finish them", "NORMAL": "To finish them",
"COMPLETIONIST": "To get them to 100%", "COMPLETIONIST": "To get them to 100%",
"KNOWN_one": "IGDB knows {{count}} of them", "KNOWN_one": "IGDB knows {{count}} of them",
@@ -608,10 +618,12 @@
"SINCE": "since {{date}}" "SINCE": "since {{date}}"
}, },
"TREND": { "TREND": {
"TITLE": "Pile trend", "TITLE": "Pile of Shame trend",
"THIS_MONTH": "This month:", "THIS_MONTH": "This month:",
"THIS_MONTH_ADDED": "+{{added}} added", "THIS_MONTH_ADDED": "+{{added}} added",
"THIS_MONTH_DONE": "−{{done}} off the pile", "THIS_MONTH_DONE": "−{{done}} off the Pile of Shame",
"NET_one": "Net: {{net}} game in 90 days",
"NET_other": "Net: {{net}} games in 90 days",
"STARTS": "The trend starts today. Come back tomorrow for the first line." "STARTS": "The trend starts today. Come back tomorrow for the first line."
}, },
"TOP_GAMES": { "TOP_GAMES": {
@@ -695,9 +707,9 @@
"IN_LIBRARIES_one": "in {{count}} library", "IN_LIBRARIES_one": "in {{count}} library",
"IN_LIBRARIES_other": "in {{count}} libraries", "IN_LIBRARIES_other": "in {{count}} libraries",
"BEFORE_TRACKING": "Finished before the status history started", "BEFORE_TRACKING": "Finished before the status history started",
"TREND_MOVES": "Pile moves in the last 90 days", "TREND_MOVES": "Pile of Shame changes in the last 90 days",
"TREND_ADDED": "Added to the pile this month", "TREND_ADDED": "Added to the Pile of Shame this month",
"TREND_DONE": "Off the pile this month", "TREND_DONE": "Off the Pile of Shame this month",
"NEW_IN_LIBRARY": "New", "NEW_IN_LIBRARY": "New",
"GAMES_one": "{{count}} game", "GAMES_one": "{{count}} game",
"GAMES_other": "{{count}} games" "GAMES_other": "{{count}} games"
@@ -934,10 +946,10 @@
"DO_NOT_SHOW_AGAIN": "Do not show again", "DO_NOT_SHOW_AGAIN": "Do not show again",
"OK": "OK", "OK": "OK",
"SAVE_FAILED": "The hint could not be saved.", "SAVE_FAILED": "The hint could not be saved.",
"AUTO_NO_STEAM": "[Link your Steam account](/settings) to sync your library.", "AUTO_NO_STEAM": "[Link your Steam account](/settings?tab=connections) to sync your library.",
"AUTO_EMPTY_LIBRARY": "Your library is still empty. [Import your games](/import).", "AUTO_EMPTY_LIBRARY": "Your library is still empty. [Import your games](/import).",
"AUTO_PILE_GREW_one": "Your pile grew by **{{count}}** game this month. [See the trend](/stats).", "AUTO_PILE_GREW_one": "Your Pile of Shame grew by **{{count}}** game this month. [See the trend](/stats).",
"AUTO_PILE_GREW_other": "Your pile grew by **{{count}}** games this month. [See the trend](/stats).", "AUTO_PILE_GREW_other": "Your Pile of Shame grew by **{{count}}** games this month. [See the trend](/stats).",
"AUTO_LINK_QUEUE_one": "**{{count}}** pair is waiting in the [link queue](/audit/links).", "AUTO_LINK_QUEUE_one": "**{{count}}** pair is waiting in the [link queue](/audit/links).",
"AUTO_LINK_QUEUE_other": "**{{count}}** pairs are waiting in the [link queue](/audit/links)." "AUTO_LINK_QUEUE_other": "**{{count}}** pairs are waiting in the [link queue](/audit/links)."
}, },
@@ -984,17 +996,26 @@
"AUTO_TITLE": "Automatic hints", "AUTO_TITLE": "Automatic hints",
"AUTO_NO_STEAM": "No Steam account linked", "AUTO_NO_STEAM": "No Steam account linked",
"AUTO_EMPTY_LIBRARY": "Empty library", "AUTO_EMPTY_LIBRARY": "Empty library",
"AUTO_PILE_GREW": "The pile grew this month", "AUTO_PILE_GREW": "The Pile of Shame grew this month",
"AUTO_LINK_QUEUE": "Open pairs in the link queue (auditors)", "AUTO_LINK_QUEUE": "Open pairs in the link queue (auditors)",
"AUTO_SAVED": "Saved." "AUTO_SAVED": "Saved."
}, },
"COMMUNITY": {
"TAB_FOLLOWING": "Following",
"TAB_FOLLOWERS": "Followers",
"TAB_PLAYERS": "Find users",
"TAB_STATS": "Community stats",
"SEARCH_PLACEHOLDER": "Search users by name",
"SEARCH_NONE": "No public profile with this name.",
"SEARCH_LIMITED": "Too many searches. Wait a minute.",
"SEARCH_FAILED": "The search did not work. Try again.",
"SEARCH_HINT": "Only public profiles can be found."
},
"FOLLOW": { "FOLLOW": {
"TITLE": "Following", "TITLE": "Following",
"FOLLOW": "Follow", "FOLLOW": "Follow",
"UNFOLLOW": "Unfollow", "UNFOLLOW": "Unfollow",
"REMOVE": "Remove", "REMOVE": "Remove",
"FOLLOWING": "People you follow",
"FOLLOWERS": "People who follow you",
"NO_FOLLOWING": "You are not following anyone yet. Open a public profile and press \"Follow\".", "NO_FOLLOWING": "You are not following anyone yet. Open a public profile and press \"Follow\".",
"NO_FOLLOWERS": "Nobody follows you yet.", "NO_FOLLOWERS": "Nobody follows you yet.",
"PRIVATE_USER": "Private user", "PRIVATE_USER": "Private user",
@@ -1004,7 +1025,8 @@
"LOAD_ERROR": "The list could not be loaded.", "LOAD_ERROR": "The list could not be loaded.",
"LAST_ACTIVITY": "Last activity {{date}}", "LAST_ACTIVITY": "Last activity {{date}}",
"NO_ACTIVITY": "No activity yet", "NO_ACTIVITY": "No activity yet",
"SHOW_ALL": "Show all" "SHOW_ALL": "Show all",
"FIND_PLAYERS": "Find users"
}, },
"INBOX": { "INBOX": {
"TITLE": "Inbox", "TITLE": "Inbox",
@@ -1031,12 +1053,13 @@
"DETAIL_WISHLIST": "Wishlist", "DETAIL_WISHLIST": "Wishlist",
"SHOW_ALL": "Show all {{count}}", "SHOW_ALL": "Show all {{count}}",
"DETAILS_GONE": "No details any more: you no longer follow this person or the part is private now.", "DETAILS_GONE": "No details any more: you no longer follow this person or the part is private now.",
"DETAILS_ERROR": "The details could not be loaded." "DETAILS_ERROR": "The details could not be loaded.",
"QUESTIONS_OPEN_one": "{{count}} open question",
"QUESTIONS_OPEN_other": "{{count}} open questions",
"ALL_NEWS": "All news"
}, },
"NEWS": { "NEWS": {
"TITLE": "What's new", "TITLE": "What's new",
"GOT_IT": "Got it",
"SAVE_FAILED": "Could not save that you read the news.",
"LOAD_ERROR": "The news could not be loaded.", "LOAD_ERROR": "The news could not be loaded.",
"EMPTY": "Nothing new yet." "EMPTY": "Nothing new yet."
}, },
@@ -1044,11 +1067,11 @@
"TITLE": "Welcome to ToBePlayed", "TITLE": "Welcome to ToBePlayed",
"INTRO": "ToBePlayed keeps your games in one place: what you own, what you play, what is next.", "INTRO": "ToBePlayed keeps your games in one place: what you own, what you play, what is next.",
"FEATURE_LIBRARY": "Library and Pile of Shame", "FEATURE_LIBRARY": "Library and Pile of Shame",
"FEATURE_LIBRARY_TEXT": "All your games with playtime, filters and search. The pile shows what you never started.", "FEATURE_LIBRARY_TEXT": "All your games with playtime, filters and search. The Pile of Shame shows what you never started.",
"FEATURE_STATUS": "Statuses", "FEATURE_STATUS": "Statuses",
"FEATURE_STATUS_TEXT": "Mark a game as \"Playing\", \"Finished\", \"100%\" or \"Dropped\", with rating and notes.", "FEATURE_STATUS_TEXT": "Mark a game as \"Playing\", \"Finished\", \"100%\" or \"Dropped\", with rating and notes.",
"FEATURE_STATS": "Your stats", "FEATURE_STATS": "Your stats",
"FEATURE_STATS_TEXT": "Totals, the pile in hours, most played games, genres and finished games per year.", "FEATURE_STATS_TEXT": "Totals, the Pile of Shame in hours, most played games, genres and finished games per year.",
"FEATURE_PROFILE": "Public profile", "FEATURE_PROFILE": "Public profile",
"FEATURE_PROFILE_TEXT": "Share your stats and what you play with a link. Off until you switch it on.", "FEATURE_PROFILE_TEXT": "Share your stats and what you play with a link. Off until you switch it on.",
"FEATURE_IMPORT": "Import", "FEATURE_IMPORT": "Import",
@@ -1239,15 +1262,15 @@
"EQUIP": "Equip", "EQUIP": "Equip",
"UNEQUIP": "Take off", "UNEQUIP": "Take off",
"EQUIPPED": "Equipped", "EQUIPPED": "Equipped",
"EMPTY_HISTORY": "No points yet. Play a game from your pile!", "EMPTY_HISTORY": "No points yet. Play a game from your Pile of Shame!",
"ERROR_NOT_ENOUGH": "Not enough points.", "ERROR_NOT_ENOUGH": "Not enough points.",
"HOW_TO": "How to earn points", "HOW_TO": "How to earn points",
"RULES": { "RULES": {
"STARTED": "5 points when you start a game from your pile and play it (Steam games, once per game).", "STARTED": "5 points when you start a game from your Pile of Shame and play it (Steam games, once per game).",
"FINISHED": "30, 50 or 80 points (by time to beat) when you finish a Steam game and there is proof that you played it. Without proof you get 10 points now and the rest once you play the game on Steam after finishing it. With no playtime on record you also get 10 points first, and the rest only with Steam playtime. Once per game.", "FINISHED": "30, 50 or 80 points (by time to beat) when you finish a Steam game and there is proof that you played it. Without proof you get 10 points now and the rest once you play the game on Steam after finishing it. With no playtime on record you also get 10 points first, and the rest only with Steam playtime. Once per game.",
"COMPLETED": "+40 points when a game is at 100% and you unlocked all achievements. It needs a proven Steam finish (the early 10 points count only once proof arrives) or finish points for a manual edition of the game, when you reached 100% on or after {{date}}; a manual game itself never gets this bonus.", "COMPLETED": "+40 points when a game is at 100% and you unlocked all achievements. It needs a proven Steam finish (the early 10 points count only once proof arrives) or finish points for a manual edition of the game, when you reached 100% on or after {{date}}; a manual game itself never gets this bonus.",
"FINISHED_MANUAL": "15 points when you finish a manual game. At most 3 finishes per week (Monday to Sunday) earn points; manual games and Steam games without proof share this limit. A 4th manual finish in that week earns nothing; a 4th Steam finish without proof still earns the full points once proven.", "FINISHED_MANUAL": "15 points when you finish a manual game. At most 3 finishes per week (Monday to Sunday) earn points; manual games and Steam games without proof share this limit. A 4th manual finish in that week earns nothing; a 4th Steam finish without proof still earns the full points once proven.",
"MONTH": "40 points when your pile is smaller at the end of a month than at its start and you finished a game that month.", "MONTH": "40 points when your Pile of Shame is smaller at the end of a month than at its start and you finished a game that month.",
"STREAK": "20 points for every week in which you finish a game, if you also finished one the week before.", "STREAK": "20 points for every week in which you finish a game, if you also finished one the week before.",
"UNLOCK": "Items with a lock cannot be bought. They unlock once, for good, when you reach their goal.", "UNLOCK": "Items with a lock cannot be bought. They unlock once, for good, when you reach their goal.",
"PROOF": "A Steam finish needs proof of recent play: the ending achievement (unlocked at most 30 days before the finish), any playtime from 30 days before the finish on, or half the normal time to beat played after it.", "PROOF": "A Steam finish needs proof of recent play: the ending achievement (unlocked at most 30 days before the finish), any playtime from 30 days before the finish on, or half the normal time to beat played after it.",
@@ -1362,7 +1385,7 @@
"FINISHED_EARLY": "Finished (early points)", "FINISHED_EARLY": "Finished (early points)",
"FINISHED_TOPUP": "Finished (rest after proof)", "FINISHED_TOPUP": "Finished (rest after proof)",
"COMPLETED": "100% bonus", "COMPLETED": "100% bonus",
"MONTH": "Smaller pile in", "MONTH": "Smaller Pile of Shame in",
"STREAK": "Streak week", "STREAK": "Streak week",
"STARTER": "Starter points", "STARTER": "Starter points",
"BUY": "Bought", "BUY": "Bought",
@@ -1373,9 +1396,12 @@
"GAMES_FINISHED": "{{count}} / {{target}} games finished", "GAMES_FINISHED": "{{count}} / {{target}} games finished",
"GAMES_COMPLETED": "{{count}} / {{target}} games at 100%", "GAMES_COMPLETED": "{{count}} / {{target}} games at 100%",
"FINISH_WEEKS": "{{count}} / {{target}} weeks in a row with a finished game", "FINISH_WEEKS": "{{count}} / {{target}} weeks in a row with a finished game",
"PILE_DOWN_MONTHS": "{{count}} / {{target}} months in a row with a smaller pile", "PILE_DOWN_MONTHS": "{{count}} / {{target}} months in a row with a smaller Pile of Shame",
"PLAY_MORE_MONTHS": "{{count}} / {{target}} months in a row with more playtime than the month before" "PLAY_MORE_MONTHS": "{{count}} / {{target}} months in a row with more playtime than the month before"
} },
"SUMMARY_START": "Start a game: +5",
"SUMMARY_FINISH": "Finish a game: +30 to +80 by time to beat",
"SUMMARY_COMPLETED": "100%: +40. Points buy looks."
}, },
"UNSAVED_CHANGES": { "UNSAVED_CHANGES": {
"CONFIRM": "You have changes that are not saved. Leave this page and lose them?" "CONFIRM": "You have changes that are not saved. Leave this page and lose them?"
@@ -1409,7 +1435,6 @@
} }
}, },
"REMOVED_GAMES": { "REMOVED_GAMES": {
"TITLE_COUNT": "Removed games ({{count}})",
"RESTORE": "Restore", "RESTORE": "Restore",
"RESTORED": "Game restored.", "RESTORED": "Game restored.",
"HINT": "Restored games keep their status, rating and notes." "HINT": "Restored games keep their status, rating and notes."
@@ -1424,7 +1449,8 @@
"SKIP": "Skip", "SKIP": "Skip",
"START": { "START": {
"ASK": "Start it?", "ASK": "Start it?",
"REASON": "You played {{playtime}}, but it is still in your backlog." "REASON": "You played {{playtime}}, but it is still in your backlog.",
"POINTS": "+5 points"
}, },
"FINISHED": { "FINISHED": {
"ASK": "Finished?", "ASK": "Finished?",
@@ -1439,6 +1465,9 @@
"RATE": { "RATE": {
"ASK": "Rate it?", "ASK": "Rate it?",
"REASON": "You finished it. How was it?" "REASON": "You finished it. How was it?"
} },
"INTRO": "Questions come from your playtime and your statuses. An answer keeps your library current and can earn points. A \"No\" is not forever: a Start or Finished question comes back after 5 more hours of play, a Dropped question after 90 days without play. A skipped rating is not asked again.",
"POINTS_PENDING": "Points by time to beat",
"NO_POINTS": "No points, but your library stays current."
} }
} }
+9 -5
View File
@@ -1,4 +1,4 @@
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 {useTranslation} from 'react-i18next';
import {useEffect} from 'react'; import {useEffect} from 'react';
@@ -15,7 +15,7 @@ import AdminHintsPage from './pages/AdminHintsPage.jsx';
import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx'; import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx';
import AdminNewsPage from './pages/AdminNewsPage.jsx'; import AdminNewsPage from './pages/AdminNewsPage.jsx';
import InboxPage from './pages/InboxPage.jsx'; import InboxPage from './pages/InboxPage.jsx';
import FollowingPage from './pages/FollowingPage.jsx'; import CommunityPage from './pages/CommunityPage.jsx';
import QuestionsPage from './pages/QuestionsPage.jsx'; import QuestionsPage from './pages/QuestionsPage.jsx';
import WhatsNewPage from './pages/WhatsNewPage.jsx'; import WhatsNewPage from './pages/WhatsNewPage.jsx';
import WelcomePage from './pages/WelcomePage.jsx'; import WelcomePage from './pages/WelcomePage.jsx';
@@ -35,7 +35,9 @@ import GameSearchPage from './pages/GameSearchPage.jsx';
import PointsPage from './pages/PointsPage.jsx'; import PointsPage from './pages/PointsPage.jsx';
import PublicProfilePage from './pages/PublicProfilePage.jsx'; import PublicProfilePage from './pages/PublicProfilePage.jsx';
import AppHeader from './components/AppHeader.jsx'; import AppHeader from './components/AppHeader.jsx';
import BottomNav from './components/BottomNav.jsx';
import RequireSession from './components/RequireSession.jsx'; import RequireSession from './components/RequireSession.jsx';
import {cn} from '@/lib/utils';
import {Toaster} from '@/components/ui/sonner'; import {Toaster} from '@/components/ui/sonner';
import {TooltipProvider} from '@/components/ui/tooltip'; import {TooltipProvider} from '@/components/ui/tooltip';
import PageBackground from './components/PageBackground.jsx'; import PageBackground from './components/PageBackground.jsx';
@@ -79,7 +81,7 @@ function App() {
)} )}
<AppHeader isAuthenticated={isAuthenticated} onLogout={handleLogout}/> <AppHeader isAuthenticated={isAuthenticated} onLogout={handleLogout}/>
<main className="grow flex flex-col"> <main className={cn('grow flex flex-col', isAuthenticated && 'max-md:pb-16')}>
<Routes> <Routes>
<Route path="/" element={<LandingPage/>}/> <Route path="/" element={<LandingPage/>}/>
<Route path="/login" element={<LoginPage/>}/> <Route path="/login" element={<LoginPage/>}/>
@@ -95,7 +97,8 @@ function App() {
<Route path="/backlog" element={<BacklogPage/>}/> <Route path="/backlog" element={<BacklogPage/>}/>
<Route path="/play-next" element={<PlayNextPage/>}/> <Route path="/play-next" element={<PlayNextPage/>}/>
<Route path="/stats" element={<StatsPage/>}/> <Route path="/stats" element={<StatsPage/>}/>
<Route path="/following" element={<FollowingPage/>}/> <Route path="/community" element={<CommunityPage/>}/>
<Route path="/following" element={<Navigate to="/community?tab=following" replace/>}/>
<Route path="/import" element={<ImportPage/>}/> <Route path="/import" element={<ImportPage/>}/>
<Route path="/points" element={<PointsPage/>}/> <Route path="/points" element={<PointsPage/>}/>
<Route path="/settings" element={<SettingsPage/>}/> <Route path="/settings" element={<SettingsPage/>}/>
@@ -122,9 +125,10 @@ function App() {
{t('IMPRINT.TITLE')} {t('IMPRINT.TITLE')}
</Link> </Link>
</footer> </footer>
{isAuthenticated && <BottomNav/>}
</div> </div>
<UnsavedChangesGuard/> <UnsavedChangesGuard/>
<Toaster richColors position="bottom-right"/> <Toaster richColors position="bottom-right" mobileOffset={{bottom: 72}} className="max-md:![--offset-bottom:72px]"/>
</TooltipProvider> </TooltipProvider>
); );
} }
+16 -1
View File
@@ -24,6 +24,21 @@ afterEach(() => {
}); });
describe('App', () => { 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', () => { it('sets the document language to the UI language', () => {
fetchRoutes({'GET /api/users/me': json(200, {settings: {}})}); fetchRoutes({'GET /api/users/me': json(200, {settings: {}})});
localStorage.clear(); localStorage.clear();
@@ -43,8 +58,8 @@ describe('App', () => {
'GET /api/library/steam-profile': json(200, null), 'GET /api/library/steam-profile': json(200, null),
'POST /api/auth/refresh': json(401, {}), 'POST /api/auth/refresh': json(401, {}),
'GET /api/inbox': json(200, {results: [], unread: 0}), '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/library/questions/count': json(200, {count: 0}),
'GET /api/library/removed-games': json(200, {results: []}),
}); });
const {router} = renderWithProviders(<App/>, {entries: ['/library']}); const {router} = renderWithProviders(<App/>, {entries: ['/library']});
await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy()); await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy());
+28 -77
View File
@@ -1,7 +1,7 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom'; import {Link, useLocation, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {Bell, ChartColumn, CircleQuestionMark, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun, UserCheck, Users} 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 {toast} from 'sonner';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
@@ -19,29 +19,17 @@ import {
import {usePreferences} from '@/context/PreferencesContext.jsx'; import {usePreferences} from '@/context/PreferencesContext.jsx';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import Logo from './Logo.jsx'; import Logo from './Logo.jsx';
import {isNavActive, NAV_ITEMS} from './navItems.js';
import PageContainer from './PageContainer.jsx'; import PageContainer from './PageContainer.jsx';
import SteamAvatar from './SteamAvatar.jsx'; import SteamAvatar from './SteamAvatar.jsx';
import useCurrentUser from '../hooks/useCurrentUser.js'; import useCurrentUser from '../hooks/useCurrentUser.js';
import useNotifications from '../hooks/useNotifications.js';
import useSteamProfile from '../hooks/useSteamProfile.js'; import useSteamProfile from '../hooks/useSteamProfile.js';
import {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js'; import {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js';
import {canCorrect} from '../utils/corrections.js'; import {canCorrect} from '../utils/corrections.js';
import {fetchInbox} from '../utils/inbox.js'; import {profileHref} from '../utils/people.js';
import {fetchRemovedGames} from '../utils/manualGames.js';
import {fetchQuestionCount} from '../utils/questions.js';
import {expireSession, markSession} from '../utils/auth.js'; import {expireSession, markSession} from '../utils/auth.js';
const NAV_ITEMS = [
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
// The wishlist and the backlog order are tabs of the library.
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist', '/backlog']},
{to: '/search', labelKey: 'NAV.SEARCH', Icon: Search},
{to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices},
{to: '/questions', labelKey: 'NAV.QUESTIONS', Icon: CircleQuestionMark},
{to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn},
{to: '/following', labelKey: 'NAV.FOLLOWING', Icon: UserCheck},
{to: '/', labelKey: 'NAV.COMMUNITY', Icon: Users, iconOnly: true},
];
const CountBadge = ({count}) => ( const CountBadge = ({count}) => (
<span aria-hidden="true" <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"> className="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
@@ -50,7 +38,7 @@ const CountBadge = ({count}) => (
); );
const navLinkClass = (isActive) => cn( const navLinkClass = (isActive) => cn(
'flex items-center gap-2 rounded-lg px-1 py-2 text-sm font-medium transition-colors sm:px-3', '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 isActive
? 'bg-accent text-accent-foreground' ? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground', : 'text-muted-foreground hover:bg-muted hover:text-foreground',
@@ -84,45 +72,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
}, [seesAdminPage, pathname, menuOpenings]); }, [seesAdminPage, pathname, menuOpenings]);
const shownCount = seesAdminPage ? pendingCount : 0; const shownCount = seesAdminPage ? pendingCount : 0;
// Loaded again on every page change. The inbox page marks the notes read, so it shows no count. // The inbox page lists all of it, so it shows no count.
const [unread, setUnread] = useState(0); const {total} = useNotifications(isAuthenticated, pathname);
useEffect(() => { const shownTotal = pathname === '/inbox' ? 0 : total;
if (!isAuthenticated) return undefined;
let current = true;
fetchInbox().then(({ok, data}) => {
if (current && ok) setUnread(data.unread);
});
return () => {
current = false;
};
}, [isAuthenticated, pathname]);
const shownUnread = pathname === '/inbox' ? 0 : unread;
// Loaded again on every page change. The questions page shows the live count in its title, so it has no badge.
const [openQuestions, setOpenQuestions] = useState(0);
const [hasRemovedGames, setHasRemovedGames] = useState(false);
useEffect(() => {
if (!isAuthenticated) return undefined;
let current = true;
// The removed games are restored on the questions page, so they keep its link on a phone. Asked only
// while no question is open: an open question keeps the link anyway.
fetchQuestionCount().then(({ok, data}) => {
if (!current || !ok) return;
setOpenQuestions(data.count);
if (data.count === 0 && pathname !== '/questions') {
fetchRemovedGames().then((removed) => {
if (current && removed.ok) setHasRemovedGames(removed.data.results.length > 0);
});
}
});
return () => {
current = false;
};
}, [isAuthenticated, pathname]);
const shownQuestions = pathname === '/questions' ? 0 : openQuestions;
// Below sm the Questions icon shows only with open questions, removed games or on its own page; the points chip is hidden (/points stays in the menu).
const questionsIconHidden = openQuestions === 0 && !hasRemovedGames && pathname !== '/questions';
const switchRole = async (role) => { const switchRole = async (role) => {
try { try {
@@ -166,18 +118,15 @@ export default function AppHeader({isAuthenticated, onLogout}) {
</Link> </Link>
{isAuthenticated && ( {isAuthenticated && (
<nav className="flex items-center sm:gap-1"> <nav aria-label={t('NAV.MENU')} className="hidden min-w-0 items-center gap-0.5 md:flex lg:gap-1">
{NAV_ITEMS.map(({to, labelKey, Icon, alsoActiveOn = [], iconOnly}) => { {NAV_ITEMS.map((item) => {
const count = to === '/questions' ? shownQuestions : 0; const active = isNavActive(item, pathname);
return ( return (
<NavLink key={to} to={to} title={t(labelKey)} <Link key={item.to} to={item.to} title={t(item.labelKey)} aria-current={active ? 'page' : undefined}
aria-label={count > 0 ? t(`${labelKey}_OPEN`, {count}) : undefined} className={navLinkClass(active)}>
className={({isActive}) => cn(navLinkClass(isActive || alsoActiveOn.includes(pathname)), 'relative', <item.Icon className="size-4 shrink-0"/>
to === '/questions' && questionsIconHidden && 'max-sm:hidden')}> <span className="sr-only lg:not-sr-only lg:truncate">{t(item.labelKey)}</span>
<Icon className="size-4"/> </Link>
{!iconOnly && <span className="hidden lg:inline">{t(labelKey)}</span>}
{count > 0 && <CountBadge count={count}/>}
</NavLink>
); );
})} })}
</nav> </nav>
@@ -189,7 +138,7 @@ export default function AppHeader({isAuthenticated, onLogout}) {
<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> <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 && ( {isAuthenticated && wallet && (
<Button asChild variant="ghost" size="sm" title={`${t('POINTS.TITLE')}: ${wallet.balance}`} className={cn('min-w-0 shrink lg:shrink-0', !questionsIconHidden && 'max-sm:hidden')}> <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}`}> <Link to="/points" aria-label={`${t('POINTS.TITLE')}: ${wallet.balance}`}>
<Coins className="text-primary"/> <Coins className="text-primary"/>
<span className="min-w-0 truncate tabular-nums">{wallet.balance}</span> <span className="min-w-0 truncate tabular-nums">{wallet.balance}</span>
@@ -198,9 +147,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
)} )}
{isAuthenticated && ( {isAuthenticated && (
<Button asChild variant="ghost" size="icon" title={t('NAV.INBOX')} className="relative shrink-0"> <Button asChild variant="ghost" size="icon" title={t('NAV.INBOX')} className="relative shrink-0">
<Link to="/inbox" aria-label={shownUnread > 0 ? t('NAV.INBOX_UNREAD', {count: shownUnread}) : t('NAV.INBOX')}> <Link to="/inbox" aria-label={shownTotal > 0 ? t('NAV.INBOX_UNREAD', {count: shownTotal}) : t('NAV.INBOX')}>
<Bell/> <Bell/>
{shownUnread > 0 && <CountBadge count={shownUnread}/>} {shownTotal > 0 && <CountBadge count={shownTotal}/>}
</Link> </Link>
</Button> </Button>
)} )}
@@ -212,22 +161,24 @@ export default function AppHeader({isAuthenticated, onLogout}) {
<SteamAvatar url={steamProfile?.avatarUrl}/> <SteamAvatar url={steamProfile?.avatarUrl}/>
{shownCount > 0 && ( {shownCount > 0 && (
<span aria-label={t('NAV.PENDING_REQUESTS', {count: shownCount})} <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-primary text-[10px] font-bold text-primary-foreground"> 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} {shownCount > 9 ? '9+' : shownCount}
</span> </span>
)} )}
</Button> </Button>
</DropdownMenuTrigger> </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> <DropdownMenuItem asChild>
<Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link> <Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link to="/points"><Coins/>{t('NAV.POINTS')}</Link> <Link to="/points"><Coins/>{t('NAV.POINTS')}</Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/inbox"><Bell/>{t('NAV.INBOX')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link to="/whats-new"><Newspaper/>{t('NAV.WHATS_NEW')}</Link> <Link to="/whats-new"><Newspaper/>{t('NAV.WHATS_NEW')}</Link>
</DropdownMenuItem> </DropdownMenuItem>
@@ -249,7 +200,7 @@ export default function AppHeader({isAuthenticated, onLogout}) {
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link to="/admin/users"> <Link to="/admin/users">
<Shield/>{t('NAV.ADMIN')} <Shield/>{t('NAV.ADMIN')}
{shownCount > 0 && <Badge className="ml-auto">{shownCount}</Badge>} {shownCount > 0 && <Badge variant="outline" className="ml-auto border-amber-500 text-amber-600 dark:text-amber-400">{shownCount}</Badge>}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
+6 -14
View File
@@ -18,14 +18,14 @@ function Load() {
return <button onClick={reloadWallet}>load</button>; return <button onClick={reloadWallet}>load</button>;
} }
async function loadChip(count) { async function loadChip() {
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
fetchRoutes({ fetchRoutes({
'GET /api/users/me': json(200, {role: 'USER', activeRole: 'USER'}), 'GET /api/users/me': json(200, {role: 'USER', activeRole: 'USER'}),
'GET /api/library/steam-profile': json(200, {}), 'GET /api/library/steam-profile': json(200, {}),
'GET /api/inbox': json(200, {results: [], unread: 0}), 'GET /api/inbox': json(200, {results: [], unread: 0}),
'GET /api/library/questions/count': json(200, {count}), 'GET /api/news': json(200, {results: []}),
'GET /api/library/removed-games': json(200, {results: []}), 'GET /api/library/questions/count': json(200, {count: 0}),
'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}), 'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}),
}); });
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>); renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
@@ -34,16 +34,8 @@ async function loadChip(count) {
} }
describe('AppHeader points chip', () => { describe('AppHeader points chip', () => {
it('hides the chip below sm while the questions icon shows', async () => { it('showsTheChipAtEveryWidth', async () => {
const chip = await loadChip(2); const chip = await loadChip();
await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:2'});
expect(chip.className).toContain('max-sm:hidden');
});
it('shows the chip at every width while the questions icon is hidden', async () => {
const chip = await loadChip(0);
await screen.findByTitle('NAV.QUESTIONS');
expect(chip.className).not.toContain('max-sm:hidden'); expect(chip.className).not.toContain('max-sm:hidden');
}); });
@@ -54,8 +46,8 @@ describe('AppHeader points chip', () => {
'GET /api/users/me': json(200, {role: 'USER', activeRole: 'USER'}), 'GET /api/users/me': json(200, {role: 'USER', activeRole: 'USER'}),
'GET /api/library/steam-profile': json(200, {}), 'GET /api/library/steam-profile': json(200, {}),
'GET /api/inbox': json(200, {results: [], unread: 0}), '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/library/questions/count': json(200, {count: 0}),
'GET /api/library/removed-games': json(200, {results: []}),
'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}), 'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}),
}); });
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>); renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
+64 -82
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest'; import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, screen} from '@testing-library/react'; import {cleanup, fireEvent, screen, within} from '@testing-library/react';
import {toast} from 'sonner'; import {toast} from 'sonner';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import AppHeader from './AppHeader.jsx'; import AppHeader from './AppHeader.jsx';
@@ -20,8 +20,9 @@ const loggedInAs = (me, routes = {}, options = {}) => {
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
fetchRoutes({'GET /api/users/me': json(200, me), 'GET /api/library/steam-profile': json(200, {}), 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/inbox': json(200, {results: [], unread: 0}),
'GET /api/news': json(200, {results: []}),
'GET /api/library/questions/count': json(200, {count: 0}), 'GET /api/library/questions/count': json(200, {count: 0}),
'GET /api/library/removed-games': json(200, {results: []}), ...routes}); ...routes});
renderWithProviders(<AppHeader isAuthenticated onLogout={() => {}}/>, options); renderWithProviders(<AppHeader isAuthenticated onLogout={() => {}}/>, options);
}; };
@@ -46,41 +47,59 @@ describe('AppHeader', () => {
screen.getByText('ToBePlayed').style.display = 'none'; screen.getByText('ToBePlayed').style.display = 'none';
expect(screen.getByRole('link', {name: 'ToBePlayed'})).toBeTruthy(); expect(screen.getByRole('link', {name: 'ToBePlayed'})).toBeTruthy();
expect(screen.queryByTitle('NAV.COMMUNITY')).toBeNull(); expect(screen.queryByRole('navigation', {name: 'NAV.MENU'})).toBeNull();
}); });
it('links to the following page when logged in', () => { it('showsTheFiveMainItemsWithTheirLabelsAtEveryWidth', () => {
loggedInAs({role: 'USER', activeRole: 'USER'}); loggedInAs({role: 'USER', activeRole: 'USER'});
expect(screen.getByTitle('NAV.FOLLOWING').getAttribute('href')).toBe('/following'); 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('links to the game search when logged in', () => { it('hasNoQuestionsFollowingOrSearchItemInTheNav', () => {
loggedInAs({role: 'USER', activeRole: 'USER'}); loggedInAs({role: 'USER', activeRole: 'USER'});
expect(screen.getByTitle('NAV.SEARCH').getAttribute('href')).toBe('/search'); const nav = screen.getByRole('navigation', {name: 'NAV.MENU'});
for (const key of ['QUESTIONS', 'FOLLOWING', 'SEARCH']) expect(within(nav).queryByText(`NAV.${key}`)).toBeNull();
}); });
it('links to the community stats when logged in', () => { 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'}); loggedInAs({role: 'USER', activeRole: 'USER'});
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
openMenu();
const link = screen.getByTitle('NAV.COMMUNITY'); await screen.findByText('NAV.SETTINGS');
expect(screen.queryByText('NAV.MY_PROFILE')).toBeNull();
expect(link.getAttribute('href')).toBe('/');
expect(link.getAttribute('aria-current')).toBe('page');
});
it('shows the community link as an icon without a text label', () => {
loggedInAs({role: 'USER', activeRole: 'USER'});
expect(screen.getByTitle('NAV.COMMUNITY').textContent).toBe('');
});
it('marks community active only on the landing page', () => {
loggedInAs({role: 'USER', activeRole: 'USER'}, {}, {entries: ['/dashboard']});
expect(screen.getByTitle('NAV.COMMUNITY').getAttribute('aria-current')).toBeNull();
expect(screen.getByTitle('NAV.DASHBOARD').getAttribute('aria-current')).toBe('page');
}); });
it('shows how many inbox notes are unread', async () => { it('shows how many inbox notes are unread', async () => {
@@ -92,6 +111,24 @@ describe('AppHeader', () => {
expect(link.textContent).toBe('3'); 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 () => { it('shows no count without unread notes', async () => {
loggedInAs({role: 'USER', activeRole: 'USER'}); loggedInAs({role: 'USER', activeRole: 'USER'});
@@ -100,62 +137,6 @@ describe('AppHeader', () => {
expect(link.textContent).toBe(''); expect(link.textContent).toBe('');
}); });
it('shows how many questions are open', async () => {
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 12})});
const link = await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:12'});
expect(link.getAttribute('href')).toBe('/questions');
expect(link.textContent).toBe('NAV.QUESTIONS9+');
});
it('hides the questions icon below sm while no question is open', async () => {
loggedInAs({role: 'USER', activeRole: 'USER'});
expect((await screen.findByTitle('NAV.QUESTIONS')).className).toContain('max-sm:hidden');
});
it('keeps the questions icon at every width while questions are open', async () => {
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 2})});
expect((await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:2'})).className).not.toContain('max-sm:hidden');
});
it('keeps the questions icon at every width while the user has removed games', async () => {
loggedInAs({role: 'USER', activeRole: 'USER'},
{'GET /api/library/removed-games': json(200, {results: [{appid: 1, name: 'Game'}]})});
await vi.waitFor(() => expect(screen.getByTitle('NAV.QUESTIONS').className).not.toContain('max-sm:hidden'));
});
it('skipsTheRemovedGamesCallWhileQuestionsAreOpen', async () => {
const removed = vi.fn(() => json(200, {results: []}));
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 2}),
'GET /api/library/removed-games': removed});
await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:2'});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(removed).not.toHaveBeenCalled();
});
it('keeps the questions icon below sm on the questions page, without a badge', async () => {
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 0})},
{entries: ['/questions']});
expect((await screen.findByTitle('NAV.QUESTIONS')).className).not.toContain('max-sm:hidden');
});
it('shows no question count on the questions page, whose title has it', async () => {
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 3})},
{entries: ['/questions']});
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/library/questions/count'), expect.anything()));
expect(screen.getByTitle('NAV.QUESTIONS').textContent).toBe('NAV.QUESTIONS');
expect(screen.queryByRole('link', {name: 'NAV.QUESTIONS_OPEN:3'})).toBeNull();
});
it('offers the admin page and "view as" to an admin', async () => { it('offers the admin page and "view as" to an admin', async () => {
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'}, loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'},
{'GET /api/admin/users/pending-count': json(200, {count: 0})}); {'GET /api/admin/users/pending-count': json(200, {count: 0})});
@@ -176,7 +157,8 @@ describe('AppHeader', () => {
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'}, loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'},
{'GET /api/admin/users/pending-count': json(200, {count: 3})}); {'GET /api/admin/users/pending-count': json(200, {count: 3})});
expect(await screen.findByLabelText('NAV.PENDING_REQUESTS:3')).toBeTruthy(); const badge = await screen.findByLabelText('NAV.PENDING_REQUESTS:3');
expect(badge.className).toContain('bg-amber-500');
openMenu(); openMenu();
expect((await screen.findByRole('menuitem', {name: /^NAV\.ADMIN ?3$/})).textContent).toContain('3'); expect((await screen.findByRole('menuitem', {name: /^NAV\.ADMIN ?3$/})).textContent).toContain('3');
}); });
+14 -1
View File
@@ -9,17 +9,26 @@ import {Switch} from '@/components/ui/switch';
import {AccentPicker, CornersPicker} from './StylePickers.jsx'; import {AccentPicker, CornersPicker} from './StylePickers.jsx';
import {equippedSkin} from '../utils/rewards.js'; import {equippedSkin} from '../utils/rewards.js';
import {usePreferences} from '../context/PreferencesContext.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx';
import usePersistSettings from '../hooks/usePersistSettings.js';
import useSaveAppearance from '../hooks/useSaveAppearance.js'; import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {coverUrl} from '../utils/games.js'; import {coverUrl} from '../utils/games.js';
import {disableTilt, requestTilt, tiltAllowed, tiltNeedsPermission} from '../utils/parallax.js'; import {disableTilt, requestTilt, tiltAllowed, tiltNeedsPermission} from '../utils/parallax.js';
import {saveSettings} from '../utils/settings.js';
export default function AppearanceCard() { export default function AppearanceCard() {
const {t} = useTranslation(); const {t} = useTranslation();
const {appearance, equipped} = usePreferences(); const {appearance, equipped, isDarkMode, setDarkMode} = usePreferences();
const persistSettings = usePersistSettings();
const saveAppearance = useSaveAppearance(); const saveAppearance = useSaveAppearance();
const [tilt, setTilt] = useState(tiltAllowed); const [tilt, setTilt] = useState(tiltAllowed);
const [tiltRefused, setTiltRefused] = useState(false); 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. // requestTilt() starts inside the tap, as iOS demands.
const changeTilt = (on) => { const changeTilt = (on) => {
if (!on) { if (!on) {
@@ -39,6 +48,10 @@ export default function AppearanceCard() {
<CardTitle className="flex items-center gap-2"><Palette className="size-4 text-primary"/>{t('SETTINGS.APPEARANCE')}</CardTitle> <CardTitle className="flex items-center gap-2"><Palette className="size-4 text-primary"/>{t('SETTINGS.APPEARANCE')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <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})}/> <AccentPicker id="accent" value={appearance.accent} onChange={(accent) => saveAppearance({accent})}/>
<CornersPicker id="corners" value={appearance.corners} onChange={(corners) => saveAppearance({corners})}/> <CornersPicker id="corners" value={appearance.corners} onChange={(corners) => saveAppearance({corners})}/>
{equippedSkin(equipped) && <p className="text-xs text-muted-foreground">{t('SETTINGS.SKIN_OVERRIDES')}</p>} {equippedSkin(equipped) && <p className="text-xs text-muted-foreground">{t('SETTINGS.SKIN_OVERRIDES')}</p>}
+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']);
});
});
+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>
);
}
+1 -1
View File
@@ -119,6 +119,6 @@ export default function DashboardHint({user, canSync, summary, trend}) {
<HintCard key={hint.id} hint={hint} hideForever={hideForever} onHideForeverChange={setHideForever} <HintCard key={hint.id} hint={hint} hideForever={hideForever} onHideForeverChange={setHideForever}
onClose={(event) => event.detail < 2 && close(hideForever, true)} onClose={(event) => event.detail < 2 && close(hideForever, true)}
onAccept={(event) => event.detail < 2 && close(true, true)} onAccept={(event) => event.detail < 2 && close(true, true)}
className="fixed inset-x-4 bottom-4 z-50 mx-auto max-w-md"/> className="fixed inset-x-4 bottom-4 z-50 mx-auto max-w-md max-md:bottom-20"/>
); );
} }
+1 -1
View File
@@ -48,7 +48,7 @@ export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forc
</Badge> </Badge>
) : ( ) : (
<Button asChild variant="link" className="mt-2 h-auto p-0"> <Button asChild variant="link" className="mt-2 h-auto p-0">
<Link to="/settings"><Link2 className="size-4"/>{t('DASHBOARD.NO_STEAM_ID')}</Link> <Link to="/settings?tab=connections"><Link2 className="size-4"/>{t('DASHBOARD.NO_STEAM_ID')}</Link>
</Button> </Button>
)} )}
</div> </div>
+1 -1
View File
@@ -24,7 +24,7 @@ export default function EmptyLibraryCard({reason}) {
</p> </p>
<div className="flex flex-wrap justify-center gap-2"> <div className="flex flex-wrap justify-center gap-2">
{reason === 'NO_SOURCE' && ( {reason === 'NO_SOURCE' && (
<Button asChild><Link to="/settings">{t('DASHBOARD.EMPTY.LINK')}</Link></Button> <Button asChild><Link to="/settings?tab=connections">{t('DASHBOARD.EMPTY.LINK')}</Link></Button>
)} )}
<Button asChild variant={reason === 'NO_SOURCE' ? 'outline' : 'default'}> <Button asChild variant={reason === 'NO_SOURCE' ? 'outline' : 'default'}>
<Link to="/import">{t('DASHBOARD.EMPTY.IMPORT')}</Link> <Link to="/import">{t('DASHBOARD.EMPTY.IMPORT')}</Link>
+1 -1
View File
@@ -19,7 +19,7 @@ describe('EmptyLibraryCard', () => {
it('NO_SOURCE offers to link a platform and to import', () => { it('NO_SOURCE offers to link a platform and to import', () => {
renderCard('NO_SOURCE'); renderCard('NO_SOURCE');
expect(screen.getByRole('link', {name: 'Link Steam / PlayStation'}).getAttribute('href')).toBe('/settings'); 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'); expect(screen.getByRole('link', {name: 'Import games'}).getAttribute('href')).toBe('/import');
}); });
+1 -1
View File
@@ -24,7 +24,7 @@ export default function FollowingCard({limit}) {
{t('FOLLOW.TITLE')} {t('FOLLOW.TITLE')}
</h2> </h2>
<Button asChild variant="ghost" size="sm"> <Button asChild variant="ghost" size="sm">
<Link to="/following">{t('FOLLOW.SHOW_ALL')}<ArrowRight/></Link> <Link to="/community?tab=following">{t('FOLLOW.SHOW_ALL')}<ArrowRight/></Link>
</Button> </Button>
</div> </div>
<div className="px-5 py-2"> <div className="px-5 py-2">
+1 -1
View File
@@ -20,7 +20,7 @@ describe('FollowingCard', () => {
expect(screen.queryByRole('link', {name: 'D'})).toBeNull(); expect(screen.queryByRole('link', {name: 'D'})).toBeNull();
expect(screen.getAllByRole('button', {name: 'FOLLOW.UNFOLLOW'})).toHaveLength(3); expect(screen.getAllByRole('button', {name: 'FOLLOW.UNFOLLOW'})).toHaveLength(3);
expect(screen.getByRole('link', {name: /FOLLOW.SHOW_ALL/}).getAttribute('href')).toBe('/following'); expect(screen.getByRole('link', {name: /FOLLOW.SHOW_ALL/}).getAttribute('href')).toBe('/community?tab=following');
}); });
it('showsTheEmptyStateWhenNobodyIsFollowed', async () => { it('showsTheEmptyStateWhenNobodyIsFollowed', async () => {
-58
View File
@@ -1,58 +0,0 @@
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Users} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import useFollowing from '../hooks/useFollowing.js';
import {profileHref} from '../utils/people.js';
export default function FollowsCard() {
const {t} = useTranslation();
const {follows, failed, unfollow: end} = useFollowing();
const link = (displayName) => (
<Link to={profileHref(displayName)} className="min-w-0 truncate hover:text-primary hover:underline">{displayName}</Link>
);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Users className="size-4 text-primary"/>{t('FOLLOW.TITLE')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{failed && <p className="text-sm text-destructive">{t('FOLLOW.LOAD_ERROR')}</p>}
{follows && (
<>
<section className="space-y-2">
<h3 className="text-sm font-medium">{t('FOLLOW.FOLLOWING')}</h3>
{follows.following.length === 0 && <p className="text-sm text-muted-foreground">{t('FOLLOW.NO_FOLLOWING')}</p>}
<ul className="space-y-1">
{follows.following.map((entry) => (
<li key={entry.id} className="flex items-center justify-between gap-2 text-sm">
{link(entry.displayName)}
<Button variant="outline" size="sm" onClick={() => end(entry.id)}>{t('FOLLOW.UNFOLLOW')}</Button>
</li>
))}
</ul>
</section>
<section className="space-y-2">
<h3 className="text-sm font-medium">{t('FOLLOW.FOLLOWERS')}</h3>
{follows.followers.length === 0 && <p className="text-sm text-muted-foreground">{t('FOLLOW.NO_FOLLOWERS')}</p>}
<ul className="space-y-1">
{follows.followers.map((entry) => (
<li key={entry.id} className="flex items-center justify-between gap-2 text-sm">
{entry.displayName
? link(entry.displayName)
: <span className="min-w-0 truncate text-muted-foreground">{t('FOLLOW.PRIVATE_USER')}</span>}
<Button variant="outline" size="sm" onClick={() => end(entry.id)}>{t('FOLLOW.REMOVE')}</Button>
</li>
))}
</ul>
</section>
</>
)}
</CardContent>
</Card>
);
}
-58
View File
@@ -1,58 +0,0 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import FollowsCard from './FollowsCard.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();
vi.clearAllMocks();
});
const follows = {following: [{id: 5, displayName: 'Lobo'}], followers: [{id: 6, displayName: 'Wolf'}, {id: 7, displayName: null}]};
describe('FollowsCard', () => {
it('listsBothSidesWithLinksAndPrivateFollowersWithoutAName', async () => {
fetchRoutes({'GET /api/follows': json(200, follows)});
renderWithProviders(<FollowsCard/>);
expect((await screen.findByRole('link', {name: 'Lobo'})).getAttribute('href')).toBe('/u/Lobo');
expect(screen.getByRole('link', {name: 'Wolf'}).getAttribute('href')).toBe('/u/Wolf');
expect(screen.getByText('FOLLOW.PRIVATE_USER')).toBeTruthy();
});
it('unfollowsByRowIdAndLoadsTheListAgain', async () => {
let answered = follows;
fetchRoutes({'GET /api/follows': () => json(200, answered),
'DELETE /api/follows/5': () => {
answered = {...follows, following: []};
return json(204);
}});
renderWithProviders(<FollowsCard/>);
fireEvent.click(await screen.findByRole('button', {name: 'FOLLOW.UNFOLLOW'}));
await waitFor(() => expect(screen.queryByRole('link', {name: 'Lobo'})).toBeNull());
expect(screen.getByText('FOLLOW.NO_FOLLOWING')).toBeTruthy();
});
it('removesAFollowerByRowId', async () => {
fetchRoutes({'GET /api/follows': json(200, follows), 'DELETE /api/follows/7': json(204)});
renderWithProviders(<FollowsCard/>);
fireEvent.click((await screen.findAllByRole('button', {name: 'FOLLOW.REMOVE'}))[1]);
await waitFor(() => expect(fetch.mock.calls.filter(([, init]) => init?.method === 'DELETE')).toHaveLength(1));
});
it('saysSoWhenTheListDoesNotLoad', async () => {
fetchRoutes({'GET /api/follows': json(500, {})});
renderWithProviders(<FollowsCard/>);
expect(await screen.findByText('FOLLOW.LOAD_ERROR')).toBeTruthy();
});
});
+5 -5
View File
@@ -5,11 +5,11 @@ import {Badge} from '@/components/ui/badge';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
export const STATUS_META = { 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'}, 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, text: 'text-primary', badge: 'bg-primary/15 text-primary', bar: 'bg-primary'}, PLAYING: {icon: Gamepad2, color: 'var(--primary)', 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'}, 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, text: 'text-amber-600 dark:text-amber-400', badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400', bar: 'bg-amber-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, text: 'text-rose-600 dark:text-rose-400', badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400', bar: 'bg-rose-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'},
}; };
/** /**
+4 -26
View File
@@ -1,8 +1,8 @@
import {NavLink} from 'react-router-dom'; import {useLocation} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {Gift, Library, ListOrdered} from 'lucide-react'; import {Gift, Library, ListOrdered} from 'lucide-react';
import {cn} from '@/lib/utils'; import TabNav from './TabNav.jsx';
const TABS = [ const TABS = [
{to: '/library', labelKey: 'LIBRARY.TITLE', icon: Library}, {to: '/library', labelKey: 'LIBRARY.TITLE', icon: Library},
@@ -12,28 +12,6 @@ const TABS = [
export default function LibraryTabs() { export default function LibraryTabs() {
const {t} = useTranslation(); const {t} = useTranslation();
return ( const {pathname} = useLocation();
<nav aria-label={t('LIBRARY.TITLE')} className="inline-flex max-w-full rounded-lg bg-muted p-1 text-sm"> return <TabNav label={t('LIBRARY.TITLE')} tabs={TABS.map((tab) => ({...tab, active: pathname === tab.to}))}/>;
{TABS.map(({to, labelKey, icon: Icon}) => (
<NavLink
key={to}
to={to}
end
title={t(labelKey)}
className={({isActive}) => cn(
'flex min-w-0 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',
)}
>
{({isActive}) => (
<>
<Icon className="size-4 shrink-0"/>
{/* Below sm only the active tab shows its label; the others keep it as their name. */}
<span className={cn('truncate', !isActive && 'sr-only sm:not-sr-only')}>{t(labelKey)}</span>
</>
)}
</NavLink>
))}
</nav>
);
} }
+9 -19
View File
@@ -1,17 +1,15 @@
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {SlidersHorizontal} from 'lucide-react'; import {SlidersHorizontal} from 'lucide-react';
import {toast} from 'sonner';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Label} from '@/components/ui/label'; import {Label} from '@/components/ui/label';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import {Switch} from '@/components/ui/switch';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {usePreferences} from '../context/PreferencesContext.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx';
import usePersistSettings from '../hooks/usePersistSettings.js'; import usePersistSettings from '../hooks/usePersistSettings.js';
import useSaveResponse from '../hooks/useSaveResponse.js'; import useSaveResponse from '../hooks/useSaveResponse.js';
import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js'; import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js';
import {LANGUAGES, saveSettings} from '../utils/settings.js'; import {LANGUAGES} from '../utils/settings.js';
// Radix Select has no empty value. // Radix Select has no empty value.
const NO_FAVORITE = 'NONE'; const NO_FAVORITE = 'NONE';
@@ -19,26 +17,22 @@ const NO_FAVORITE = 'NONE';
export default function PreferencesCard() { export default function PreferencesCard() {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
const {user: profile, updateUser: updateProfile} = useCurrentUserContext(); const {user: profile, updateUser: updateProfile} = useCurrentUserContext();
const {isDarkMode, setDarkMode, setLanguage} = usePreferences(); const {setLanguage} = usePreferences();
const persistSettings = usePersistSettings(); const persistSettings = usePersistSettings();
const runSave = useSaveResponse(); const runSave = useSaveResponse();
const handleDarkThemeToggle = async () => {
const next = !isDarkMode;
setDarkMode(next); // apply on the fly
await persistSettings(() => saveSettings({useDarkTheme: next}));
};
const handleLanguageChange = async (next) => { const handleLanguageChange = async (next) => {
await persistSettings(() => setLanguage(next)); const previous = i18n.language;
const {ok} = await persistSettings(() => setLanguage(next), {silent: true});
if (!ok) i18n.changeLanguage(previous);
}; };
const handleFavoritePlatformChange = async (value) => { const handleFavoritePlatformChange = async (value) => {
const platform = value === NO_FAVORITE ? null : value; const platform = value === NO_FAVORITE ? null : value;
if (await runSave(() => saveFavoritePlatform(platform))) { const previous = profile?.settings?.favoritePlatform ?? null;
updateProfile((p) => ({...p, settings: {...p.settings, favoritePlatform: platform}})); const setPlatform = (next) => updateProfile((p) => ({...p, settings: {...p.settings, favoritePlatform: next}}));
toast.success(t('SETTINGS.SAVED')); setPlatform(platform);
} if (!await runSave(() => saveFavoritePlatform(platform))) setPlatform(previous);
}; };
return ( return (
@@ -47,10 +41,6 @@ export default function PreferencesCard() {
<CardTitle className="flex items-center gap-2"><SlidersHorizontal className="size-4 text-primary"/>{t('SETTINGS.PREFERENCES')}</CardTitle> <CardTitle className="flex items-center gap-2"><SlidersHorizontal className="size-4 text-primary"/>{t('SETTINGS.PREFERENCES')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <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={handleDarkThemeToggle}/>
</div>
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<Label htmlFor="language">{t('SETTINGS.LANGUAGE')}</Label> <Label htmlFor="language">{t('SETTINGS.LANGUAGE')}</Label>
<Select value={i18n.language} onValueChange={handleLanguageChange}> <Select value={i18n.language} onValueChange={handleLanguageChange}>
+22 -23
View File
@@ -3,7 +3,6 @@ import {useTranslation} from 'react-i18next';
import {useForm} from 'react-hook-form'; import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod'; import {zodResolver} from '@hookform/resolvers/zod';
import {Copy, Gamepad2, Loader2, Save} from 'lucide-react'; import {Copy, Gamepad2, Loader2, Save} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
@@ -14,6 +13,8 @@ import FormField from './FormField.jsx';
import ProfileStyleSettings from './ProfileStyleSettings.jsx'; import ProfileStyleSettings from './ProfileStyleSettings.jsx';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import usePersistSettings from '../hooks/usePersistSettings.js'; import usePersistSettings from '../hooks/usePersistSettings.js';
import useCopyLink from '../hooks/useCopyLink.js';
import useUnsavedChanges from '../hooks/useUnsavedChanges.js';
import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js'; import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
import {normalizeProfileStyle} from '../utils/profileStyle.js'; import {normalizeProfileStyle} from '../utils/profileStyle.js';
import {saveOwnerLists, saveSettings} from '../utils/settings.js'; import {saveOwnerLists, saveSettings} from '../utils/settings.js';
@@ -25,32 +26,37 @@ export default function ProfileCard({displayName}) {
const {t} = useTranslation(); const {t} = useTranslation();
const {user: profile, updateUser: updateProfile} = useCurrentUserContext(); const {user: profile, updateUser: updateProfile} = useCurrentUserContext();
const persistSettings = usePersistSettings(); const persistSettings = usePersistSettings();
const copyProfileLink = useCopyLink();
const saveStyle = useSaveProfileStyle(); const saveStyle = useSaveProfileStyle();
const [savingProfileStyle, setSavingProfileStyle] = useState(false); const [savingProfileStyle, setSavingProfileStyle] = useState(false);
const { const {
register, register,
handleSubmit, handleSubmit,
formState: {errors, isSubmitting}, reset,
formState: {errors, isSubmitting, isDirty},
} = useForm({ } = useForm({
resolver: zodResolver(settingsSchema), resolver: zodResolver(settingsSchema),
defaultValues: {displayName}, defaultValues: {displayName},
}); });
const handlePublicProfileToggle = async () => { useUnsavedChanges(isDirty);
const next = !profile?.settings?.publicProfile;
const result = await persistSettings(() => saveSettings({publicProfile: next})); const toggleSetting = async (key, next, save) => {
if (result.ok) { const show = (value) => updateProfile((p) => ({...p, settings: {...p.settings, [key]: value}}));
updateProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}})); show(next);
} const {ok} = await persistSettings(save, {silent: true});
if (!ok) show(!next);
}; };
const handleOwnerListsToggle = async () => { const handlePublicProfileToggle = () => {
const next = !profile?.settings?.publicProfile;
return toggleSetting('publicProfile', next, () => saveSettings({publicProfile: next}));
};
const handleOwnerListsToggle = () => {
const next = !profile?.settings?.showInOwnerLists; const next = !profile?.settings?.showInOwnerLists;
const result = await persistSettings(() => saveOwnerLists(next)); return toggleSetting('showInOwnerLists', next, () => saveOwnerLists(next));
if (result.ok) {
updateProfile((p) => ({...p, settings: {...p.settings, showInOwnerLists: next}}));
}
}; };
// Shown at once; the pickers wait for the answer, so the saves reach Auth one after the other. // Shown at once; the pickers wait for the answer, so the saves reach Auth one after the other.
@@ -63,21 +69,14 @@ export default function ProfileCard({displayName}) {
setSavingProfileStyle(false); setSavingProfileStyle(false);
}; };
const copyProfileLink = async (link) => {
try {
await navigator.clipboard.writeText(link);
toast.success(t('SETTINGS.PUBLIC_PROFILE_COPIED'));
} catch {
toast.error(link);
}
};
const onSubmitProfile = async (values) => { const onSubmitProfile = async (values) => {
// Without a name the profile has no link, so removing the name also makes it private. // Without a name the profile has no link, so removing the name also makes it private.
const publicProfile = !!profile?.settings?.publicProfile && !!values.displayName; const publicProfile = !!profile?.settings?.publicProfile && !!values.displayName;
const result = await persistSettings(() => saveSettings({displayName: values.displayName, publicProfile})); const result = await persistSettings(() => saveSettings({displayName: values.displayName, publicProfile}));
if (result.ok) { if (result.ok) {
updateProfile((p) => ({...p, displayName: values.displayName, settings: {...p.settings, publicProfile}})); updateProfile((p) => ({...p, displayName: values.displayName, settings: {...p.settings, publicProfile}}));
// The saved name is the new default, so the unsaved-changes guard lets go.
reset({displayName: values.displayName});
} }
}; };
@@ -146,7 +145,7 @@ export default function ProfileCard({displayName}) {
<div className="mt-6 border-t pt-6"> <div className="mt-6 border-t pt-6">
<ProfileStyleSettings style={normalizeProfileStyle(profile?.settings?.profileStyle)} <ProfileStyleSettings style={normalizeProfileStyle(profile?.settings?.profileStyle)}
disabled={!publicProfile || savingProfileStyle} privateProfile={!publicProfile} disabled={!publicProfile || savingProfileStyle}
onChange={handleProfileStyleChange}/> onChange={handleProfileStyleChange}/>
</div> </div>
</CardContent> </CardContent>
+3 -1
View File
@@ -33,15 +33,17 @@ function ArtRow({id, label, appid, onRemove, disabled}) {
* page itself. * page itself.
* *
* @param disabled the profile is private, or a save runs * @param disabled the profile is private, or a save runs
* @param privateProfile explains why the controls are disabled
* @param onChange gets the changed fields only * @param onChange gets the changed fields only
*/ */
export default function ProfileStyleSettings({style, disabled, onChange}) { export default function ProfileStyleSettings({style, disabled, privateProfile, onChange}) {
const {t} = useTranslation(); const {t} = useTranslation();
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-1"> <div className="space-y-1">
<span className="text-sm font-medium">{t('SETTINGS.PROFILE_STYLE')}</span> <span className="text-sm font-medium">{t('SETTINGS.PROFILE_STYLE')}</span>
<p className="text-xs text-muted-foreground">{t('SETTINGS.PROFILE_STYLE_HINT')}</p> <p className="text-xs text-muted-foreground">{t('SETTINGS.PROFILE_STYLE_HINT')}</p>
{privateProfile && <p className="text-xs text-muted-foreground">{t('SETTINGS.PROFILE_STYLE_PRIVATE')}</p>}
</div> </div>
<AccentPicker id="profile-accent" value={style.accent} disabled={disabled} <AccentPicker id="profile-accent" value={style.accent} disabled={disabled}
onChange={(accent) => onChange({accent})}/> onChange={(accent) => onChange({accent})}/>
@@ -0,0 +1,24 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import ProfileStyleSettings from './ProfileStyleSettings.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
afterEach(cleanup);
const style = {accent: 'violet', corners: 'default', bannerAppid: null, backgroundAppid: null};
describe('ProfileStyleSettings', () => {
it('aPrivateProfile_SaysWhyTheControlsAreDisabled', () => {
render(<ProfileStyleSettings style={style} disabled privateProfile onChange={vi.fn()}/>);
expect(screen.getByText('SETTINGS.PROFILE_STYLE_PRIVATE')).toBeTruthy();
});
it('aPublicProfile_ShowsNoExplanation', () => {
render(<ProfileStyleSettings style={style} disabled={false} privateProfile={false} onChange={vi.fn()}/>);
expect(screen.queryByText('SETTINGS.PROFILE_STYLE_PRIVATE')).toBeNull();
});
});
+13 -4
View File
@@ -3,18 +3,26 @@ import {useTranslation} from 'react-i18next';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import FitText from './FitText.jsx'; import FitText from './FitText.jsx';
import GameCover from './GameCover.jsx'; import GameCover from './GameCover.jsx';
import RatingStars from './RatingStars.jsx'; import RatingStars from './RatingStars.jsx';
import {formatDate} from '../utils/format.js'; import {formatDate} from '../utils/format.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
// Only START and FINISHED earn points.
const pointsText = (question, expectedPoints, t) => {
if (question === 'START') return t('QUESTIONS.START.POINTS');
if (question !== 'FINISHED') return null;
return expectedPoints > 0 ? t('QUESTIONS.FINISHED.POINTS', {count: expectedPoints}) : t('QUESTIONS.POINTS_PENDING');
};
/** /**
* One open question of one game. The page decides what an answer does. * One open question of one game. The page decides what an answer does.
* *
* @param item a result of `GET /api/library/questions` * @param item a result of `GET /api/library/questions`
* @param busy true while an answer is on its way * @param busy true while an answer is on its way
* @param expectedPoints the points a finish would pay (`GET /api/rewards/expected`), absent or 0 shows nothing * @param expectedPoints the points a finish would pay (`GET /api/rewards/expected`), absent or 0 shows "points by time to beat"
* @param onAnswer `({yes, rating?})`; a yes to the rating question comes with the stars * @param onAnswer `({yes, rating?})`; a yes to the rating question comes with the stars
*/ */
export default function QuestionCard({item, busy, expectedPoints, onAnswer}) { export default function QuestionCard({item, busy, expectedPoints, onAnswer}) {
@@ -27,6 +35,7 @@ export default function QuestionCard({item, busy, expectedPoints, onAnswer}) {
date: lastActivity ? formatDate(lastActivity, i18n.language) : '', date: lastActivity ? formatDate(lastActivity, i18n.language) : '',
}); });
const askId = `question-${appid}`; const askId = `question-${appid}`;
const points = pointsText(question, expectedPoints, t);
return ( return (
<Card className="flex-row items-start gap-3 p-4"> <Card className="flex-row items-start gap-3 p-4">
@@ -39,9 +48,9 @@ export default function QuestionCard({item, busy, expectedPoints, onAnswer}) {
</Link> </Link>
<p id={askId} className="text-base font-semibold">{t(`QUESTIONS.${question}.ASK`)}</p> <p id={askId} className="text-base font-semibold">{t(`QUESTIONS.${question}.ASK`)}</p>
<p className="text-sm text-muted-foreground">{reason}</p> <p className="text-sm text-muted-foreground">{reason}</p>
{question === 'FINISHED' && expectedPoints > 0 && ( <p className={cn('text-sm font-medium', points ? 'text-primary' : 'text-muted-foreground')}>
<p className="text-sm font-medium text-primary">{t('QUESTIONS.FINISHED.POINTS', {count: expectedPoints})}</p> {points ?? t('QUESTIONS.NO_POINTS')}
)} </p>
{question === 'RATE' ? ( {question === 'RATE' ? (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<RatingStars value={0} labelledBy={askId} disabled={busy} <RatingStars value={0} labelledBy={askId} disabled={busy}
+26
View File
@@ -48,4 +48,30 @@ describe('QuestionCard', () => {
expect(screen.getByRole('link', {name: 'Hades'}).getAttribute('href')).toBe('/library/10'); expect(screen.getByRole('link', {name: 'Hades'}).getAttribute('href')).toBe('/library/10');
}); });
describe('points line', () => {
const renderWithPoints = (props, expectedPoints) => render(
<MemoryRouter><QuestionCard item={item(props)} busy={false} expectedPoints={expectedPoints} onAnswer={vi.fn()}/></MemoryRouter>);
it('start_ShowsThePointsOfAStart', () => {
renderWithPoints({question: 'START'});
expect(screen.getByText('QUESTIONS.START.POINTS')).toBeTruthy();
});
it('finished_ShowsTheExpectedPointsOrThePendingText', () => {
const {unmount} = renderWithPoints({question: 'FINISHED'}, 40);
expect(screen.getByText('QUESTIONS.FINISHED.POINTS:40')).toBeTruthy();
unmount();
renderWithPoints({question: 'FINISHED'}, 0);
expect(screen.getByText('QUESTIONS.POINTS_PENDING')).toBeTruthy();
});
it.each(['DROPPED', 'RATE'])('%s_SaysThereAreNoPoints', (question) => {
renderWithPoints({question});
expect(screen.getByText('QUESTIONS.NO_POINTS')).toBeTruthy();
});
});
}); });
+77
View File
@@ -0,0 +1,77 @@
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Loader2} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx';
import {fetchRemovedGames, restoreGame} from '../utils/manualGames.js';
/**
* The games the user removed, one Restore button each: the "Removed" filter of the library. A list that does not load
* looks empty, like a list without games.
*/
export default function RemovedGamesList() {
const {t} = useTranslation();
const [games, setGames] = useState(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
let active = true;
fetchRemovedGames().then(({ok, data}) => {
if (active) setGames(ok ? data.results : []);
});
return () => {
active = false;
};
}, []);
const restore = async (appid) => {
setBusy(true);
try {
const response = await restoreGame(appid);
if (response.status === 401) return;
if (response.ok || response.status === 404) {
setGames((current) => current.filter((game) => game.appid !== appid));
if (response.ok) toast.success(t('REMOVED_GAMES.RESTORED'));
} else {
toast.error(t('BACKLOG.SAVE_FAILED'));
}
} catch (err) {
console.error('restore error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
if (!games) return <Loader2 className="size-5 animate-spin text-muted-foreground"/>;
return (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">{t('REMOVED_GAMES.HINT')}</p>
{games.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('LIBRARY.NO_REMOVED')}</p>
) : (
<Card className="py-0">
<ul className="divide-y">
{games.map((game) => (
<li key={game.appid} className="flex items-center gap-3 p-3">
<div className="w-16 shrink-0">
<GameCover appid={game.appid} imageUrl={game.imageUrl}/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{game.name ?? `App ${game.appid}`}</p>
{game.platform && <p className="text-sm text-muted-foreground">{game.platform}</p>}
</div>
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => restore(game.appid)}>
{t('REMOVED_GAMES.RESTORE')}
</Button>
</li>
))}
</ul>
</Card>
)}
</div>
);
}
@@ -3,7 +3,7 @@ import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom'; import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner'; import {toast} from 'sonner';
import RemovedGamesSection from './RemovedGamesSection.jsx'; import RemovedGamesList from './RemovedGamesList.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
@@ -14,7 +14,7 @@ const LIST = 'GET /api/library/removed-games';
const renderSection = (routes) => { const renderSection = (routes) => {
fetchRoutes(routes); fetchRoutes(routes);
return render(<MemoryRouter><RemovedGamesSection/></MemoryRouter>); return render(<MemoryRouter><RemovedGamesList/></MemoryRouter>);
}; };
afterEach(() => { afterEach(() => {
@@ -22,14 +22,14 @@ afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe('RemovedGamesSection', () => { describe('RemovedGamesList', () => {
it('isClosedByDefaultAndListsTheGames', async () => { it('listsTheGamesWithTheHint', async () => {
const {container} = renderSection({[LIST]: json(200, {results: [removed(-1, 'Zelda'), removed(-2, 'Mario')]})}); renderSection({[LIST]: json(200, {results: [removed(-1, 'Zelda'), removed(-2, 'Mario')]})});
expect(await screen.findByText('REMOVED_GAMES.TITLE_COUNT:2')).toBeTruthy(); expect(await screen.findByText('Zelda')).toBeTruthy();
expect(container.querySelector('details').open).toBe(false);
expect(screen.getByText('Zelda')).toBeTruthy();
expect(screen.getByText('Mario')).toBeTruthy(); expect(screen.getByText('Mario')).toBeTruthy();
expect(screen.getByText('REMOVED_GAMES.HINT')).toBeTruthy();
expect(screen.queryByText('LIBRARY.NO_REMOVED')).toBeNull();
}); });
it('showsNoLinkToTheGamePage', async () => { it('showsNoLinkToTheGamePage', async () => {
@@ -66,17 +66,15 @@ describe('RemovedGamesSection', () => {
expect(screen.getByText('Zelda')).toBeTruthy(); expect(screen.getByText('Zelda')).toBeTruthy();
}); });
it('rendersNothingForAnEmptyList', async () => { it('saysSoForAnEmptyList', async () => {
const {container} = renderSection({[LIST]: json(200, {results: []})}); renderSection({[LIST]: json(200, {results: []})});
await waitFor(() => expect(fetch).toHaveBeenCalled()); expect(await screen.findByText('LIBRARY.NO_REMOVED')).toBeTruthy();
expect(container.innerHTML).toBe('');
}); });
it('rendersNothingWhenTheListFails', async () => { it('saysSoWhenTheListFails', async () => {
const {container} = renderSection({[LIST]: json(500, {})}); renderSection({[LIST]: json(500, {})});
await waitFor(() => expect(fetch).toHaveBeenCalled()); expect(await screen.findByText('LIBRARY.NO_REMOVED')).toBeTruthy();
expect(container.innerHTML).toBe('');
}); });
}); });
-70
View File
@@ -1,70 +0,0 @@
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import GameCover from './GameCover.jsx';
import {fetchRemovedGames, restoreGame} from '../utils/manualGames.js';
/**
* The games the user removed, in a closed `<details>`, one Restore button each. Shows nothing when there are none or
* the list does not load: it is a safety net, not a page.
*/
export default function RemovedGamesSection() {
const {t} = useTranslation();
const [games, setGames] = useState([]);
const [busy, setBusy] = useState(false);
useEffect(() => {
let active = true;
fetchRemovedGames().then(({ok, data}) => {
if (active && ok) setGames(data.results);
});
return () => {
active = false;
};
}, []);
const restore = async (appid) => {
setBusy(true);
try {
const response = await restoreGame(appid);
if (response.status === 401) return;
if (response.ok || response.status === 404) {
setGames((current) => current.filter((game) => game.appid !== appid));
if (response.ok) toast.success(t('REMOVED_GAMES.RESTORED'));
} else {
toast.error(t('BACKLOG.SAVE_FAILED'));
}
} catch (err) {
console.error('restore error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
if (games.length === 0) return null;
return (
<details className="rounded-lg border p-4">
<summary className="cursor-pointer font-medium">{t('REMOVED_GAMES.TITLE_COUNT', {count: games.length})}</summary>
<p className="mt-3 text-sm text-muted-foreground">{t('REMOVED_GAMES.HINT')}</p>
<ul className="mt-3 space-y-3">
{games.map((game) => (
<li key={game.appid} className="flex items-center gap-3">
<div className="w-16 shrink-0">
<GameCover appid={game.appid} imageUrl={game.imageUrl}/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{game.name ?? `App ${game.appid}`}</p>
{game.platform && <p className="text-sm text-muted-foreground">{game.platform}</p>}
</div>
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => restore(game.appid)}>
{t('REMOVED_GAMES.RESTORE')}
</Button>
</li>
))}
</ul>
</details>
);
}
+34
View File
@@ -0,0 +1,34 @@
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {cn} from '@/lib/utils';
/**
* A pill navigation. The caller decides which tab is active, so a tab can be a path or a `?tab=` value.
*
* @param label accessible name of the navigation
* @param tabs `[{to, labelKey, icon, active}]`
*/
export default function TabNav({label, tabs}) {
const {t} = useTranslation();
return (
<nav aria-label={label} className="flex max-w-full overflow-x-auto rounded-lg bg-muted p-1 text-sm sm:inline-flex sm:overflow-visible">
{tabs.map(({to, labelKey, icon: Icon, active}) => (
<Link
key={to}
to={to}
title={t(labelKey)}
aria-current={active ? 'page' : undefined}
className={cn(
'flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 font-medium transition-colors',
active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
)}
>
<Icon className="size-4 shrink-0"/>
{/* Below sm only the active tab shows its label; the others keep it as their name. */}
<span className={cn(!active && 'sr-only sm:not-sr-only')}>{t(labelKey)}</span>
</Link>
))}
</nav>
);
}
-55
View File
@@ -1,55 +0,0 @@
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog';
import NewsEntry from './NewsEntry.jsx';
import {fetchNews, markNewsSeen} from '../utils/news.js';
/**
* The news entries the user has not seen, newest first. Any close (Got it, X, Esc, click outside) marks them seen.
*/
export default function WhatsNewDialog() {
const {t} = useTranslation();
const [unseen, setUnseen] = useState([]);
const [open, setOpen] = useState(false);
useEffect(() => {
// A failed load shows nothing, not an error.
fetchNews()
.then((response) => (response.ok ? response.json() : null))
.then((data) => {
const entries = (data?.results ?? []).filter((entry) => !entry.seen);
setUnseen(entries);
setOpen(entries.length > 0);
})
.catch(() => undefined);
}, []);
const gotIt = async () => {
setOpen(false);
try {
const response = await markNewsSeen();
if (!response.ok) toast.error(t('NEWS.SAVE_FAILED'));
} catch {
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
}
};
return (
<Dialog open={open} onOpenChange={(next) => { if (!next) gotIt(); }}>
<DialogContent className="max-h-[90vh] overflow-y-auto" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>{t('NEWS.TITLE')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{unseen.map((entry) => <NewsEntry key={entry.id} entry={entry}/>)}
</div>
<DialogFooter>
<Button onClick={gotIt}>{t('NEWS.GOT_IT')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
-106
View File
@@ -1,106 +0,0 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
import {toast} from 'sonner';
import WhatsNewDialog from './WhatsNewDialog.jsx';
import {newsText, newsTitle} from '../utils/news.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 NEW = {id: 2, titleEn: 'New feature', titleDe: 'Neue Funktion', textEn: 'English text', textDe: 'Deutscher Text',
publishedAt: '2026-10-01T10:00:00Z', seen: false};
const ENGLISH_ONLY = {...NEW, id: 3, titleEn: 'English only', titleDe: null, textEn: 'Only English', textDe: null};
const OLD = {...NEW, id: 1, titleEn: 'Old', titleDe: 'Alt', seen: true};
const newsRoutes = (list, seen = json(204)) => fetchRoutes({'GET /api/news': list, 'PUT /api/news/seen': seen});
const putCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT');
const showNews = async (...entries) => {
newsRoutes(json(200, {results: entries}));
render(<WhatsNewDialog/>);
await act(async () => undefined);
};
describe('WhatsNewDialog', () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it('shows only the unseen entries, German when set and English otherwise', async () => {
await showNews(NEW, ENGLISH_ONLY, OLD);
expect(screen.getByRole('dialog')).toBeTruthy();
expect(screen.getByText('Neue Funktion')).toBeTruthy();
expect(screen.getByText('Deutscher Text')).toBeTruthy();
expect(screen.getByText('English only')).toBeTruthy();
expect(screen.getByText('Only English')).toBeTruthy();
expect(screen.queryByText('Alt')).toBeNull();
});
it('marks the entries seen and closes on "Got it"', async () => {
await showNews(NEW);
fireEvent.click(screen.getByText('NEWS.GOT_IT'));
await act(async () => undefined);
expect(putCalls().map(([url]) => url)).toEqual([expect.stringContaining('/api/news/seen')]);
expect(screen.queryByRole('dialog')).toBeNull();
});
it('marks the entries seen when closed with X', async () => {
await showNews(NEW);
fireEvent.click(screen.getByRole('button', {name: 'DIALOG.CLOSE'}));
await act(async () => undefined);
expect(screen.queryByRole('dialog')).toBeNull();
expect(putCalls()).toHaveLength(1);
});
it('marks the entries seen when closed with Esc', async () => {
await showNews(NEW);
fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Escape'});
await act(async () => undefined);
expect(screen.queryByRole('dialog')).toBeNull();
expect(putCalls()).toHaveLength(1);
});
it.each([
['all entries are seen', json(200, {results: [OLD]})],
['there are no entries', json(200, {results: []})],
['the load fails', json(500, {})],
])('shows nothing when %s', async (_, answer) => {
newsRoutes(answer);
render(<WhatsNewDialog/>);
await act(async () => undefined);
expect(screen.queryByRole('dialog')).toBeNull();
expect(toast.error).not.toHaveBeenCalled();
});
it('shows an error when the seen mark is not saved', async () => {
newsRoutes(json(200, {results: [NEW]}), json(500, {}));
render(<WhatsNewDialog/>);
await act(async () => undefined);
fireEvent.click(screen.getByText('NEWS.GOT_IT'));
await act(async () => undefined);
expect(toast.error).toHaveBeenCalledWith('NEWS.SAVE_FAILED');
});
});
describe('newsTitle / newsText', () => {
it('pick German only for a German UI and a set German field', () => {
expect(newsTitle(NEW, 'de-DE')).toBe('Neue Funktion');
expect(newsText(NEW, 'de')).toBe('Deutscher Text');
expect(newsTitle(NEW, 'en')).toBe('New feature');
expect(newsText(ENGLISH_ONLY, 'de')).toBe('Only English');
expect(newsTitle(ENGLISH_ONLY, undefined)).toBe('English only');
});
});
+15
View File
@@ -0,0 +1,15 @@
import {ChartColumn, Dices, LayoutDashboard, Library, Users} from 'lucide-react';
// Search, the wishlist and the queue are reached from the library; the old following page is part of Community.
export const NAV_ITEMS = [
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist', '/backlog', '/search']},
{to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices},
{to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn},
{to: '/community', labelKey: 'NAV.COMMUNITY', Icon: Users, alsoActiveOn: ['/following']},
];
const matches = (to, pathname) => pathname === to || pathname.startsWith(`${to}/`);
export const isNavActive = (item, pathname) =>
matches(item.to, pathname) || (item.alsoActiveOn?.some((to) => matches(to, pathname)) ?? false);
+25
View File
@@ -0,0 +1,25 @@
import {describe, expect, it} from 'vitest';
import {isNavActive, NAV_ITEMS} from './navItems.js';
const item = (to) => NAV_ITEMS.find((candidate) => candidate.to === to);
describe('isNavActive', () => {
it('theOwnPathAndItsChildrenAreActive', () => {
expect(isNavActive(item('/library'), '/library')).toBe(true);
expect(isNavActive(item('/library'), '/library/220')).toBe(true);
});
it('thePagesOfTheLibraryKeepLibraryActive', () => {
for (const path of ['/wishlist', '/backlog', '/search']) expect(isNavActive(item('/library'), path)).toBe(true);
});
it('theOldFollowingUrlKeepsCommunityActive', () => {
expect(isNavActive(item('/community'), '/following')).toBe(true);
});
it('aPathWithTheSamePrefixIsNotActive', () => {
expect(isNavActive(item('/library'), '/library-x')).toBe(false);
expect(isNavActive(item('/stats'), '/dashboard')).toBe(false);
});
});
+85
View File
@@ -0,0 +1,85 @@
import {useEffect, useState} from 'react';
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Loader2, Search} from 'lucide-react';
import {Input} from '@/components/ui/input';
import FitText from '../FitText.jsx';
import {searchPlayers} from '../../utils/follows.js';
import {profileHref} from '../../utils/people.js';
const DEBOUNCE_MS = 400;
const MIN_LENGTH = 2;
const MAX_LENGTH = 100;
// idle | loading | done | limited | failed
function usePlayerSearch(query) {
const [state, setState] = useState('idle');
const [results, setResults] = useState([]);
useEffect(() => {
const trimmed = query.trim();
if (trimmed.length < MIN_LENGTH) {
setState('idle');
setResults([]);
return undefined;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
setState('loading');
const {ok, status, data} = await searchPlayers(trimmed, controller.signal);
if (controller.signal.aborted || status === 401) return;
if (ok) {
setResults(data.results);
setState('done');
} else {
setState(status === 429 ? 'limited' : 'failed');
}
}, DEBOUNCE_MS);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query]);
return {state, results};
}
/**
* Searches public profiles by name; a hit links to the profile, where the user can follow.
*/
export default function PlayerSearch() {
const {t} = useTranslation();
const [query, setQuery] = useState('');
const {state, results} = usePlayerSearch(query);
return (
<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" value={query} maxLength={MAX_LENGTH} className="pl-8"
placeholder={t('COMMUNITY.SEARCH_PLACEHOLDER')} aria-label={t('COMMUNITY.SEARCH_PLACEHOLDER')}
onChange={(e) => setQuery(e.target.value)}/>
</div>
<p className="text-xs text-muted-foreground">{t('COMMUNITY.SEARCH_HINT')}</p>
{state === 'loading' && <Loader2 className="size-4 animate-spin text-muted-foreground"/>}
{state === 'limited' && <p className="text-sm text-muted-foreground">{t('COMMUNITY.SEARCH_LIMITED')}</p>}
{state === 'failed' && <p className="text-sm text-destructive">{t('COMMUNITY.SEARCH_FAILED')}</p>}
{state === 'done' && results.length === 0 && (
<p className="text-sm text-muted-foreground">{t('COMMUNITY.SEARCH_NONE')}</p>
)}
{state === 'done' && results.length > 0 && (
<ul className="divide-y rounded-md border">
{results.map(({displayName}) => (
<li key={displayName}>
<Link to={profileHref(displayName)}
className="@container block px-3 py-2 font-medium transition-colors hover:bg-muted/50 hover:text-primary">
<FitText text={displayName}/>
</Link>
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,83 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, screen} from '@testing-library/react';
import PlayerSearch from './PlayerSearch.jsx';
import {fetchRoutes, json} from '../../test/fetchRoutes.js';
import {renderWithProviders} from '../../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../../test/i18n-mock.js'));
const DEBOUNCE_MS = 400;
const searchCalls = (mock) => mock.mock.calls.filter(([url]) => url.includes('/api/follows/players'));
const type = (text) => fireEvent.change(screen.getByRole('searchbox'), {target: {value: text}});
const me = {'GET /api/users/me': json(200, {settings: {}})};
const settle = (ms) => act(() => vi.advanceTimersByTimeAsync(ms));
describe('PlayerSearch', () => {
beforeEach(() => {
vi.useFakeTimers();
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
vi.useRealTimers();
localStorage.clear();
});
it('typingTwoLetters_SearchesOnceAfterTheDebounceAndLinksTheHits', async () => {
const mock = fetchRoutes({...me, 'GET /api/follows/players': json(200, {results: [{displayName: 'Lobo'}, {displayName: 'Zolo'}]})});
renderWithProviders(<PlayerSearch/>);
type('l');
type('lo');
await settle(DEBOUNCE_MS - 1);
expect(searchCalls(mock)).toHaveLength(0);
await settle(1);
expect(searchCalls(mock)).toHaveLength(1);
expect(searchCalls(mock)[0][0]).toContain('q=lo');
expect(screen.getByRole('link', {name: 'Lobo'}).getAttribute('href')).toBe('/u/Lobo');
expect(screen.getByRole('link', {name: 'Zolo'})).toBeTruthy();
});
it('oneLetter_SendsNoRequest', async () => {
const mock = fetchRoutes(me);
renderWithProviders(<PlayerSearch/>);
type('l');
await settle(DEBOUNCE_MS * 2);
expect(searchCalls(mock)).toHaveLength(0);
});
it('noHit_SaysSo', async () => {
fetchRoutes({...me, 'GET /api/follows/players': json(200, {results: []})});
renderWithProviders(<PlayerSearch/>);
type('xx');
await settle(DEBOUNCE_MS);
expect(screen.getByText('COMMUNITY.SEARCH_NONE')).toBeTruthy();
});
it('answer429_ShowsTheLimitText', async () => {
fetchRoutes({...me, 'GET /api/follows/players': json(429, {code: 'TOO_MANY_SEARCHES'})});
renderWithProviders(<PlayerSearch/>);
type('lo');
await settle(DEBOUNCE_MS);
expect(screen.getByText('COMMUNITY.SEARCH_LIMITED')).toBeTruthy();
});
it('answer500_ShowsTheFailedText', async () => {
fetchRoutes({...me, 'GET /api/follows/players': json(500, {})});
renderWithProviders(<PlayerSearch/>);
type('lo');
await settle(DEBOUNCE_MS);
expect(screen.getByText('COMMUNITY.SEARCH_FAILED')).toBeTruthy();
});
});
+33 -4
View File
@@ -1,12 +1,22 @@
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {StatCount} from './StatDrill.jsx'; import {StatCount} from './StatDrill.jsx';
import {formatDay} from '../../utils/format.js'; import {formatDay} from '../../utils/format.js';
import {trendPoints} from '../../utils/stats.js'; import {trendNet, trendPoints, trendRange} from '../../utils/stats.js';
import {trendBars} from '../../utils/statCharts.js'; import {trendBars} from '../../utils/statCharts.js';
import {cn} from '@/lib/utils';
const WIDTH = 300; const WIDTH = 300;
const HEIGHT = 60; const HEIGHT = 60;
// A bigger pile is bad news, a smaller one good news.
const DIRECTIONS = {
up: {text: 'text-rose-600 dark:text-rose-400', stroke: 'stroke-rose-500', fill: 'fill-rose-500'},
down: {text: 'text-emerald-600 dark:text-emerald-400', stroke: 'stroke-emerald-500', fill: 'fill-emerald-500'},
flat: {text: 'text-muted-foreground', stroke: 'stroke-muted-foreground', fill: 'fill-muted-foreground'},
};
const signed = (net) => (net > 0 ? `+${net}` : net < 0 ? `−${-net}` : '±0');
/** /**
* The pile size over the last 90 days and the moves of this month. * The pile size over the last 90 days and the moves of this month.
* *
@@ -16,8 +26,18 @@ const HEIGHT = 60;
export default function PileTrend({trend, chart = 'LINE'}) { export default function PileTrend({trend, chart = 'LINE'}) {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
const {days, thisMonth} = trend; const {days, thisMonth} = trend;
const net = trendNet(days);
const direction = DIRECTIONS[net > 0 ? 'up' : net < 0 ? 'down' : 'flat'];
const {min, max} = trendRange(days);
// The chart starts at 0; the min label shows only when it does not touch the max label.
const showMin = max > 0 && min / max < 0.8;
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{days.length > 1 && (
<p data-testid="pile-net" className={cn('text-2xl font-bold tabular-nums', direction.text)}>
{t('STATS.TREND.NET', {net: signed(net), count: Math.abs(net)})}
</p>
)}
<p className="text-sm"> <p className="text-sm">
<span>{t('STATS.TREND.THIS_MONTH')}</span>{' '} <span>{t('STATS.TREND.THIS_MONTH')}</span>{' '}
<StatCount params={thisMonth.added > 0 ? {key: 'TREND_ADDED'} : null} title={t('STATS.DRILL.TREND_ADDED')}> <StatCount params={thisMonth.added > 0 ? {key: 'TREND_ADDED'} : null} title={t('STATS.DRILL.TREND_ADDED')}>
@@ -30,18 +50,27 @@ export default function PileTrend({trend, chart = 'LINE'}) {
</p> </p>
{days.length > 1 ? ( {days.length > 1 ? (
<div> <div>
<div className="flex gap-2">
<div aria-hidden="true" className="relative h-16 shrink-0 text-xs tabular-nums text-muted-foreground">
<span className="absolute right-0 top-0 leading-none">{max}</span>
{showMin && (
<span className="absolute right-0 translate-y-1/2 leading-none"
style={{bottom: `${(min / max) * 100}%`}}>{min}</span>
)}
</div>
<StatCount params={{key: 'TREND_MOVES'}} title={t('STATS.DRILL.TREND_MOVES')} <StatCount params={{key: 'TREND_MOVES'}} title={t('STATS.DRILL.TREND_MOVES')}
label={t('STATS.DRILL.TREND_MOVES')} className="flex w-full no-underline"> label={t('STATS.DRILL.TREND_MOVES')} className="flex min-w-0 flex-1 no-underline">
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} preserveAspectRatio="none" className="h-16 w-full" <svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} preserveAspectRatio="none" className="h-16 w-full"
role="img" aria-label={t('STATS.TREND.TITLE')}> role="img" aria-label={t('STATS.TREND.TITLE')}>
{chart === 'BARS' ? trendBars(days, WIDTH, HEIGHT).map((bar) => ( {chart === 'BARS' ? trendBars(days, WIDTH, HEIGHT).map((bar) => (
<rect key={bar.x} x={bar.x} y={bar.y} width={bar.w} height={bar.h} className="fill-rose-500"/> <rect key={bar.x} x={bar.x} y={bar.y} width={bar.w} height={bar.h} className={direction.fill}/>
)) : ( )) : (
<polyline data-testid="pile-trend" points={trendPoints(days, WIDTH, HEIGHT)} fill="none" <polyline data-testid="pile-trend" points={trendPoints(days, WIDTH, HEIGHT)} fill="none"
strokeWidth={2} vectorEffect="non-scaling-stroke" className="stroke-rose-500"/> strokeWidth={2} vectorEffect="non-scaling-stroke" className={direction.stroke}/>
)} )}
</svg> </svg>
</StatCount> </StatCount>
</div>
<table className="sr-only"> <table className="sr-only">
<tbody> <tbody>
{days.map((d) => ( {days.map((d) => (
+50
View File
@@ -0,0 +1,50 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, screen} from '@testing-library/react';
import PileTrend from './PileTrend.jsx';
import {renderWithProviders} from '../../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../../test/i18n-mock.js'));
const day = (date, backlogGames) => ({day: date, backlogGames, neverPlayed: 0, backlogMinutes: 0});
const trendOf = (...sizes) => ({
days: sizes.map((size, index) => day(`2026-09-0${index + 1}`, size)),
thisMonth: {added: 1, done: 1},
});
const renderTrend = (trend, chart) => renderWithProviders(<PileTrend trend={trend} chart={chart}/>);
describe('PileTrend', () => {
afterEach(cleanup);
it('aGrowingPile_IsRedAndAShrinkingPileIsGreen', () => {
renderTrend(trendOf(5, 8));
expect(screen.getByTestId('pile-net').className).toContain('text-rose-600');
expect(screen.getByTestId('pile-trend').getAttribute('class')).toContain('stroke-rose-500');
cleanup();
renderTrend(trendOf(8, 5));
expect(screen.getByTestId('pile-net').className).toContain('text-emerald-600');
expect(screen.getByTestId('pile-trend').getAttribute('class')).toContain('stroke-emerald-500');
});
it('anUnchangedPile_IsMuted', () => {
renderTrend(trendOf(5, 7, 5));
expect(screen.getByTestId('pile-net').className).toContain('text-muted-foreground');
});
it('bars_TakeTheDirectionColor', () => {
const {container} = renderTrend(trendOf(5, 8), 'BARS');
expect(container.querySelector('rect').getAttribute('class')).toContain('fill-rose-500');
});
it('showsTheBiggestAndTheSmallestPile', () => {
const {container} = renderTrend(trendOf(10, 4, 6));
const labels = [...container.querySelectorAll('[aria-hidden="true"] span')].map((span) => span.textContent);
expect(labels).toEqual(['10', '4']);
});
it('oneDay_HasNoHeadline', () => {
renderTrend(trendOf(5));
expect(screen.queryByTestId('pile-net')).toBeNull();
});
});
+3 -2
View File
@@ -63,13 +63,14 @@ function SliceRow({slice, drillKey, label, linkOf, hrefOf, valueText, color}) {
* @param linkOf library URL of a slice name; the own views link there instead of opening the dialog * @param linkOf library URL of a slice name; the own views link there instead of opening the dialog
* @param hrefOf `(slice) => url|null`: a slice links there instead of using `linkOf` or the dialog; never "other" or 0 * @param hrefOf `(slice) => url|null`: a slice links there instead of using `linkOf` or the dialog; never "other" or 0
* @param valueText `(slice) => string`: the text at the right of a legend row * @param valueText `(slice) => string`: the text at the right of a legend row
* @param colorOf `(slice) => color`: the colors of the slices with games, instead of the palette
* @param center shown in the hole of a donut * @param center shown in the hole of a donut
* @param note a line under the legend * @param note a line under the legend
* *
* A slice without games has a legend row only. * A slice without games has a legend row only.
*/ */
export default function SliceChart({slices, donut = false, drillKey, label = (name) => name, linkOf, hrefOf, valueText, center, note}) { export default function SliceChart({slices, donut = false, drillKey, label = (name) => name, linkOf, hrefOf, valueText, colorOf, center, note}) {
const colors = sliceColors(slices); const colors = colorOf ? slices.map((slice) => (slice.value > 0 ? colorOf(slice) : null)) : sliceColors(slices);
const paths = slicePaths(slices.map((slice) => slice.value), RADIUS, donut ? HOLE : 0); const paths = slicePaths(slices.map((slice) => slice.value), RADIUS, donut ? HOLE : 0);
return ( return (
<div className="@container space-y-2"> <div className="@container space-y-2">
+3 -1
View File
@@ -1,5 +1,6 @@
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {STATUS_META} from '../GameStatusBadge.jsx';
import SliceChart from './SliceChart.jsx'; import SliceChart from './SliceChart.jsx';
import {StatCount} from './StatDrill.jsx'; import {StatCount} from './StatDrill.jsx';
import {libraryLink} from '../../utils/statGames.js'; import {libraryLink} from '../../utils/statGames.js';
@@ -27,6 +28,7 @@ export default function StatusDonut({byStatus, totalParams = {key: 'TOTAL'}}) {
); );
return ( return (
<SliceChart slices={slices} donut drillKey="STATUS" label={(s) => t(`BACKLOG.STATUS.${s}`)} <SliceChart slices={slices} donut drillKey="STATUS" label={(s) => t(`BACKLOG.STATUS.${s}`)}
linkOf={libraryLink} center={center}/> linkOf={libraryLink} center={center}
colorOf={(slice) => STATUS_META[slice.name].color}/>
); );
} }
+16
View File
@@ -0,0 +1,16 @@
import {useCallback} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
/** Copies a profile link. When the browser refuses, the error toast shows the link to copy by hand. */
export default function useCopyLink() {
const {t} = useTranslation();
return useCallback(async (link) => {
try {
await navigator.clipboard.writeText(link);
toast.success(t('SETTINGS.PUBLIC_PROFILE_COPIED'));
} catch {
toast.error(link);
}
}, [t]);
}
+34
View File
@@ -0,0 +1,34 @@
import {useEffect, useState} from 'react';
import {fetchInbox, notificationTotal} from '../utils/inbox.js';
import {fetchUnseenNews} from '../utils/news.js';
import {fetchQuestionCount} from '../utils/questions.js';
const NONE = {unread: 0, news: 0, questions: 0};
/**
* What waits for the user: unread inbox notes, unseen news and open questions, and their sum. Loaded again on every
* page change; a call that fails counts 0.
*/
export default function useNotifications(enabled, pathname) {
const [counts, setCounts] = useState(NONE);
useEffect(() => {
if (!enabled) return undefined;
let current = true;
Promise.all([fetchInbox(), fetchUnseenNews(), fetchQuestionCount()]).then(([inbox, news, questions]) => {
if (!current) return;
setCounts({
unread: inbox.ok ? inbox.data.unread : 0,
news: news.length,
questions: questions.ok ? questions.data.count : 0,
});
});
return () => {
current = false;
};
}, [enabled, pathname]);
const shown = enabled ? counts : NONE;
return {...shown, total: notificationTotal(shown)};
}
+58
View File
@@ -0,0 +1,58 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest';
import {cleanup, renderHook, waitFor} from '@testing-library/react';
import useNotifications from './useNotifications.js';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
afterEach(cleanup);
const news = (...seen) => json(200, {results: seen.map((flag, id) => ({id, seen: flag}))});
describe('useNotifications', () => {
it('sumsUnreadNotesUnseenNewsAndOpenQuestions', async () => {
fetchRoutes({
'GET /api/inbox': json(200, {results: [], unread: 2}),
'GET /api/news': news(false, true, false),
'GET /api/library/questions/count': json(200, {count: 4}),
});
const {result} = renderHook(() => useNotifications(true, '/dashboard'));
await waitFor(() => expect(result.current.total).toBe(8));
expect(result.current).toEqual({unread: 2, news: 2, questions: 4, total: 8});
});
it('aFailedCallCountsZero', async () => {
fetchRoutes({
'GET /api/inbox': json(500, {}),
'GET /api/news': json(500, {}),
'GET /api/library/questions/count': json(200, {count: 1}),
});
const {result} = renderHook(() => useNotifications(true, '/dashboard'));
await waitFor(() => expect(result.current.total).toBe(1));
});
it('loadsAgainOnAPageChange', async () => {
let count = 1;
fetchRoutes({
'GET /api/inbox': json(200, {results: [], unread: 0}),
'GET /api/news': news(),
'GET /api/library/questions/count': () => json(200, {count}),
});
const {result, rerender} = renderHook(({path}) => useNotifications(true, path), {initialProps: {path: '/a'}});
await waitFor(() => expect(result.current.questions).toBe(1));
count = 0;
rerender({path: '/b'});
await waitFor(() => expect(result.current.questions).toBe(0));
});
it('asksNothingWhileDisabled', () => {
const mock = fetchRoutes({});
const {result} = renderHook(() => useNotifications(false, '/'));
expect(result.current.total).toBe(0);
expect(mock).not.toHaveBeenCalled();
});
});
+6 -3
View File
@@ -6,11 +6,14 @@ const BACKEND_ERROR_KEY = {
DISPLAY_NAME_ALREADY_IN_USE: 'SETTINGS.ERROR_DISPLAY_NAME_IN_USE', DISPLAY_NAME_ALREADY_IN_USE: 'SETTINGS.ERROR_DISPLAY_NAME_IN_USE',
}; };
/** Like `useSaveResponse`, but maps backend error codes, shows "saved" and resolves to `{ok}`. */ /**
* Like `useSaveResponse`, but maps backend error codes, shows "saved" and resolves to `{ok}`. `silent` leaves out
* "saved": a switch that shows its new state at once needs no message, only a failure does.
*/
export default function usePersistSettings() { export default function usePersistSettings() {
const {t} = useTranslation(); const {t} = useTranslation();
return useCallback(async (send) => { return useCallback(async (send, {silent = false} = {}) => {
try { try {
const response = await send(); const response = await send();
@@ -23,7 +26,7 @@ export default function usePersistSettings() {
return {ok: false}; return {ok: false};
} }
toast.success(t('SETTINGS.SAVED')); if (!silent) toast.success(t('SETTINGS.SAVED'));
return {ok: true}; return {ok: true};
} catch (err) { } catch (err) {
console.error('Settings save error:', err); console.error('Settings save error:', err);
+31
View File
@@ -105,3 +105,34 @@ describe('people texts', () => {
expect(i18n.t('PEOPLE.OTHER_OWNERS', {count: 1, lng: 'de'})).toBe('1 anderer Besitzer'); expect(i18n.t('PEOPLE.OTHER_OWNERS', {count: 1, lng: 'de'})).toBe('1 anderer Besitzer');
}); });
}); });
describe('review findings', () => {
// REVIEW-FINDING: QUESTIONS.INTRO says a "No" asks again "in a few months"; Core snoozes Start/Finished for
// 5 more hours of playtime, Dropped for 90 idle days and a Rate "Skip" for good (Core readme, QuestionService).
it('reviewFinding_questionsIntroDoesNotPromiseAFewMonths', () => {
expect(readTexts('de.json').QUESTIONS.INTRO).not.toMatch(/Monaten/);
expect(readTexts('en.json').QUESTIONS.INTRO).not.toMatch(/months/);
});
// REVIEW-FINDING: "in der Verknüpfungen" is wrong German (dative singular article before a plural noun).
it('reviewFinding_deLinkQueueHintsAreGrammatical', () => {
const texts = flatTexts(readTexts('de.json')).filter(([, text]) => /in der \[?Verknüpfungen/.test(text));
expect(texts).toEqual([]);
});
});
describe('glossary', () => {
const OLD_TERMS = {
'de.json': ['Stapel', 'Nächstes Spiel', 'Mein Backlog', 'Verknüpfungs-Warteschlange', 'synchronisier', 'erneut'],
'en.json': ['Up next', 'My backlog', 'Please try again', 'Synchroniz'],
};
it('de_And_En_UseTheGlossary', () => {
for (const [file, terms] of Object.entries(OLD_TERMS)) {
const texts = flatTexts(readTexts(file)).map(([, text]) => text);
for (const term of terms) {
expect(texts.filter((text) => text.includes(term)), `${file}: ${term}`).toEqual([]);
}
}
});
});
+81
View File
@@ -0,0 +1,81 @@
import {Link, useSearchParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ChartColumn, Loader2, Search, UserCheck, Users} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card, CardContent} from '@/components/ui/card';
import CommunityStats from '../components/CommunityStats.jsx';
import PageContainer from '../components/PageContainer.jsx';
import TabNav from '../components/TabNav.jsx';
import FollowingList from '../components/people/FollowingList.jsx';
import PlayerSearch from '../components/people/PlayerSearch.jsx';
import useFollowing from '../hooks/useFollowing.js';
import {profileHref} from '../utils/people.js';
import {searchTabs, tabOf} from '../utils/tabs.js';
const TAB_META = {
following: {labelKey: 'COMMUNITY.TAB_FOLLOWING', icon: UserCheck},
followers: {labelKey: 'COMMUNITY.TAB_FOLLOWERS', icon: Users},
players: {labelKey: 'COMMUNITY.TAB_PLAYERS', icon: Search},
stats: {labelKey: 'COMMUNITY.TAB_STATS', icon: ChartColumn},
};
const TABS = Object.keys(TAB_META);
function Followers({followers, onRemove}) {
const {t} = useTranslation();
if (followers.length === 0) return <p className="text-sm text-muted-foreground">{t('FOLLOW.NO_FOLLOWERS')}</p>;
return (
<ul className="divide-y">
{followers.map((entry) => (
<li key={entry.id} className="flex items-center justify-between gap-2 py-3 text-sm">
{entry.displayName
? <Link to={profileHref(entry.displayName)} className="min-w-0 truncate font-medium hover:text-primary hover:underline">{entry.displayName}</Link>
: <span className="min-w-0 truncate text-muted-foreground">{t('FOLLOW.PRIVATE_USER')}</span>}
<Button variant="outline" size="sm" onClick={() => onRemove(entry.id)}>{t('FOLLOW.REMOVE')}</Button>
</li>
))}
</ul>
);
}
function Network({tab}) {
const {t} = useTranslation();
const {follows, failed, unfollow} = useFollowing();
return (
<Card>
<CardContent>
{failed && <p className="text-sm text-destructive">{t('FOLLOW.LOAD_ERROR')}</p>}
{!failed && !follows && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
{follows && tab === 'following' && (
follows.following.length === 0 ? (
<div className="space-y-2 text-sm">
<p className="text-muted-foreground">{t('FOLLOW.NO_FOLLOWING')}</p>
<Link to="/community?tab=players" className="font-medium text-primary hover:underline">{t('FOLLOW.FIND_PLAYERS')}</Link>
</div>
) : <FollowingList entries={follows.following} onUnfollow={unfollow}/>
)}
{follows && tab === 'followers' && <Followers followers={follows.followers} onRemove={unfollow}/>}
</CardContent>
</Card>
);
}
/**
* Everything about other users: who the user follows, the followers, a search for public profiles, and the stats of
* all users. The tab is the `tab` search parameter.
*/
export default function CommunityPage() {
const {t} = useTranslation();
const [params] = useSearchParams();
const tab = tabOf(params, TABS);
return (
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
<h1 className="text-3xl font-bold tracking-tight">{t('NAV.COMMUNITY')}</h1>
<TabNav label={t('NAV.COMMUNITY')} tabs={searchTabs('/community', TAB_META, tab)}/>
{(tab === 'following' || tab === 'followers') && <Network tab={tab}/>}
{tab === 'players' && <PlayerSearch/>}
{tab === 'stats' && <CommunityStats/>}
</PageContainer>
);
}
+100
View File
@@ -0,0 +1,100 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import CommunityPage from './CommunityPage.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);
const person = (id, displayName, lastActivityAt) => ({id, displayName, lastActivityAt});
const renderTab = (tab) => renderWithProviders(<CommunityPage/>, {entries: [tab ? `/community?tab=${tab}` : '/community']});
describe('CommunityPage following tab', () => {
it('listsThePeopleByLastActivityNewestFirstAndThoseWithoutActivityLast', async () => {
fetchRoutes({'GET /api/follows': json(200, {followers: [], following: [
person(1, 'Anna', null), person(2, 'Bert', '2026-10-01T10:00:00Z'), person(3, 'Cleo', '2026-10-09T10:00:00Z')]})});
renderTab('following');
await screen.findByRole('link', {name: 'Cleo'});
const names = screen.getAllByRole('link').map((link) => link.textContent).filter((name) => ['Cleo', 'Bert', 'Anna'].includes(name));
expect(names).toEqual(['Cleo', 'Bert', 'Anna']);
expect(screen.getByRole('link', {name: 'Cleo'}).getAttribute('href')).toBe('/u/Cleo');
expect(screen.getAllByText('FOLLOW.LAST_ACTIVITY')).toHaveLength(2);
expect(screen.getByText('FOLLOW.NO_ACTIVITY')).toBeTruthy();
});
it('unfollowsByTheFollowRowAndLoadsTheListAgain', async () => {
let list = [person(5, 'Anna', null), person(6, 'Bert', null)];
const mock = fetchRoutes({
'GET /api/follows': () => json(200, {followers: [], following: list}),
'DELETE /api/follows/5': () => {
list = [list[1]];
return json(204);
},
});
renderTab('following');
fireEvent.click((await screen.findAllByRole('button', {name: 'FOLLOW.UNFOLLOW'}))[0]);
await waitFor(() => expect(screen.queryByRole('link', {name: 'Anna'})).toBeNull());
expect(screen.getByRole('link', {name: 'Bert'})).toBeTruthy();
expect(mock.mock.calls.filter(([, init]) => init?.method === 'DELETE')).toHaveLength(1);
});
it('whenThereIsNobody_SaysSoAndLinksToThePlayerSearch', async () => {
fetchRoutes({'GET /api/follows': json(200, {followers: [], following: []})});
renderTab('following');
expect(await screen.findByText('FOLLOW.NO_FOLLOWING')).toBeTruthy();
expect(screen.getByRole('link', {name: 'FOLLOW.FIND_PLAYERS'}).getAttribute('href')).toBe('/community?tab=players');
});
it('showsAnErrorWhenTheListFails', async () => {
fetchRoutes({'GET /api/follows': json(500, {})});
renderTab('following');
expect(await screen.findByText('FOLLOW.LOAD_ERROR')).toBeTruthy();
});
it('anUnknownTabShowsTheFollowingTab', async () => {
fetchRoutes({'GET /api/follows': json(200, {followers: [], following: [person(1, 'Anna', null)]})});
renderTab('x');
expect(await screen.findByRole('link', {name: 'Anna'})).toBeTruthy();
expect(screen.getByRole('link', {name: 'COMMUNITY.TAB_FOLLOWING'}).getAttribute('aria-current')).toBe('page');
});
});
describe('CommunityPage followers tab', () => {
it('listsNamesAndPrivateFollowersAndRemovesByTheRowId', async () => {
let followers = [person(7, 'Dora', null), person(8, null, null)];
const mock = fetchRoutes({
'GET /api/follows': () => json(200, {following: [], followers}),
'DELETE /api/follows/7': () => {
followers = [followers[1]];
return json(204);
},
});
renderTab('followers');
expect(await screen.findByRole('link', {name: 'Dora'})).toBeTruthy();
expect(screen.getByText('FOLLOW.PRIVATE_USER')).toBeTruthy();
fireEvent.click(screen.getAllByRole('button', {name: 'FOLLOW.REMOVE'})[0]);
await waitFor(() => expect(screen.queryByRole('link', {name: 'Dora'})).toBeNull());
expect(mock.mock.calls.filter(([, init]) => init?.method === 'DELETE')).toHaveLength(1);
});
it('saysSoWhenNobodyFollows', async () => {
fetchRoutes({'GET /api/follows': json(200, {followers: [], following: []})});
renderTab('followers');
expect(await screen.findByText('FOLLOW.NO_FOLLOWERS')).toBeTruthy();
});
});
-2
View File
@@ -18,7 +18,6 @@ import PileOfShameCard from '../components/PileOfShameCard.jsx';
import SteamGameCount from '../components/SteamGameCount'; import SteamGameCount from '../components/SteamGameCount';
import UpNextCard from '../components/UpNextCard.jsx'; import UpNextCard from '../components/UpNextCard.jsx';
import SyncChangesBanner from '../components/SyncChangesBanner.jsx'; import SyncChangesBanner from '../components/SyncChangesBanner.jsx';
import WhatsNewDialog from '../components/WhatsNewDialog.jsx';
import useDashboardData from '../hooks/useDashboardData.js'; import useDashboardData from '../hooks/useDashboardData.js';
import useLibrarySync from '../hooks/useLibrarySync.js'; import useLibrarySync from '../hooks/useLibrarySync.js';
import useForceSync from '../hooks/useForceSync.js'; import useForceSync from '../hooks/useForceSync.js';
@@ -161,7 +160,6 @@ const Dashboard = () => {
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing} <TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/> defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
<WhatsNewDialog/>
{/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */} {/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
<DashboardHint user={userData} canSync={canSync} summary={summaryFailed ? null : summary ?? undefined} <DashboardHint user={userData} canSync={canSync} summary={summaryFailed ? null : summary ?? undefined}
trend={stats.failed ? null : stats.stats ? stats.stats.trend ?? null : undefined}/> trend={stats.failed ? null : stats.stats ? stats.stats.trend ?? null : undefined}/>
+1 -2
View File
@@ -19,7 +19,6 @@ const empty = {content: [], total: 0, byStatus: {}};
const quietRoutes = { const quietRoutes = {
'GET /api/library/summary': json(200, empty), 'GET /api/library/summary': json(200, empty),
'GET /api/hints': json(200, {results: []}), 'GET /api/hints': json(200, {results: []}),
'GET /api/news': json(200, {results: []}),
'GET /api/library/sync-changes': json(204, {}), 'GET /api/library/sync-changes': json(204, {}),
}; };
@@ -240,7 +239,7 @@ describe('Dashboard', () => {
renderDashboard(); renderDashboard();
expect(await screen.findByText('DASHBOARD.EMPTY.NO_SOURCE')).toBeTruthy(); expect(await screen.findByText('DASHBOARD.EMPTY.NO_SOURCE')).toBeTruthy();
expect(screen.getByRole('link', {name: 'DASHBOARD.EMPTY.LINK'}).getAttribute('href')).toBe('/settings'); expect(screen.getByRole('link', {name: 'DASHBOARD.EMPTY.LINK'}).getAttribute('href')).toBe('/settings?tab=connections');
expect(screen.getByRole('link', {name: 'DASHBOARD.EMPTY.IMPORT'}).getAttribute('href')).toBe('/import'); expect(screen.getByRole('link', {name: 'DASHBOARD.EMPTY.IMPORT'}).getAttribute('href')).toBe('/import');
expect(screen.queryByText('DASHBOARD.WELCOME_BACK')).toBeNull(); expect(screen.queryByText('DASHBOARD.WELCOME_BACK')).toBeNull();
expect(screen.getByText('DASHBOARD.WELCOME')).toBeTruthy(); expect(screen.getByText('DASHBOARD.WELCOME')).toBeTruthy();
-1
View File
@@ -29,7 +29,6 @@ describe('Dashboard sync on a visit', () => {
fetchRoutes({ fetchRoutes({
'GET /api/library/summary': json(200, empty), 'GET /api/library/summary': json(200, empty),
'GET /api/hints': json(200, {results: []}), 'GET /api/hints': json(200, {results: []}),
'GET /api/news': json(200, {results: []}),
'GET /api/library/sync-changes': () => syncChanges, 'GET /api/library/sync-changes': () => syncChanges,
'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000'}), 'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000'}),
'GET /api/library/dashboard-games': json(200, {top: [], playing: []}), 'GET /api/library/dashboard-games': json(200, {top: [], playing: []}),
-30
View File
@@ -1,30 +0,0 @@
import {useTranslation} from 'react-i18next';
import {Loader2, UserCheck} from 'lucide-react';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import FollowingList from '../components/people/FollowingList.jsx';
import PageContainer from '../components/PageContainer.jsx';
import useFollowing from '../hooks/useFollowing.js';
/**
* The people the user follows, newest activity first. The followers stay in the settings.
*/
export default function FollowingPage() {
const {t} = useTranslation();
const {following, failed, unfollow} = useFollowing();
return (
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><UserCheck className="size-4 text-primary"/>{t('FOLLOW.TITLE')}</CardTitle>
</CardHeader>
<CardContent>
{failed && <p className="text-sm text-destructive">{t('FOLLOW.LOAD_ERROR')}</p>}
{!failed && !following && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
{following && <FollowingList entries={following} onUnfollow={unfollow}/>}
</CardContent>
</Card>
</PageContainer>
);
}
-60
View File
@@ -1,60 +0,0 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import FollowingPage from './FollowingPage.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);
const person = (id, displayName, lastActivityAt) => ({id, displayName, lastActivityAt});
describe('FollowingPage', () => {
it('listsThePeopleByLastActivityNewestFirstAndThoseWithoutActivityLast', async () => {
fetchRoutes({'GET /api/follows': json(200, {followers: [], following: [
person(1, 'Anna', null), person(2, 'Bert', '2026-10-01T10:00:00Z'), person(3, 'Cleo', '2026-10-09T10:00:00Z')]})});
renderWithProviders(<FollowingPage/>);
await screen.findByRole('link', {name: 'Cleo'});
expect(screen.getAllByRole('link').map((link) => link.textContent)).toEqual(['Cleo', 'Bert', 'Anna']);
expect(screen.getByRole('link', {name: 'Cleo'}).getAttribute('href')).toBe('/u/Cleo');
expect(screen.getAllByText('FOLLOW.LAST_ACTIVITY')).toHaveLength(2);
expect(screen.getByText('FOLLOW.NO_ACTIVITY')).toBeTruthy();
});
it('unfollowsByTheFollowRowAndLoadsTheListAgain', async () => {
let list = [person(5, 'Anna', null), person(6, 'Bert', null)];
const mock = fetchRoutes({
'GET /api/follows': () => json(200, {followers: [], following: list}),
'DELETE /api/follows/5': () => {
list = [list[1]];
return json(204);
},
});
renderWithProviders(<FollowingPage/>);
fireEvent.click((await screen.findAllByRole('button', {name: 'FOLLOW.UNFOLLOW'}))[0]);
await waitFor(() => expect(screen.queryByRole('link', {name: 'Anna'})).toBeNull());
expect(screen.getByRole('link', {name: 'Bert'})).toBeTruthy();
expect(mock.mock.calls.filter(([, init]) => init?.method === 'DELETE')).toHaveLength(1);
});
it('saysHowToFollowWhenThereIsNobody', async () => {
fetchRoutes({'GET /api/follows': json(200, {followers: [], following: []})});
renderWithProviders(<FollowingPage/>);
expect(await screen.findByText('FOLLOW.NO_FOLLOWING')).toBeTruthy();
});
it('showsAnErrorWhenTheListFails', async () => {
fetchRoutes({'GET /api/follows': json(500, {})});
renderWithProviders(<FollowingPage/>);
expect(await screen.findByText('FOLLOW.LOAD_ERROR')).toBeTruthy();
});
});
+51 -18
View File
@@ -7,7 +7,7 @@ import useForceSync from '../hooks/useForceSync.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import {libraryQuery, parseSources, parseStatus} from '../utils/backlog.js'; import {libraryQuery, parseSources, parseStatus} from '../utils/backlog.js';
import {GAME_SOURCES, GAME_STATUSES, MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js'; import {GAME_SOURCES, GAME_STATUSES, MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
import {ArrowUpDown, ChevronRight, Clock, EyeOff, Gamepad2, Layers, Library, Plus, RefreshCw, Search, SearchX, Timer, Trophy, Upload} from 'lucide-react'; import {ArrowUpDown, ChevronRight, Clock, EyeOff, Gamepad2, Layers, Library, Plus, RefreshCw, Search, SearchX, Timer, Trash2, Trophy, Upload} from 'lucide-react';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
@@ -28,6 +28,7 @@ import useSetStatus from '../hooks/useSetStatus.js';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import HintText from '../components/HintText.jsx'; import HintText from '../components/HintText.jsx';
import PlatformIcons from '../components/PlatformIcons.jsx'; import PlatformIcons from '../components/PlatformIcons.jsx';
import RemovedGamesList from '../components/RemovedGamesList.jsx';
import {usePreferences} from '../context/PreferencesContext.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {coverEffect} from '../utils/rewards.js'; import {coverEffect} from '../utils/rewards.js';
@@ -136,14 +137,17 @@ export default function GameLibrary() {
const status = parseStatus(searchParams.get('status')); const status = parseStatus(searchParams.get('status'));
const q = searchParams.get('q') ?? ''; const q = searchParams.get('q') ?? '';
const shortOnly = searchParams.get('maxMinutes') === String(SHORT_GAME_MINUTES); const shortOnly = searchParams.get('maxMinutes') === String(SHORT_GAME_MINUTES);
const hiddenOnly = searchParams.get('hidden') === 'true'; // The removed and the hidden list stand alone, also for a hand-made URL: their status, short and source filters
// The hidden list stands alone, also for a hand-made URL: its status and short filters are ignored. // are ignored. Removed wins over hidden.
const listStatus = hiddenOnly ? null : status; const removedOnly = searchParams.get('removed') === 'true';
const listShortOnly = !hiddenOnly && shortOnly; const hiddenOnly = !removedOnly && searchParams.get('hidden') === 'true';
const standalone = hiddenOnly || removedOnly;
const listStatus = standalone ? null : status;
const listShortOnly = !standalone && shortOnly;
const sources = parseSources(searchParams.get('source')); const sources = parseSources(searchParams.get('source'));
const listSources = hiddenOnly ? [] : sources; const listSources = standalone ? [] : sources;
const sourceKey = listSources.join(','); const sourceKey = listSources.join(',');
const filtered = Boolean(listStatus || q.trim() || listShortOnly || hiddenOnly || sourceKey); const filtered = Boolean(listStatus || q.trim() || listShortOnly || standalone || sourceKey);
const [search, setSearch] = useState(q); const [search, setSearch] = useState(q);
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
// A Steam ID or a PlayStation link. Starts true until /games-count answers, so the sync controls do not vanish // A Steam ID or a PlayStation link. Starts true until /games-count answers, so the sync controls do not vanish
@@ -154,12 +158,17 @@ export default function GameLibrary() {
const query = libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly, source: sourceKey}); const query = libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly, source: sourceKey});
const cached = cachedFor(query); const cached = cachedFor(query);
const [data, setData] = useState(cached ?? {content: [], page: 0, size, totalElements: 0, totalPages: 0}); const [data, setData] = useState(cached ?? {content: [], page: 0, size, totalElements: 0, totalPages: 0});
const [isLoading, setIsLoading] = useState(!cached); const [isLoading, setIsLoading] = useState(!cached && !removedOnly);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const nextSignal = useAbortPrevious(); const nextSignal = useAbortPrevious();
const load = useCallback(async () => { const load = useCallback(async () => {
const signal = nextSignal(); const signal = nextSignal();
if (removedOnly) {
setIsLoading(false);
setError(null);
return;
}
setIsLoading(!cachedFor(query)); setIsLoading(!cachedFor(query));
setError(null); setError(null);
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${query}`; const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${query}`;
@@ -173,7 +182,7 @@ export default function GameLibrary() {
} }
lastPage = {query, data: json, stamp: sessionStamp()}; lastPage = {query, data: json, stamp: sessionStamp()};
setData(json); setData(json);
}, [nextSignal, query]); }, [nextSignal, query, removedOnly]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -233,16 +242,20 @@ export default function GameLibrary() {
}, [search, q, updateParams]); }, [search, q, updateParams]);
const onStatusChange = (value) => { const onStatusChange = (value) => {
updateParams({status: value, hidden: null, page: 0}); updateParams({status: value, hidden: null, removed: null, page: 0});
}; };
const toggleSource = (source) => { const toggleSource = (source) => {
const next = GAME_SOURCES.filter((s) => (s === source ? !sources.includes(s) : sources.includes(s))); const next = GAME_SOURCES.filter((s) => (s === source ? !sources.includes(s) : sources.includes(s)));
updateParams({source: next.join(','), hidden: null, page: 0}); updateParams({source: next.join(','), hidden: null, removed: null, page: 0});
}; };
const onHiddenChange = () => { const onHiddenChange = () => {
updateParams(hiddenOnly ? {hidden: null, page: 0} : {hidden: true, status: null, maxMinutes: null, source: null, page: 0}); updateParams(hiddenOnly ? {hidden: null, page: 0} : {hidden: true, removed: null, status: null, maxMinutes: null, source: null, page: 0});
};
const onRemovedChange = () => {
updateParams(removedOnly ? {removed: null, page: 0} : {removed: true, hidden: null, status: null, maxMinutes: null, source: null, page: 0});
}; };
const onSizeChange = (value) => { const onSizeChange = (value) => {
@@ -268,6 +281,7 @@ export default function GameLibrary() {
const syncing = syncBusy || sync.isRunning; const syncing = syncBusy || sync.isRunning;
const ready = !isLoading && !error; const ready = !isLoading && !error;
const listing = ready && !removedOnly;
// A selected source the user lacks stays visible so a stale URL value can be cleared. // A selected source the user lacks stays visible so a stale URL value can be cleared.
const sourceChips = GAME_SOURCES.filter((s) => ownSources.includes(s) || sources.includes(s)); const sourceChips = GAME_SOURCES.filter((s) => ownSources.includes(s) || sources.includes(s));
@@ -299,6 +313,9 @@ export default function GameLibrary() {
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>
<Link to="/import"><Upload/>{t('IMPORT.OPEN')}</Link> <Link to="/import"><Upload/>{t('IMPORT.OPEN')}</Link>
</Button> </Button>
<Button variant="outline" size="sm" asChild>
<Link to="/search"><Search/>{t('GAME_SEARCH.TITLE')}</Link>
</Button>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
@@ -335,8 +352,8 @@ export default function GameLibrary() {
<Button <Button
key={s ?? 'ALL'} key={s ?? 'ALL'}
size="sm" size="sm"
variant={listStatus === s && !hiddenOnly ? 'default' : 'outline'} variant={listStatus === s && !standalone ? 'default' : 'outline'}
aria-pressed={listStatus === s && !hiddenOnly} aria-pressed={listStatus === s && !standalone}
className="h-7 rounded-full px-3 text-xs pointer-coarse:h-11 pointer-coarse:px-4" className="h-7 rounded-full px-3 text-xs pointer-coarse:h-11 pointer-coarse:px-4"
onClick={() => onStatusChange(s)} onClick={() => onStatusChange(s)}
> >
@@ -348,7 +365,7 @@ export default function GameLibrary() {
variant={listShortOnly ? 'default' : 'outline'} variant={listShortOnly ? 'default' : 'outline'}
aria-pressed={listShortOnly} aria-pressed={listShortOnly}
className="h-7 rounded-full px-3 text-xs pointer-coarse:h-11 pointer-coarse:px-4" className="h-7 rounded-full px-3 text-xs pointer-coarse:h-11 pointer-coarse:px-4"
onClick={() => updateParams({maxMinutes: listShortOnly ? null : SHORT_GAME_MINUTES, hidden: null, page: 0})} onClick={() => updateParams({maxMinutes: listShortOnly ? null : SHORT_GAME_MINUTES, hidden: null, removed: null, page: 0})}
> >
<Timer className="size-3"/>{t('LIBRARY.FILTER_SHORT')} <Timer className="size-3"/>{t('LIBRARY.FILTER_SHORT')}
</Button> </Button>
@@ -361,6 +378,15 @@ export default function GameLibrary() {
> >
<EyeOff className="size-3"/>{t('LIBRARY.FILTER_HIDDEN')} <EyeOff className="size-3"/>{t('LIBRARY.FILTER_HIDDEN')}
</Button> </Button>
<Button
size="sm"
variant={removedOnly ? 'default' : 'outline'}
aria-pressed={removedOnly}
className="h-7 rounded-full px-3 text-xs pointer-coarse:h-11 pointer-coarse:px-4"
onClick={onRemovedChange}
>
<Trash2 className="size-3"/>{t('LIBRARY.FILTER_REMOVED')}
</Button>
</div> </div>
{sourceChips.length >= 2 && ( {sourceChips.length >= 2 && (
<div role="group" aria-label={t('LIBRARY.SOURCE_LABEL')} className="flex flex-wrap gap-1.5"> <div role="group" aria-label={t('LIBRARY.SOURCE_LABEL')} className="flex flex-wrap gap-1.5">
@@ -407,7 +433,9 @@ export default function GameLibrary() {
{error && <div className="py-12 text-center text-destructive">{t(error)}</div>} {error && <div className="py-12 text-center text-destructive">{t(error)}</div>}
{ready && data.content.length === 0 && ( {ready && removedOnly && <RemovedGamesList/>}
{listing && data.content.length === 0 && (
<Card className="items-center py-16 text-center text-muted-foreground"> <Card className="items-center py-16 text-center text-muted-foreground">
{filtered ? <SearchX className="size-10"/> : <Library className="size-10"/>} {filtered ? <SearchX className="size-10"/> : <Library className="size-10"/>}
<p className="max-w-sm text-sm"> <p className="max-w-sm text-sm">
@@ -415,10 +443,15 @@ export default function GameLibrary() {
: sync.isRunning || sync.queued ? t('LIBRARY.SYNCING_FIRST_TIME') : sync.isRunning || sync.queued ? t('LIBRARY.SYNCING_FIRST_TIME')
: privateProfile ? <HintText text={t('LIBRARY.EMPTY_PRIVATE')}/> : t('LIBRARY.EMPTY')} : privateProfile ? <HintText text={t('LIBRARY.EMPTY_PRIVATE')}/> : t('LIBRARY.EMPTY')}
</p> </p>
{!hiddenOnly && q.trim() && (
<Link to={`/search?q=${encodeURIComponent(q.trim())}`} className="text-sm font-medium text-primary hover:underline">
{t('LIBRARY.FIND_GAMES_INSTEAD')}
</Link>
)}
</Card> </Card>
)} )}
{ready && data.content.length > 0 && ( {listing && data.content.length > 0 && (
<Card className="gap-0 py-0"> <Card className="gap-0 py-0">
<ul className="divide-y"> <ul className="divide-y">
{data.content.map((g) => ( {data.content.map((g) => (
@@ -428,7 +461,7 @@ export default function GameLibrary() {
</Card> </Card>
)} )}
{ready && data.totalPages > 1 && ( {listing && data.totalPages > 1 && (
<Pagination page={page} totalPages={data.totalPages} onChange={goToPage} <Pagination page={page} totalPages={data.totalPages} onChange={goToPage}
summary={`${t('LIBRARY.PAGE_OF', {current: data.page + 1, total: data.totalPages})} · ${data.totalElements} ${t('LIBRARY.GAMES')}`}/> summary={`${t('LIBRARY.PAGE_OF', {current: data.page + 1, total: data.totalPages})} · ${data.totalElements} ${t('LIBRARY.GAMES')}`}/>
)} )}
+60
View File
@@ -633,3 +633,63 @@ describe('GameLibrary session', () => {
expect(screen.queryByText('LIBRARY.ERROR_LOAD')).toBeNull(); expect(screen.queryByText('LIBRARY.ERROR_LOAD')).toBeNull();
}); });
}); });
describe('GameLibrary removed filter and search links', () => {
const gamesCalls = () => fetch.mock.calls.filter(([url]) => String(url).includes('/api/library/games?'));
const withRemoved = (results = [{appid: -1, name: 'Zelda', imageUrl: null, source: 'MANUAL', platform: 'Switch'}]) => fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/library/games': json(200, page),
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true, canSync: true, sources: []}),
'GET /api/library/removed-games': json(200, {results}),
});
it('theRemovedChip_ShowsTheListAndAsksForNoGames', async () => {
withRemoved();
const lib = renderLibrary('/library?status=PLAYING');
await screen.findByText('Half-Life 2');
const callsBefore = gamesCalls().length;
fireEvent.click(screen.getByRole('button', {name: 'LIBRARY.FILTER_REMOVED'}));
expect(await screen.findByText('Zelda')).toBeTruthy();
expect(lib.params().get('removed')).toBe('true');
expect(lib.params().get('status')).toBeNull();
expect(screen.queryByText('Half-Life 2')).toBeNull();
expect(gamesCalls()).toHaveLength(callsBefore);
});
it('aRemovedLink_MakesNoGamesCall', async () => {
withRemoved();
renderLibrary('/library?removed=true&status=BACKLOG');
expect(await screen.findByText('Zelda')).toBeTruthy();
expect(gamesCalls()).toHaveLength(0);
});
it('aStatusClick_LeavesTheRemovedList', async () => {
withRemoved();
const lib = renderLibrary('/library?removed=true');
await screen.findByText('Zelda');
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.STATUS.PLAYING'}));
await waitFor(() => expect(lib.params().get('removed')).toBeNull());
expect(await screen.findByText('Half-Life 2')).toBeTruthy();
});
it('theToolbarLinksToFindGames', async () => {
renderLibrary();
await screen.findByText('Half-Life 2');
expect(screen.getByRole('link', {name: 'GAME_SEARCH.TITLE'}).getAttribute('href')).toBe('/search');
});
it('noMatch_OffersTheSearchOfFindGamesWithTheQuery', async () => {
answer([]);
renderLibrary('/library?q=zelda%20x');
const link = await screen.findByRole('link', {name: 'LIBRARY.FIND_GAMES_INSTEAD'});
expect(link.getAttribute('href')).toBe('/search?q=zelda%20x');
});
});
+34 -6
View File
@@ -1,24 +1,30 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {Link} from 'react-router-dom'; import {Link} from 'react-router-dom';
import {ChevronDown, Loader2} from 'lucide-react'; import {ChevronDown, CircleQuestionMark, Loader2} from 'lucide-react';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import NewsEntry from '../components/NewsEntry.jsx';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import {formatDate} from '../utils/format.js'; import {formatDate} from '../utils/format.js';
import {profileGameHref, profileHref} from '../utils/people.js'; import {profileGameHref, profileHref} from '../utils/people.js';
import {digestParts, fetchInbox, fetchInboxDetails, groupDetails, markInboxRead} from '../utils/inbox.js'; import {digestParts, fetchInbox, fetchInboxDetails, groupDetails, markInboxRead} from '../utils/inbox.js';
import {fetchUnseenNews, markNewsSeen} from '../utils/news.js';
import {fetchQuestionCount} from '../utils/questions.js';
const PSN_KINDS = ['PSN_EXPIRING', 'PSN_EXPIRED']; const PSN_KINDS = ['PSN_EXPIRING', 'PSN_EXPIRED'];
const ACHIEVEMENTS_SHOWN = 5; const ACHIEVEMENTS_SHOWN = 5;
/** /**
* The newest notes about the people the user follows and about the PlayStation link. Opening the page marks them read. * The one place for what is new: the open questions, the news the user has not seen, and the newest notes about the
* people the user follows and about the PlayStation link. Opening the page marks the notes read and the news seen.
*/ */
export default function InboxPage() { export default function InboxPage() {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
const [notes, setNotes] = useState([]); const [notes, setNotes] = useState([]);
const [news, setNews] = useState([]);
const [questions, setQuestions] = useState(0);
const [status, setStatus] = useState('loading'); const [status, setStatus] = useState('loading');
const [open, setOpen] = useState(null); const [open, setOpen] = useState(null);
// note id -> {status: 'loading' | 'error' | 'ready', results} // note id -> {status: 'loading' | 'error' | 'ready', results}
@@ -27,16 +33,19 @@ export default function InboxPage() {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const {ok, status: code, data} = await fetchInbox(); const [{ok, status: code, data}, unseen, count] = await Promise.all([fetchInbox(), fetchUnseenNews(), fetchQuestionCount()]);
if (code === 401) return; if (code === 401) return;
if (!ok) { if (!ok) {
setStatus('error'); setStatus('error');
return; return;
} }
setNotes(data.results); setNotes(data.results);
setNews(unseen);
setQuestions(count.ok ? count.data.count : 0);
setStatus('ready'); setStatus('ready');
// A failed mark is silent: the notes show as unread again on the next visit. // A failed mark is silent: it shows as new again on the next visit.
if (data.unread > 0) markInboxRead(); if (data.unread > 0) markInboxRead();
if (unseen.length > 0) markNewsSeen().catch(() => undefined);
})(); })();
}, []); }, []);
@@ -136,9 +145,27 @@ export default function InboxPage() {
<CardTitle>{t('INBOX.TITLE')}</CardTitle> <CardTitle>{t('INBOX.TITLE')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="divide-y">
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>} {status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
{status === 'error' && <p className="text-sm text-destructive">{t('INBOX.LOAD_ERROR')}</p>} {status === 'error' && <p className="text-sm text-destructive">{t('INBOX.LOAD_ERROR')}</p>}
{status === 'ready' && notes.length === 0 && <p className="text-sm text-muted-foreground">{t('INBOX.EMPTY')}</p>} {status === 'ready' && notes.length === 0 && news.length === 0 && questions === 0 && (
<p className="text-sm text-muted-foreground">{t('INBOX.EMPTY')}</p>
)}
{status === 'ready' && questions > 0 && (
<p className="flex items-center gap-2 py-3 text-sm">
<CircleQuestionMark className="size-4 shrink-0 text-primary"/>
<Link to="/questions" className="font-medium hover:underline">{t('INBOX.QUESTIONS_OPEN', {count: questions})}</Link>
</p>
)}
{status === 'ready' && news.length > 0 && (
<section className="space-y-3 py-3">
<div className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold">{t('NEWS.TITLE')}</h2>
<Link to="/whats-new" className="text-xs underline">{t('INBOX.ALL_NEWS')}</Link>
</div>
{news.map((entry) => <NewsEntry key={entry.id} entry={entry}/>)}
</section>
)}
{status === 'ready' && notes.length > 0 && ( {status === 'ready' && notes.length > 0 && (
<ul className="divide-y"> <ul className="divide-y">
{notes.map((note) => ( {notes.map((note) => (
@@ -149,7 +176,7 @@ export default function InboxPage() {
<p className={cn('break-words text-sm', !note.read && 'font-semibold')}> <p className={cn('break-words text-sm', !note.read && 'font-semibold')}>
{noteLine(note)} {noteLine(note)}
{PSN_KINDS.includes(note.kind) && ( {PSN_KINDS.includes(note.kind) && (
<> <Link to="/settings" className="underline">{t('INBOX.OPEN_SETTINGS')}</Link></> <> <Link to="/settings?tab=connections" className="underline">{t('INBOX.OPEN_SETTINGS')}</Link></>
)} )}
</p> </p>
{open === note.id && <div className="space-y-2 pt-1">{detailBlock(note)}</div>} {open === note.id && <div className="space-y-2 pt-1">{detailBlock(note)}</div>}
@@ -157,6 +184,7 @@ export default function InboxPage() {
))} ))}
</ul> </ul>
)} )}
</div>
</CardContent> </CardContent>
</Card> </Card>
</PageContainer> </PageContainer>
+61 -14
View File
@@ -9,6 +9,7 @@ vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const note = (id, extra) => ({id, kind: 'DIGEST', displayName: 'Lobo', newGames: 0, achievements: 0, statusChanges: 0, const note = (id, extra) => ({id, kind: 'DIGEST', displayName: 'Lobo', newGames: 0, achievements: 0, statusChanges: 0,
wishlistAdds: 0, createdAt: '2026-10-06T10:00:00Z', read: false, ...extra}); wishlistAdds: 0, createdAt: '2026-10-06T10:00:00Z', read: false, ...extra});
const quiet = {'GET /api/news': json(200, {results: []}), 'GET /api/library/questions/count': json(200, {count: 0})};
const putCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT'); const putCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT');
afterEach(() => { afterEach(() => {
@@ -18,7 +19,7 @@ afterEach(() => {
describe('InboxPage', () => { describe('InboxPage', () => {
it('showsADigestWithOnlyTheNonZeroPartsAndMarksTheNotesRead', async () => { it('showsADigestWithOnlyTheNonZeroPartsAndMarksTheNotesRead', async () => {
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 12, wishlistAdds: 2})], unread: 1}), fetchRoutes({...quiet, 'GET /api/inbox': json(200, {results: [note(1, {newGames: 12, wishlistAdds: 2})], unread: 1}),
'PUT /api/inbox/read': json(204)}); 'PUT /api/inbox/read': json(204)});
renderWithProviders(<InboxPage/>); renderWithProviders(<InboxPage/>);
@@ -27,7 +28,7 @@ describe('InboxPage', () => {
}); });
it('showsAVisibilityNote', async () => { it('showsAVisibilityNote', async () => {
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {kind: 'VISIBILITY'})], unread: 1}), fetchRoutes({...quiet, 'GET /api/inbox': json(200, {results: [note(1, {kind: 'VISIBILITY'})], unread: 1}),
'PUT /api/inbox/read': json(204)}); 'PUT /api/inbox/read': json(204)});
renderWithProviders(<InboxPage/>); renderWithProviders(<InboxPage/>);
@@ -35,17 +36,17 @@ describe('InboxPage', () => {
}); });
it.each(['PSN_EXPIRING', 'PSN_EXPIRED'])('shows the %s note with a link to the settings', async (kind) => { it.each(['PSN_EXPIRING', 'PSN_EXPIRED'])('shows the %s note with a link to the settings', async (kind) => {
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {kind, displayName: null})], unread: 1}), fetchRoutes({...quiet, 'GET /api/inbox': json(200, {results: [note(1, {kind, displayName: null})], unread: 1}),
'PUT /api/inbox/read': json(204)}); 'PUT /api/inbox/read': json(204)});
renderWithProviders(<InboxPage/>); renderWithProviders(<InboxPage/>);
expect(await screen.findByText(`INBOX.${kind}`)).toBeTruthy(); expect(await screen.findByText(`INBOX.${kind}`)).toBeTruthy();
expect(screen.getByRole('link', {name: 'INBOX.OPEN_SETTINGS'}).getAttribute('href')).toBe('/settings'); expect(screen.getByRole('link', {name: 'INBOX.OPEN_SETTINGS'}).getAttribute('href')).toBe('/settings?tab=connections');
expect(screen.queryByText('INBOX.DIGEST')).toBeNull(); expect(screen.queryByText('INBOX.DIGEST')).toBeNull();
}); });
it('gives a digest or a visibility note no settings link', async () => { it('gives a digest or a visibility note no settings link', async () => {
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 2}), note(2, {kind: 'VISIBILITY'})], unread: 0})}); fetchRoutes({...quiet, 'GET /api/inbox': json(200, {results: [note(1, {newGames: 2}), note(2, {kind: 'VISIBILITY'})], unread: 0})});
renderWithProviders(<InboxPage/>); renderWithProviders(<InboxPage/>);
expect(await screen.findByText('INBOX.VISIBILITY')).toBeTruthy(); expect(await screen.findByText('INBOX.VISIBILITY')).toBeTruthy();
@@ -53,7 +54,7 @@ describe('InboxPage', () => {
}); });
it('saysSoWhenThereAreNoNotesAndMarksNothing', async () => { it('saysSoWhenThereAreNoNotesAndMarksNothing', async () => {
fetchRoutes({'GET /api/inbox': json(200, {results: [], unread: 0})}); fetchRoutes({...quiet, 'GET /api/inbox': json(200, {results: [], unread: 0})});
renderWithProviders(<InboxPage/>); renderWithProviders(<InboxPage/>);
expect(await screen.findByText('INBOX.EMPTY')).toBeTruthy(); expect(await screen.findByText('INBOX.EMPTY')).toBeTruthy();
@@ -61,7 +62,7 @@ describe('InboxPage', () => {
}); });
it('showsAnErrorAndMarksNothingWhenTheLoadFails', async () => { it('showsAnErrorAndMarksNothingWhenTheLoadFails', async () => {
fetchRoutes({'GET /api/inbox': json(500, {})}); fetchRoutes({...quiet, 'GET /api/inbox': json(500, {})});
renderWithProviders(<InboxPage/>); renderWithProviders(<InboxPage/>);
expect(await screen.findByText('INBOX.LOAD_ERROR')).toBeTruthy(); expect(await screen.findByText('INBOX.LOAD_ERROR')).toBeTruthy();
@@ -70,7 +71,7 @@ describe('InboxPage', () => {
describe('links', () => { describe('links', () => {
it('linksTheNameToTheProfileOnlyWhileTheProfileIsPublic', async () => { it('linksTheNameToTheProfileOnlyWhileTheProfileIsPublic', async () => {
fetchRoutes({'GET /api/inbox': json(200, {results: [ fetchRoutes({...quiet, 'GET /api/inbox': json(200, {results: [
note(1, {newGames: 1, profileName: 'Subject', displayName: 'Subject'}), note(1, {newGames: 1, profileName: 'Subject', displayName: 'Subject'}),
note(2, {newGames: 2, displayName: 'Gone', profileName: null})], unread: 0})}); note(2, {newGames: 2, displayName: 'Gone', profileName: null})], unread: 0})});
renderWithProviders(<InboxPage/>); renderWithProviders(<InboxPage/>);
@@ -81,7 +82,7 @@ describe('InboxPage', () => {
}); });
it('keepsTheNameLinkOutsideTheButtonThatOpensTheDetails', async () => { it('keepsTheNameLinkOutsideTheButtonThatOpensTheDetails', async () => {
fetchRoutes({'GET /api/inbox': json(200, {results: [ fetchRoutes({...quiet, 'GET /api/inbox': json(200, {results: [
note(1, {newGames: 1, details: true, profileName: 'Subject', displayName: 'Subject'})], unread: 0})}); note(1, {newGames: 1, details: true, profileName: 'Subject', displayName: 'Subject'})], unread: 0})});
renderWithProviders(<InboxPage/>); renderWithProviders(<InboxPage/>);
@@ -92,7 +93,7 @@ describe('InboxPage', () => {
}); });
const open = async (rows, extra) => { const open = async (rows, extra) => {
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: true, ...extra})], unread: 0}), fetchRoutes({...quiet, 'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: true, ...extra})], unread: 0}),
'GET /api/inbox/1/details': json(200, {results: rows})}); 'GET /api/inbox/1/details': json(200, {results: rows})});
renderWithProviders(<InboxPage/>); renderWithProviders(<InboxPage/>);
fireEvent.click(await screen.findByRole('button')); fireEvent.click(await screen.findByRole('button'));
@@ -126,7 +127,7 @@ describe('InboxPage', () => {
const row = (kind, gameName, extra) => ({kind, gameName, itemName: null, fromStatus: null, toStatus: null, ...extra}); const row = (kind, gameName, extra) => ({kind, gameName, itemName: null, fromStatus: null, toStatus: null, ...extra});
const detailCalls = () => fetch.mock.calls.filter(([url]) => String(url).includes('/details')); const detailCalls = () => fetch.mock.calls.filter(([url]) => String(url).includes('/details'));
const setup = (results, extra) => { const setup = (results, extra) => {
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: true, ...extra})], unread: 0}), fetchRoutes({...quiet, 'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: true, ...extra})], unread: 0}),
'GET /api/inbox/1/details': json(200, {results})}); 'GET /api/inbox/1/details': json(200, {results})});
renderWithProviders(<InboxPage/>); renderWithProviders(<InboxPage/>);
}; };
@@ -152,7 +153,7 @@ describe('InboxPage', () => {
}); });
it('givesANoteWithoutDetailsNoButtonAndNoRequest', async () => { it('givesANoteWithoutDetailsNoButtonAndNoRequest', async () => {
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: false})], unread: 0})}); fetchRoutes({...quiet, 'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: false})], unread: 0})});
renderWithProviders(<InboxPage/>); renderWithProviders(<InboxPage/>);
expect(await screen.findByText('INBOX.DIGEST')).toBeTruthy(); expect(await screen.findByText('INBOX.DIGEST')).toBeTruthy();
@@ -169,7 +170,7 @@ describe('InboxPage', () => {
}); });
it('saysSoWhenTheNoteIsGone', async () => { it('saysSoWhenTheNoteIsGone', async () => {
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: true})], unread: 0}), fetchRoutes({...quiet, 'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: true})], unread: 0}),
'GET /api/inbox/1/details': json(404, {})}); 'GET /api/inbox/1/details': json(404, {})});
renderWithProviders(<InboxPage/>); renderWithProviders(<InboxPage/>);
@@ -179,7 +180,7 @@ describe('InboxPage', () => {
}); });
it('showsAnErrorWhenTheDetailsFail', async () => { it('showsAnErrorWhenTheDetailsFail', async () => {
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: true})], unread: 0}), fetchRoutes({...quiet, 'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: true})], unread: 0}),
'GET /api/inbox/1/details': json(500, {})}); 'GET /api/inbox/1/details': json(500, {})});
renderWithProviders(<InboxPage/>); renderWithProviders(<InboxPage/>);
@@ -199,4 +200,50 @@ describe('InboxPage', () => {
expect(screen.getByText('ach5')).toBeTruthy(); expect(screen.getByText('ach5')).toBeTruthy();
}); });
}); });
describe('the one place for what is new', () => {
const entry = (id) => ({id, titleEn: `Title ${id}`, titleDe: `Titel ${id}`, textEn: 'text', textDe: 'Text',
publishedAt: '2026-10-01T10:00:00Z', seen: false});
it('showsTheOpenQuestionsAsALinkToTheQuestions', async () => {
fetchRoutes({...quiet, 'GET /api/inbox': json(200, {results: [], unread: 0}),
'GET /api/library/questions/count': json(200, {count: 3})});
renderWithProviders(<InboxPage/>);
const link = await screen.findByRole('link', {name: 'INBOX.QUESTIONS_OPEN:3'});
expect(link.getAttribute('href')).toBe('/questions');
expect(screen.queryByText('INBOX.EMPTY')).toBeNull();
});
it('showsUnseenNewsAndMarksThemSeenOnce', async () => {
fetchRoutes({...quiet, 'GET /api/inbox': json(200, {results: [], unread: 0}),
'GET /api/news': json(200, {results: [entry(1), {...entry(2), seen: true}]}),
'PUT /api/news/seen': json(204)});
renderWithProviders(<InboxPage/>);
expect(await screen.findByText('Titel 1')).toBeTruthy();
expect(screen.queryByText('Titel 2')).toBeNull();
expect(screen.getByRole('link', {name: 'INBOX.ALL_NEWS'}).getAttribute('href')).toBe('/whats-new');
await waitFor(() => expect(putCalls().filter(([url]) => url.includes('/api/news/seen'))).toHaveLength(1));
});
it('withNothingUnseen_CallsNoPut', async () => {
fetchRoutes({...quiet, 'GET /api/inbox': json(200, {results: [], unread: 0}),
'GET /api/news': json(200, {results: [{...entry(2), seen: true}]})});
renderWithProviders(<InboxPage/>);
await screen.findByText('INBOX.EMPTY');
expect(putCalls()).toEqual([]);
});
it('aFailingNewsOrQuestionCall_DoesNotHideTheNotes', async () => {
fetchRoutes({'GET /api/news': json(500, {}), 'GET /api/library/questions/count': json(500, {}),
'GET /api/inbox': json(200, {results: [note(1, {kind: 'VISIBILITY'})], unread: 0})});
renderWithProviders(<InboxPage/>);
expect(await screen.findByText('INBOX.VISIBILITY')).toBeTruthy();
});
});
}); });
+4 -145
View File
@@ -1,123 +1,18 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {Link} from 'react-router-dom'; import {Link} from 'react-router-dom';
import { import {ArrowRight} from 'lucide-react';
Archive, ArrowRight, Blocks, Clock, Gamepad2, Library, Link2, Sparkles, Trophy, Users,
} from 'lucide-react';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import CommunityStats from '../components/CommunityStats.jsx';
import {Skeleton} from '@/components/ui/skeleton';
import GameCover from '../components/GameCover.jsx';
import FitText from '../components/FitText.jsx';
import Logo from '../components/Logo.jsx'; import Logo from '../components/Logo.jsx';
import StatCard from '../components/StatCard.jsx';
import PeopleDialog from '../components/people/PeopleDialog.jsx';
import {YearBars} from '../components/stats/FinishedStats.jsx';
import {TagBars} from '../components/stats/TasteStats.jsx';
import {StatDrillProvider, useStatDrill, useTagDrill} from '../components/stats/StatDrill.jsx';
import useSession from '../hooks/useSession.js'; import useSession from '../hooks/useSession.js';
import {fetchUsers} from '../utils/people.js';
import {genreKey} from '../utils/stats.js';
import usePublicStats from '../hooks/usePublicStats.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>
);
}
export default function LandingPage() { export default function LandingPage() {
const {t, i18n} = useTranslation(); const {t} = useTranslation();
const {data, isLoading} = usePublicStats();
const {active} = useSession(); 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 ( return (
<StatDrillProvider source="community">
<div className="grow"> <div className="grow">
<section className="relative overflow-hidden border-b"> <section className="relative overflow-hidden border-b">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-primary/15 via-primary/5 to-transparent"/> <div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-primary/15 via-primary/5 to-transparent"/>
@@ -140,44 +35,8 @@ export default function LandingPage() {
<section className="mx-auto w-full max-w-5xl space-y-8 px-4 py-12 md:px-8 md:py-16"> <section className="mx-auto w-full max-w-5xl space-y-8 px-4 py-12 md:px-8 md:py-16">
<h2 className="text-center text-2xl font-bold tracking-tight">{t('LANDING.STATS_TITLE')}</h2> <h2 className="text-center text-2xl font-bold tracking-tight">{t('LANDING.STATS_TITLE')}</h2>
<CommunityStats/>
<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}/>
</section> </section>
</div> </div>
{people && (
<PeopleDialog title={people.title} onClose={() => setPeople(null)}
load={(page) => fetchUsers({key: people.key, page})}/>
)}
</StatDrillProvider>
); );
} }
+10 -3
View File
@@ -19,6 +19,7 @@ import {formatDate, formatDay} from '../utils/format.js';
import {buyItem, equipItem, LOOKS, SLOTS} from '../utils/rewards.js'; import {buyItem, equipItem, LOOKS, SLOTS} from '../utils/rewards.js';
const GAME_KINDS = ['STARTED', 'FINISHED', 'FINISHED_MANUAL', 'FINISHED_EARLY', 'FINISHED_TOPUP', 'COMPLETED']; const GAME_KINDS = ['STARTED', 'FINISHED', 'FINISHED_MANUAL', 'FINISHED_EARLY', 'FINISHED_TOPUP', 'COMPLETED'];
const SUMMARY = ['START', 'FINISH', 'COMPLETED'];
const HOW_TO = ['STARTED', 'FINISHED', 'PROOF', 'COMPLETED', 'FINISHED_MANUAL', 'ONCE', 'PLAYSTATION', 'HIDDEN', 'CHECKED', 'MONTH', const HOW_TO = ['STARTED', 'FINISHED', 'PROOF', 'COMPLETED', 'FINISHED_MANUAL', 'ONCE', 'PLAYSTATION', 'HIDDEN', 'CHECKED', 'MONTH',
'STREAK', 'STARTER', 'UNLOCK']; 'STREAK', 'STARTER', 'UNLOCK'];
@@ -86,9 +87,8 @@ export default function PointsPage() {
</div> </div>
<Card className="gap-2 p-5"> <Card className="gap-2 p-5">
<h2 className="font-semibold">{t('POINTS.HOW_TO')}</h2> <ul className="space-y-1 text-sm">
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground"> {SUMMARY.map((kind) => <li key={kind}>{t(`POINTS.SUMMARY_${kind}`)}</li>)}
{HOW_TO.map((kind) => <li key={kind}>{t(`POINTS.RULES.${kind}`, {date: earlySince})}</li>)}
</ul> </ul>
</Card> </Card>
@@ -131,6 +131,13 @@ export default function PointsPage() {
))} ))}
</section> </section>
<details className="rounded-xl border p-5">
<summary className="cursor-pointer font-semibold">{t('POINTS.HOW_TO')}</summary>
<ul className="mt-3 list-disc space-y-1 pl-5 text-sm text-muted-foreground">
{HOW_TO.map((kind) => <li key={kind}>{t(`POINTS.RULES.${kind}`, {date: earlySince})}</li>)}
</ul>
</details>
<Card className="gap-3 p-5"> <Card className="gap-3 p-5">
<h2 className="text-xl font-semibold">{t('POINTS.HISTORY')}</h2> <h2 className="text-xl font-semibold">{t('POINTS.HISTORY')}</h2>
{wallet.history.length === 0 ? ( {wallet.history.length === 0 ? (
+17
View File
@@ -61,6 +61,23 @@ afterEach(() => {
}); });
describe('PointsPage', () => { describe('PointsPage', () => {
it('putsAThreeLineSummaryFirstAndTheRulesInAClosedDetails', async () => {
await renderPage();
const container = document.body;
const summary = ['START', 'FINISH', 'COMPLETED'].map((kind) => screen.getByText(`POINTS.SUMMARY_${kind}`));
const details = container.querySelector('details');
expect(details.open).toBe(false);
expect(details.querySelector('summary').textContent).toBe('POINTS.HOW_TO');
expect(details.querySelectorAll('li')).toHaveLength(13);
for (const line of summary) expect(details.contains(line)).toBe(false);
// The summary comes before the shop and the shop before the rules.
const order = (node) => [...container.querySelectorAll('*')].indexOf(node);
expect(order(summary[0])).toBeLessThan(order(screen.getByText('POINTS.SHOP')));
expect(order(screen.getByText('POINTS.SHOP'))).toBeLessThan(order(details));
});
it('lists all thirteen rules', async () => { it('lists all thirteen rules', async () => {
await renderPage(); await renderPage();
+16 -4
View File
@@ -1,8 +1,9 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {Link, useParams, useSearchParams} from 'react-router-dom'; import {Link, useParams, useSearchParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {CircleCheck, Clock, Gamepad2, LayoutGrid, Library, Percent, Star, Trophy, UserRoundX} from 'lucide-react'; import {CircleCheck, Clock, Copy, Gamepad2, Globe, LayoutGrid, Library, Percent, Star, Trophy, UserRoundX} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton'; import {Skeleton} from '@/components/ui/skeleton';
@@ -14,12 +15,14 @@ import ComingBadge from '../components/ComingBadge.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx'; import GameStatusBadge from '../components/GameStatusBadge.jsx';
import ProfileGameCard from '../components/ProfileGameCard.jsx'; import ProfileGameCard from '../components/ProfileGameCard.jsx';
import {formatDay} from '../utils/format.js'; import {formatDay} from '../utils/format.js';
import {profileHref} from '../utils/people.js';
import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, equippedSkin, lookClass} from '../utils/rewards.js'; import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, equippedSkin, lookClass} from '../utils/rewards.js';
import PointsBadge from '../components/PointsBadge.jsx'; import PointsBadge from '../components/PointsBadge.jsx';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import useSession from '../hooks/useSession.js'; import useSession from '../hooks/useSession.js';
import {normalizeProfileStyle} from '../utils/profileStyle.js'; import {normalizeProfileStyle} from '../utils/profileStyle.js';
import {DEFAULT_PROFILE, PROFILE_TILES, PROFILE_TITLES, normalizeLayout, saveTiles} from '../utils/tiles.js'; import {DEFAULT_PROFILE, PROFILE_TILES, PROFILE_TITLES, normalizeLayout, saveTiles} from '../utils/tiles.js';
import useCopyLink from '../hooks/useCopyLink.js';
import useCurrentUser from '../hooks/useCurrentUser.js'; import useCurrentUser from '../hooks/useCurrentUser.js';
import useSaveResponse from '../hooks/useSaveResponse.js'; import useSaveResponse from '../hooks/useSaveResponse.js';
import useStatCharts from '../hooks/useStatCharts.js'; import useStatCharts from '../hooks/useStatCharts.js';
@@ -41,6 +44,7 @@ const FINISHED_GAMES = {S: 6, M: 12};
*/ */
export default function PublicProfilePage() { export default function PublicProfilePage() {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
const copyLink = useCopyLink();
const {name} = useParams(); const {name} = useParams();
const [params] = useSearchParams(); const [params] = useSearchParams();
// Only a game key goes into the request address. // Only a game key goes into the request address.
@@ -110,12 +114,16 @@ export default function PublicProfilePage() {
}, [name, reloads]); }, [name, reloads]);
if (error) { if (error) {
// A private profile answers 404 like an unknown name; the owner learns why and where to change it.
const ownPrivate = error === 'PROFILE.NOT_FOUND' && viewer?.displayName?.toLowerCase() === name.toLowerCase();
return ( return (
<PageContainer className="p-4 md:p-8"> <PageContainer className="p-4 md:p-8">
<Card className="items-center py-16 text-center text-muted-foreground"> <Card className="items-center py-16 text-center text-muted-foreground">
<UserRoundX className="size-10"/> <UserRoundX className="size-10"/>
<p className="max-w-sm text-sm">{t(error)}</p> <p className="max-w-sm text-sm">{t(ownPrivate ? 'PROFILE.OWN_PRIVATE' : error)}</p>
<Link to="/" className="text-sm font-medium text-primary hover:underline">{t('PROFILE.TO_START')}</Link> {ownPrivate
? <Link to="/settings?tab=profile" className="text-sm font-medium text-primary hover:underline">{t('PROFILE.OPEN_SETTINGS')}</Link>
: <Link to="/" className="text-sm font-medium text-primary hover:underline">{t('PROFILE.TO_START')}</Link>}
</Card> </Card>
</PageContainer> </PageContainer>
); );
@@ -189,9 +197,13 @@ export default function PublicProfilePage() {
</div> </div>
{viewer && !owner && <FollowButton displayName={profile.displayName}/>} {viewer && !owner && <FollowButton displayName={profile.displayName}/>}
{owner && !editing && ( {owner && !editing && (
<Button variant="outline" size="sm" className="relative mt-4" onClick={() => setEditing(true)}> <div className="relative mt-4 flex flex-wrap items-center gap-2">
<Badge variant="secondary"><Globe/>{t('PROFILE.PUBLIC_CHIP')}</Badge>
<Button variant="outline" size="sm" onClick={() => copyLink(`${window.location.origin}${profileHref(profile.displayName)}`)}><Copy/>{t('PROFILE.COPY_LINK')}</Button>
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
<LayoutGrid/>{t('TILES.CUSTOMIZE')} <LayoutGrid/>{t('TILES.CUSTOMIZE')}
</Button> </Button>
</div>
)} )}
</Card> </Card>
{game && <ProfileGameCard ticket={profile.ticket} appid={game}/>} {game && <ProfileGameCard ticket={profile.ticket} appid={game}/>}
+52
View File
@@ -1,6 +1,7 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest'; import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, screen, waitFor, within} from '@testing-library/react'; import {cleanup, fireEvent, screen, waitFor, within} from '@testing-library/react';
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
import PublicProfilePage from './PublicProfilePage.jsx'; import PublicProfilePage from './PublicProfilePage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx'; import {renderWithProviders} from '../test/renderWithProviders.jsx';
@@ -594,4 +595,55 @@ describe('PublicProfilePage', () => {
delete library.averageCompletion; delete library.averageCompletion;
} }
}); });
describe('own profile', () => {
const me = {displayName: 'Lobo', role: 'USER'};
it('ownPrivateProfile_SaysSoAndLinksToTheProfileSettings', async () => {
localStorage.setItem('tbp_session', '1');
mockBackends({profileStatus: 404, me});
renderPage();
expect(await screen.findByText('PROFILE.OWN_PRIVATE')).toBeTruthy();
expect(screen.getByRole('link', {name: 'PROFILE.OPEN_SETTINGS'}).getAttribute('href')).toBe('/settings?tab=profile');
expect(screen.queryByRole('link', {name: 'PROFILE.TO_START'})).toBeNull();
localStorage.clear();
});
it('aPrivateProfileOfSomeoneElse_StaysNotFound', async () => {
localStorage.setItem('tbp_session', '1');
mockBackends({profileStatus: 404, me: {displayName: 'Anna', role: 'USER'}});
renderPage();
expect(await screen.findByText('PROFILE.NOT_FOUND')).toBeTruthy();
expect(screen.getByRole('link', {name: 'PROFILE.TO_START'})).toBeTruthy();
localStorage.clear();
});
it('ownPublicProfile_ShowsTheChipAndCopiesTheLink', async () => {
localStorage.setItem('tbp_session', '1');
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {value: {writeText}, configurable: true});
mockBackends({me});
renderPage();
expect(await screen.findByText('PROFILE.PUBLIC_CHIP')).toBeTruthy();
fireEvent.click(screen.getByRole('button', {name: 'PROFILE.COPY_LINK'}));
await waitFor(() => expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/u/Lobo`));
localStorage.clear();
});
it('someoneElsesProfile_HasNoChipAndNoCopyButton', async () => {
localStorage.setItem('tbp_session', '1');
mockBackends({me: {displayName: 'Anna', role: 'USER'}});
renderPage();
await screen.findByText('Portal 2');
expect(screen.queryByText('PROFILE.PUBLIC_CHIP')).toBeNull();
expect(screen.queryByRole('button', {name: 'PROFILE.COPY_LINK'})).toBeNull();
localStorage.clear();
});
});
}); });
+1 -2
View File
@@ -5,7 +5,6 @@ import {toast} from 'sonner';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import QuestionCard from '../components/QuestionCard.jsx'; import QuestionCard from '../components/QuestionCard.jsx';
import RemovedGamesSection from '../components/RemovedGamesSection.jsx';
import usePointsCheck from '../hooks/usePointsCheck.js'; import usePointsCheck from '../hooks/usePointsCheck.js';
import {answerQuestion, fetchQuestions} from '../utils/questions.js'; import {answerQuestion, fetchQuestions} from '../utils/questions.js';
import {loadExpected} from '../utils/rewards.js'; import {loadExpected} from '../utils/rewards.js';
@@ -76,6 +75,7 @@ export default function QuestionsPage() {
<h1 className="text-2xl font-bold tracking-tight"> <h1 className="text-2xl font-bold tracking-tight">
{status === 'ready' && items.length > 0 ? t('QUESTIONS.TITLE_COUNT', {count: items.length}) : t('QUESTIONS.TITLE')} {status === 'ready' && items.length > 0 ? t('QUESTIONS.TITLE_COUNT', {count: items.length}) : t('QUESTIONS.TITLE')}
</h1> </h1>
<p className="text-sm text-muted-foreground">{t('QUESTIONS.INTRO')}</p>
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>} {status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
{status === 'error' && <p className="text-sm text-destructive">{t('INFO.ERROR.NETWORK_FAILURE')}</p>} {status === 'error' && <p className="text-sm text-destructive">{t('INFO.ERROR.NETWORK_FAILURE')}</p>}
{status === 'ready' && items.length === 0 && ( {status === 'ready' && items.length === 0 && (
@@ -85,7 +85,6 @@ export default function QuestionsPage() {
<QuestionCard key={item.appid} item={item} busy={busyAppid !== null} expectedPoints={expected[item.appid]} <QuestionCard key={item.appid} item={item} busy={busyAppid !== null} expectedPoints={expected[item.appid]}
onAnswer={(choice) => answer(item, choice)}/> onAnswer={(choice) => answer(item, choice)}/>
))} ))}
<RemovedGamesSection/>
</PageContainer> </PageContainer>
); );
} }
+4 -6
View File
@@ -41,14 +41,12 @@ afterEach(() => {
}); });
describe('QuestionsPage', () => { describe('QuestionsPage', () => {
it('showsTheRemovedGamesSectionAlsoWithoutQuestions', async () => { it('showsTheIntroAndNoRemovedGamesSection', async () => {
renderPage({ renderPage({'GET /api/library/questions': json(200, {results: []})});
'GET /api/library/questions': json(200, {results: []}),
'GET /api/library/removed-games': json(200, {results: [{appid: -1, name: 'Zelda', imageUrl: null, source: 'MANUAL', platform: 'Switch'}]}),
});
expect(await screen.findByText('QUESTIONS.EMPTY')).toBeTruthy(); expect(await screen.findByText('QUESTIONS.EMPTY')).toBeTruthy();
expect(await screen.findByText('REMOVED_GAMES.TITLE_COUNT:1')).toBeTruthy(); expect(screen.getByText('QUESTIONS.INTRO')).toBeTruthy();
expect(fetch).not.toHaveBeenCalledWith(expect.stringContaining('removed-games'), expect.anything());
}); });
it('showsOneCardPerGameWithTheCountInTheTitle', async () => { it('showsOneCardPerGameWithTheCountInTheTitle', async () => {
+37 -12
View File
@@ -1,21 +1,33 @@
import {useEffect, useRef, useState} from 'react'; import {useEffect, useRef, useState} from 'react';
import {useNavigate, useSearchParams} from 'react-router-dom'; import {Link, useNavigate, useSearchParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {Cable, Info, Palette, ShieldCheck, UserRound} from 'lucide-react';
import {toast} from 'sonner'; import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Skeleton} from '@/components/ui/skeleton'; import {Skeleton} from '@/components/ui/skeleton';
import AccountInfoCard from '../components/AccountInfoCard.jsx'; import AccountInfoCard from '../components/AccountInfoCard.jsx';
import AppearanceCard from '../components/AppearanceCard.jsx'; import AppearanceCard from '../components/AppearanceCard.jsx';
import BuildVersionCard from '../components/BuildVersionCard.jsx'; import BuildVersionCard from '../components/BuildVersionCard.jsx';
import ChangePasswordCard from '../components/ChangePasswordCard.jsx'; import ChangePasswordCard from '../components/ChangePasswordCard.jsx';
import DeleteAccountCard from '../components/DeleteAccountCard.jsx'; import DeleteAccountCard from '../components/DeleteAccountCard.jsx';
import FollowsCard from '../components/FollowsCard.jsx';
import PreferencesCard from '../components/PreferencesCard.jsx'; import PreferencesCard from '../components/PreferencesCard.jsx';
import ProfileCard from '../components/ProfileCard.jsx'; import ProfileCard from '../components/ProfileCard.jsx';
import PsnAccountCard from '../components/PsnAccountCard.jsx'; import PsnAccountCard from '../components/PsnAccountCard.jsx';
import SteamAccountCard from '../components/SteamAccountCard.jsx'; import SteamAccountCard from '../components/SteamAccountCard.jsx';
import TabNav from '../components/TabNav.jsx';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import useSettingsLoad from '../hooks/useSettingsLoad.js'; import useSettingsLoad from '../hooks/useSettingsLoad.js';
import {searchTabs, tabOf} from '../utils/tabs.js';
const TAB_META = {
account: {labelKey: 'SETTINGS.TAB_ACCOUNT', icon: ShieldCheck},
appearance: {labelKey: 'SETTINGS.TAB_APPEARANCE', icon: Palette},
connections: {labelKey: 'SETTINGS.TAB_CONNECTIONS', icon: Cable},
profile: {labelKey: 'SETTINGS.TAB_PROFILE', icon: UserRound},
about: {labelKey: 'SETTINGS.TAB_ABOUT', icon: Info},
};
const TABS = Object.keys(TAB_META);
// The `steam` query parameter Auth adds when "Sign in through Steam" sends the user back. // The `steam` query parameter Auth adds when "Sign in through Steam" sends the user back.
const STEAM_LINK_TOASTS = { const STEAM_LINK_TOASTS = {
@@ -33,6 +45,8 @@ export default function SettingsPage() {
// Read once: removing the parameter below must not load the profile again. // Read once: removing the parameter below must not load the profile again.
const [steamOutcome] = useState(() => searchParams.get('steam')); const [steamOutcome] = useState(() => searchParams.get('steam'));
const {isLoading, loadError, displayName} = useSettingsLoad(steamOutcome); const {isLoading, loadError, displayName} = useSettingsLoad(steamOutcome);
// Back from Steam the user wants to see the link, whatever tab the old URL had.
const tab = tabOf(searchParams, TABS, steamOutcome ? 'connections' : 'account');
// A language change gives a new `t` and runs the effect again; the toast shows once. // A language change gives a new `t` and runs the effect again; the toast shows once.
const steamToastShown = useRef(false); const steamToastShown = useRef(false);
@@ -72,26 +86,37 @@ export default function SettingsPage() {
<div className="w-full max-w-2xl mx-auto p-4 md:p-8 space-y-6"> <div className="w-full max-w-2xl mx-auto p-4 md:p-8 space-y-6">
<h1 className="text-3xl font-bold tracking-tight">{t('SETTINGS.TITLE')}</h1> <h1 className="text-3xl font-bold tracking-tight">{t('SETTINGS.TITLE')}</h1>
<TabNav label={t('SETTINGS.TITLE')} tabs={searchTabs('/settings', TAB_META, tab)}/>
{tab === 'account' && (
<>
<AccountInfoCard/> <AccountInfoCard/>
<PreferencesCard/> <PreferencesCard/>
<ChangePasswordCard/>
<DeleteAccountCard email={profile?.email}/>
</>
)}
<AppearanceCard/> {tab === 'appearance' && <AppearanceCard/>}
{tab === 'connections' && (
<>
<SteamAccountCard steamId={profile?.steamId} verified={!!profile?.steamIdVerified} <SteamAccountCard steamId={profile?.steamId} verified={!!profile?.steamIdVerified}
onUnlinked={() => updateProfile((p) => ({...p, steamId: null, steamIdVerified: false}))}/> onUnlinked={() => updateProfile((p) => ({...p, steamId: null, steamIdVerified: false}))}/>
<PsnAccountCard/> <PsnAccountCard/>
</>
)}
{tab === 'profile' && (
<>
<ProfileCard displayName={displayName}/> <ProfileCard displayName={displayName}/>
<Button variant="link" asChild className="h-auto whitespace-normal p-0 text-left">
<Link to="/community">{t('SETTINGS.OPEN_COMMUNITY')}</Link>
</Button>
</>
)}
<FollowsCard/> {tab === 'about' && <BuildVersionCard/>}
<BuildVersionCard/>
<ChangePasswordCard/>
<DeleteAccountCard email={profile?.email}/>
</div> </div>
); );
} }
+2 -2
View File
@@ -24,7 +24,7 @@ const calls = (method) => fetch.mock.calls.filter(([, init]) => init?.method ===
const noBody = {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }}; const noBody = {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }};
function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings', unlinkAnswer = noBody} = {}) { function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings?tab=connections', unlinkAnswer = noBody} = {}) {
fetchRoutes({ fetchRoutes({
'GET /api/users/me': json(200, me), 'GET /api/users/me': json(200, me),
'POST /api/users/steam-link': json(200, {redirectUrl}), 'POST /api/users/steam-link': json(200, {redirectUrl}),
@@ -131,7 +131,7 @@ describe('SettingsPage Steam account', () => {
}); });
it('never sends a Steam ID with the settings', async () => { it('never sends a Steam ID with the settings', async () => {
renderPage(profile()); renderPage(profile(), {entry: '/settings?tab=appearance'});
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.DARK_THEME'})); fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.DARK_THEME'}));
+229 -22
View File
@@ -5,6 +5,7 @@ import SettingsPage from './SettingsPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx'; import {renderWithProviders} from '../test/renderWithProviders.jsx';
import {toast} from 'sonner'; import {toast} from 'sonner';
import {UnsavedChangesGuard} from '../hooks/useUnsavedChanges.js';
vi.mock('react-i18next', async () => { vi.mock('react-i18next', async () => {
const mock = await import('../test/i18n-mock.js'); const mock = await import('../test/i18n-mock.js');
@@ -37,9 +38,9 @@ const authRoutes = (me) => ({
'GET /api/library/psn-account': json(200, {enabled: false, linked: false}), 'GET /api/library/psn-account': json(200, {enabled: false, linked: false}),
}); });
function renderPage(me) { function renderPage(me, tab = 'account') {
fetchRoutes(authRoutes(() => me)); fetchRoutes(authRoutes(() => me));
renderWithProviders(<SettingsPage/>); renderWithProviders(<SettingsPage/>, {entries: [`/settings?tab=${tab}`]});
} }
/** /**
@@ -69,7 +70,7 @@ describe('SettingsPage public profile', () => {
}); });
it('disablesTheOwnerListSwitchWhileTheProfileIsPrivate', async () => { it('disablesTheOwnerListSwitchWhileTheProfileIsPrivate', async () => {
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: false, showInOwnerLists: true}})); renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: false, showInOwnerLists: true}}), 'profile');
const toggle = await screen.findByRole('switch', {name: 'SETTINGS.OWNER_LISTS'}); const toggle = await screen.findByRole('switch', {name: 'SETTINGS.OWNER_LISTS'});
@@ -78,7 +79,7 @@ describe('SettingsPage public profile', () => {
}); });
it('savesTheOwnerListSwitchThroughItsOwnEndpoint', async () => { it('savesTheOwnerListSwitchThroughItsOwnEndpoint', async () => {
renderPage(profile()); renderPage(profile(), 'profile');
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.OWNER_LISTS'})); fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.OWNER_LISTS'}));
@@ -90,7 +91,7 @@ describe('SettingsPage public profile', () => {
}); });
it('keeps the public profile when another setting is saved', async () => { it('keeps the public profile when another setting is saved', async () => {
renderPage(profile()); renderPage(profile(), 'appearance');
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.DARK_THEME'})); fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.DARK_THEME'}));
@@ -100,15 +101,16 @@ describe('SettingsPage public profile', () => {
it('builds a save on the one before it, also while that one is still open', async () => { it('builds a save on the one before it, also while that one is still open', async () => {
const auth = fakeAuth(profile()); const auth = fakeAuth(profile());
renderWithProviders(<SettingsPage/>); renderWithProviders(<SettingsPage/>, {entries: ['/settings?tab=profile']});
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'})); fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}));
await waitFor(() => expect(puts()).toHaveLength(1)); await waitFor(() => expect(puts()).toHaveLength(1));
fireEvent.click(screen.getByRole('switch', {name: 'SETTINGS.DARK_THEME'})); fireEvent.change(screen.getByLabelText('SETTINGS.DISPLAY_NAME'), {target: {value: 'Anna'}});
fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.SAVE_PROFILE'}));
await act(async () => auth.answer()); await act(async () => auth.answer());
await waitFor(() => expect(puts()).toHaveLength(2)); await waitFor(() => expect(puts()).toHaveLength(2));
expect(puts()[1]).toMatchObject({publicProfile: false, useDarkTheme: false, displayName: 'Lobo'}); expect(puts()[1]).toMatchObject({publicProfile: false, displayName: 'Anna'});
await act(async () => auth.answer()); await act(async () => auth.answer());
}); });
@@ -119,7 +121,7 @@ describe('SettingsPage public profile', () => {
}); });
it('turns the public profile off and shows the link while it is on', async () => { it('turns the public profile off and shows the link while it is on', async () => {
renderPage(profile()); renderPage(profile(), 'profile');
expect(await screen.findByText(/\/u\/Lobo$/)).toBeTruthy(); expect(await screen.findByText(/\/u\/Lobo$/)).toBeTruthy();
fireEvent.click(screen.getByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'})); fireEvent.click(screen.getByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}));
@@ -130,12 +132,12 @@ describe('SettingsPage public profile', () => {
}); });
const styled = (profileStyle) => profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStyle}}); const styled = (profileStyle) => profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStyle}});
const styleGroup = (label) => within(screen.getAllByRole('group', {name: label})[1]); const styleGroup = (label) => within(screen.getByRole('group', {name: label}));
const stylePuts = () => fetch.mock.calls.filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/profile-style')) const stylePuts = () => fetch.mock.calls.filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/profile-style'))
.map(([, init]) => JSON.parse(init.body)); .map(([, init]) => JSON.parse(init.body));
it('saves the profile accent apart from the app accent', async () => { it('saves the profile accent apart from the app accent', async () => {
renderPage(styled({accent: 'blue', corners: 'sharp', bannerAppid: 620, backgroundAppid: null})); renderPage(styled({accent: 'blue', corners: 'sharp', bannerAppid: 620, backgroundAppid: null}), 'profile');
await screen.findByText('SETTINGS.PROFILE_STYLE'); await screen.findByText('SETTINGS.PROFILE_STYLE');
fireEvent.click(styleGroup('SETTINGS.ACCENT').getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.RED'})); fireEvent.click(styleGroup('SETTINGS.ACCENT').getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.RED'}));
@@ -147,13 +149,13 @@ describe('SettingsPage public profile', () => {
}); });
it('points to the profile page for the tiles', async () => { it('points to the profile page for the tiles', async () => {
renderPage(profile()); renderPage(profile(), 'profile');
expect(await screen.findByText('SETTINGS.PROFILE_TILES_HINT')).toBeTruthy(); expect(await screen.findByText('SETTINGS.PROFILE_TILES_HINT')).toBeTruthy();
}); });
it('removes the profile banner', async () => { it('removes the profile banner', async () => {
renderPage(styled({bannerAppid: 620})); renderPage(styled({bannerAppid: 620}), 'profile');
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.PROFILE_ART_REMOVE'})); fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.PROFILE_ART_REMOVE'}));
@@ -162,7 +164,7 @@ describe('SettingsPage public profile', () => {
}); });
it('offers the profile look only while the profile is public', async () => { it('offers the profile look only while the profile is public', async () => {
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: false}})); renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: false}}), 'profile');
await screen.findByText('SETTINGS.PROFILE_STYLE'); await screen.findByText('SETTINGS.PROFILE_STYLE');
expect(screen.queryByText('SETTINGS.PROFILE_TILES_HINT')).toBeNull(); expect(screen.queryByText('SETTINGS.PROFILE_TILES_HINT')).toBeNull();
@@ -170,7 +172,7 @@ describe('SettingsPage public profile', () => {
}); });
it('needs a display name first', async () => { it('needs a display name first', async () => {
renderPage(profile({displayName: null, settings: {useDarkTheme: true, language: 'de', publicProfile: false}})); renderPage(profile({displayName: null, settings: {useDarkTheme: true, language: 'de', publicProfile: false}}), 'profile');
const toggle = await screen.findByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}); const toggle = await screen.findByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'});
expect(toggle.disabled).toBe(true); expect(toggle.disabled).toBe(true);
@@ -178,7 +180,7 @@ describe('SettingsPage public profile', () => {
}); });
it('turns the public profile off when the display name is removed', async () => { it('turns the public profile off when the display name is removed', async () => {
renderPage(profile()); renderPage(profile(), 'profile');
const name = await screen.findByLabelText('SETTINGS.DISPLAY_NAME'); const name = await screen.findByLabelText('SETTINGS.DISPLAY_NAME');
fireEvent.change(name, {target: {value: ''}}); fireEvent.change(name, {target: {value: ''}});
@@ -203,7 +205,7 @@ describe('SettingsPage appearance', () => {
.map(([, init]) => JSON.parse(init.body)); .map(([, init]) => JSON.parse(init.body));
it('saves a picked accent and corner style and marks them as picked', async () => { it('saves a picked accent and corner style and marks them as picked', async () => {
renderPage(profile()); renderPage(profile(), 'appearance');
await screen.findByText('SETTINGS.APPEARANCE'); await screen.findByText('SETTINGS.APPEARANCE');
fireEvent.click(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'})); fireEvent.click(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'}));
@@ -216,7 +218,7 @@ describe('SettingsPage appearance', () => {
}); });
it('saves the reduce animations switch', async () => { it('saves the reduce animations switch', async () => {
renderPage(profile()); renderPage(profile(), 'appearance');
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.REDUCE_MOTION'})); fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.REDUCE_MOTION'}));
@@ -226,7 +228,7 @@ describe('SettingsPage appearance', () => {
it('removes the background', async () => { it('removes the background', async () => {
localStorage.setItem('tbp_appearance', JSON.stringify({backgroundAppid: 620})); localStorage.setItem('tbp_appearance', JSON.stringify({backgroundAppid: 620}));
renderPage(profile()); renderPage(profile(), 'appearance');
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.BACKGROUND_REMOVE'})); fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.BACKGROUND_REMOVE'}));
@@ -237,7 +239,7 @@ describe('SettingsPage appearance', () => {
it('turns the banner off and goes back to the automatic banner', async () => { it('turns the banner off and goes back to the automatic banner', async () => {
localStorage.setItem('tbp_appearance', JSON.stringify({bannerAppid: 620})); localStorage.setItem('tbp_appearance', JSON.stringify({bannerAppid: 620}));
renderPage(profile()); renderPage(profile(), 'appearance');
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.BANNER_AUTO'})); fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.BANNER_AUTO'}));
await waitFor(() => expect(appearancePuts()).toHaveLength(1)); await waitFor(() => expect(appearancePuts()).toHaveLength(1));
@@ -321,13 +323,218 @@ describe('SettingsPage load order', () => {
...authRoutes(() => profile()), ...authRoutes(() => profile()),
'GET /api/users/me': () => new Promise((resolve) => open.push(() => resolve(json(200, profile())))), 'GET /api/users/me': () => new Promise((resolve) => open.push(() => resolve(json(200, profile())))),
}); });
renderWithProviders(<SettingsPage/>); renderWithProviders(<SettingsPage/>, {entries: ['/settings?tab=profile']});
await waitFor(() => expect(open).toHaveLength(2)); await waitFor(() => expect(open).toHaveLength(2));
await act(async () => open.shift()()); await act(async () => open.shift()());
await act(async () => open.shift()()); await act(async () => open.shift()());
expect(await screen.findByText('a@b.de')).toBeTruthy(); expect(await screen.findByLabelText('SETTINGS.DISPLAY_NAME')).toBeTruthy();
expect(screen.getByLabelText('SETTINGS.DISPLAY_NAME').value).toBe('Lobo'); expect(screen.getByLabelText('SETTINGS.DISPLAY_NAME').value).toBe('Lobo');
}); });
}); });
describe('SettingsPage tabs', () => {
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
});
const tab = (name) => screen.getByRole('link', {name: `SETTINGS.TAB_${name}`});
it('withoutATab_ShowsAccountAndSecurity', async () => {
fetchRoutes(authRoutes(() => profile()));
renderWithProviders(<SettingsPage/>, {entries: ['/settings']});
expect(await screen.findByText('SETTINGS.PREFERENCES')).toBeTruthy();
expect(tab('ACCOUNT').getAttribute('aria-current')).toBe('page');
expect(screen.queryByText('SETTINGS.APPEARANCE')).toBeNull();
expect(screen.queryByText('SETTINGS.PROFILE')).toBeNull();
});
it('anUnknownTab_ShowsAccountAndSecurity', async () => {
renderPage(profile(), 'nope');
expect(await screen.findByText('SETTINGS.PREFERENCES')).toBeTruthy();
});
it('aboutTab_ShowsTheVersionCardOnly', async () => {
renderPage(profile(), 'about');
expect(await screen.findByText('SETTINGS.VERSION')).toBeTruthy();
expect(screen.queryByText('SETTINGS.PREFERENCES')).toBeNull();
});
it('everyTabLinksToItsOwnTabParameter', async () => {
renderPage(profile());
await screen.findByText('SETTINGS.PREFERENCES');
expect(['ACCOUNT', 'APPEARANCE', 'CONNECTIONS', 'PROFILE', 'ABOUT'].map((name) => tab(name).getAttribute('href')))
.toEqual(['/settings?tab=account', '/settings?tab=appearance', '/settings?tab=connections', '/settings?tab=profile', '/settings?tab=about']);
});
it('theProfileTabLinksToTheCommunityPage', async () => {
renderPage(profile(), 'profile');
expect((await screen.findByRole('link', {name: 'SETTINGS.OPEN_COMMUNITY'})).getAttribute('href')).toBe('/community');
});
it('backFromSteamLandsOnConnections', async () => {
fetchRoutes(authRoutes(() => profile()));
renderWithProviders(<SettingsPage/>, {entries: ['/settings?steam=failed']});
expect(await screen.findByText('SETTINGS.STEAM')).toBeTruthy();
expect(tab('CONNECTIONS').getAttribute('aria-current')).toBe('page');
});
});
describe('SettingsPage save model', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
toast.success.mockClear();
toast.error.mockClear();
});
afterEach(() => {
cleanup();
localStorage.clear();
});
const failingRoutes = (route) => fetchRoutes({...authRoutes(() => profile()), [route]: json(500, {})});
it('aSwitchSavesWithoutASuccessToast', async () => {
renderPage(profile(), 'profile');
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.OWNER_LISTS'}));
await waitFor(() => expect(fetch.mock.calls.some(([url]) => url.endsWith('/settings/owner-lists'))).toBe(true));
await waitFor(() => expect(screen.getByRole('switch', {name: 'SETTINGS.OWNER_LISTS'}).getAttribute('aria-checked')).toBe('true'));
expect(toast.success).not.toHaveBeenCalled();
});
it('aFailedSwitchSavesGoesBackAndShowsTheErrorToast', async () => {
failingRoutes('PUT /api/users/settings/owner-lists');
renderWithProviders(<SettingsPage/>, {entries: ['/settings?tab=profile']});
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.OWNER_LISTS'}));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.ERROR_SAVE'));
expect(screen.getByRole('switch', {name: 'SETTINGS.OWNER_LISTS'}).getAttribute('aria-checked')).toBe('false');
});
it('aFailedPublicProfileSaveGoesBack', async () => {
failingRoutes('PUT /api/users/settings');
renderWithProviders(<SettingsPage/>, {entries: ['/settings?tab=profile']});
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}));
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(screen.getByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}).getAttribute('aria-checked')).toBe('true');
});
it('theDarkModeSwitchLivesInAppearanceAndSavesWithoutASuccessToast', async () => {
renderPage(profile(), 'appearance');
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.DARK_THEME'}));
await waitFor(() => expect(puts()).toHaveLength(1));
expect(puts()[0].useDarkTheme).toBe(false);
expect(toast.success).not.toHaveBeenCalled();
});
it('aFailedDarkModeSaveGoesBack', async () => {
failingRoutes('PUT /api/users/settings');
renderWithProviders(<SettingsPage/>, {entries: ['/settings?tab=appearance']});
const before = (await screen.findByRole('switch', {name: 'SETTINGS.DARK_THEME'})).getAttribute('aria-checked');
fireEvent.click(screen.getByRole('switch', {name: 'SETTINGS.DARK_THEME'}));
await waitFor(() => expect(toast.error).toHaveBeenCalled());
await waitFor(() => expect(screen.getByRole('switch', {name: 'SETTINGS.DARK_THEME'}).getAttribute('aria-checked')).toBe(before));
});
it('thePreferencesCardHasNoDarkModeSwitchAnyMore', async () => {
renderPage(profile());
await screen.findByText('SETTINGS.PREFERENCES');
expect(screen.queryByRole('switch', {name: 'SETTINGS.DARK_THEME'})).toBeNull();
});
it('aFailedFavoritePlatformSaveGoesBack', async () => {
Element.prototype.hasPointerCapture ??= () => false;
Element.prototype.scrollIntoView ??= () => {};
failingRoutes('PUT /api/users/settings/favorite-platform');
renderWithProviders(<SettingsPage/>, {entries: ['/settings?tab=account']});
const trigger = await screen.findByRole('combobox', {name: 'SETTINGS.FAVORITE_PLATFORM'});
fireEvent.pointerDown(trigger, {button: 0, ctrlKey: false, pointerType: 'mouse'});
fireEvent.click(await screen.findByRole('option', {name: 'Nintendo Switch 2'}));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.ERROR_SAVE'));
await waitFor(() => expect(trigger.textContent).toContain('SETTINGS.FAVORITE_PLATFORM_NONE'));
expect(toast.success).not.toHaveBeenCalled();
});
it('theNameFormKeepsItsSuccessToast', async () => {
renderPage(profile(), 'profile');
fireEvent.change(await screen.findByLabelText('SETTINGS.DISPLAY_NAME'), {target: {value: 'Anna'}});
fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.SAVE_PROFILE'}));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('SETTINGS.SAVED'));
});
});
describe('SettingsPage unsaved name', () => {
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
vi.restoreAllMocks();
});
const renderWithGuard = () => {
fetchRoutes(authRoutes(() => profile()));
return renderWithProviders(<><SettingsPage/><UnsavedChangesGuard/></>, {entries: ['/settings?tab=profile']});
};
it('aChangedNameAsksBeforeTheUserLeavesTheTab', async () => {
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
const {router} = renderWithGuard();
fireEvent.change(await screen.findByLabelText('SETTINGS.DISPLAY_NAME'), {target: {value: 'Anna'}});
fireEvent.click(screen.getByRole('link', {name: 'SETTINGS.TAB_ACCOUNT'}));
await waitFor(() => expect(confirm).toHaveBeenCalled());
expect(router.state.location.search).toBe('?tab=profile');
});
it('anUntouchedNameLetsTheUserLeaveWithoutAsking', async () => {
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
const {router} = renderWithGuard();
await screen.findByLabelText('SETTINGS.DISPLAY_NAME');
fireEvent.click(screen.getByRole('link', {name: 'SETTINGS.TAB_ACCOUNT'}));
await waitFor(() => expect(router.state.location.search).toBe('?tab=account'));
expect(confirm).not.toHaveBeenCalled();
});
it('aSavedNameLetsTheUserLeaveWithoutAsking', async () => {
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
const {router} = renderWithGuard();
fireEvent.change(await screen.findByLabelText('SETTINGS.DISPLAY_NAME'), {target: {value: 'Anna'}});
fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.SAVE_PROFILE'}));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('SETTINGS.SAVED'));
fireEvent.click(screen.getByRole('link', {name: 'SETTINGS.TAB_ACCOUNT'}));
await waitFor(() => expect(router.state.location.search).toBe('?tab=account'));
expect(confirm).not.toHaveBeenCalled();
});
});
+4 -3
View File
@@ -160,15 +160,16 @@ describe('StatsPage', () => {
const overview = () => screen.getByText('STATS.OVERVIEW.TITLE').closest('[data-slot="card"]'); const overview = () => screen.getByText('STATS.OVERVIEW.TITLE').closest('[data-slot="card"]');
const paths = () => overview().querySelectorAll('path.stroke-card'); const paths = () => overview().querySelectorAll('path.stroke-card');
it('drawsOneColoredSlicePerStatus', async () => { it('drawsOneSlicePerStatusInTheStatusColor', async () => {
answerStats(json(200, stats())); answerStats(json(200, stats()));
renderPage(); renderPage();
await screen.findByText('STATS.OVERVIEW.TITLE'); await screen.findByText('STATS.OVERVIEW.TITLE');
expect(paths()).toHaveLength(5); expect(paths()).toHaveLength(5);
expect(paths()[0].getAttribute('style')).toContain('var(--chart-1)'); expect(paths()[0].getAttribute('style')).toContain('var(--color-zinc-400)');
expect(paths()[4].getAttribute('style')).toContain('var(--chart-5)'); expect(paths()[1].getAttribute('style')).toContain('var(--primary)');
expect(paths()[4].getAttribute('style')).toContain('var(--color-rose-500)');
}); });
it('putsTheTotalInTheHoleAsALink', async () => { it('putsTheTotalInTheHoleAsALink', async () => {
+2 -2
View File
@@ -15,7 +15,7 @@ const FEATURES = [
{key: 'LIBRARY', to: '/library', icon: Library}, {key: 'LIBRARY', to: '/library', icon: Library},
{key: 'STATUS', to: '/library', icon: ListChecks}, {key: 'STATUS', to: '/library', icon: ListChecks},
{key: 'STATS', to: '/stats', icon: ChartBarBig}, {key: 'STATS', to: '/stats', icon: ChartBarBig},
{key: 'PROFILE', to: '/settings', icon: UserRound}, {key: 'PROFILE', to: '/settings?tab=profile', icon: UserRound},
{key: 'IMPORT', to: '/import', icon: Upload}, {key: 'IMPORT', to: '/import', icon: Upload},
{key: 'DASHBOARD', to: null, icon: LayoutGrid}, {key: 'DASHBOARD', to: null, icon: LayoutGrid},
]; ];
@@ -41,7 +41,7 @@ export default function WelcomePage() {
}, []); }, []);
const steps = [ const steps = [
{key: 'STEAM', to: '/settings', done: !!user?.steamId || canSync}, {key: 'STEAM', to: '/settings?tab=connections', done: !!user?.steamId || canSync},
{key: 'IMPORT', to: '/import', done: summary?.total > 0}, {key: 'IMPORT', to: '/import', done: summary?.total > 0},
]; ];
+1 -1
View File
@@ -44,7 +44,7 @@ describe('WelcomePage', () => {
expect(screen.getAllByRole('heading', {level: 2})).toHaveLength(6); expect(screen.getAllByRole('heading', {level: 2})).toHaveLength(6);
expect(screen.getAllByRole('link', {name: 'WELCOME.OPEN'}).map((link) => link.getAttribute('href'))) expect(screen.getAllByRole('link', {name: 'WELCOME.OPEN'}).map((link) => link.getAttribute('href')))
.toEqual(['/library', '/library', '/stats', '/settings', '/import']); .toEqual(['/library', '/library', '/stats', '/settings?tab=profile', '/import']);
}); });
it('checks the steps that are done', async () => { it('checks the steps that are done', async () => {
+7
View File
@@ -9,6 +9,13 @@ const FOLLOWS = () => `${import.meta.env.VITE_CORE_API_URL}/api/follows`;
*/ */
export const fetchFollows = () => request(FOLLOWS()); export const fetchFollows = () => request(FOLLOWS());
/**
* GET /api/follows/players?q=: `{results: [{displayName}]}`, public profiles whose name contains `q`, at most 20, name
* starts first. 429 `TOO_MANY_SEARCHES` beyond 30 searches per minute.
*/
export const searchPlayers = (query, signal) =>
request(`${FOLLOWS()}/players?${new URLSearchParams({q: query.trim()})}`, {signal});
const byName = (displayName) => `${FOLLOWS()}/by-name/${encodeURIComponent(displayName)}`; const byName = (displayName) => `${FOLLOWS()}/by-name/${encodeURIComponent(displayName)}`;
/** /**
+3
View File
@@ -47,3 +47,6 @@ export const groupDetails = (details) =>
}); });
return {kind, games: [...games.values()]}; return {kind, games: [...games.values()]};
}).filter((group) => group.games.length > 0); }).filter((group) => group.games.length > 0);
/** What the bell shows: unread notes, unseen news entries and open questions. */
export const notificationTotal = ({unread, news, questions}) => unread + news + questions;
+8 -1
View File
@@ -1,5 +1,5 @@
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
import {digestParts, groupDetails} from './inbox.js'; import {digestParts, groupDetails, notificationTotal} from './inbox.js';
describe('digestParts', () => { describe('digestParts', () => {
it('keepsOnlyTheNonZeroCountsInAFixedOrder', () => { it('keepsOnlyTheNonZeroCountsInAFixedOrder', () => {
@@ -38,3 +38,10 @@ describe('groupDetails', () => {
expect(groupDetails([])).toEqual([]); expect(groupDetails([])).toEqual([]);
}); });
}); });
describe('notificationTotal', () => {
it('addsUnreadNotesUnseenNewsAndOpenQuestions', () => {
expect(notificationTotal({unread: 2, news: 1, questions: 4})).toBe(7);
expect(notificationTotal({unread: 0, news: 0, questions: 0})).toBe(0);
});
});
+12
View File
@@ -8,6 +8,18 @@ const JSON_HEADERS = {'Content-Type': 'application/json'};
*/ */
export const fetchNews = () => apiFetch(`${AUTH()}/api/news`); export const fetchNews = () => apiFetch(`${AUTH()}/api/news`);
/**
* The entries the user has not seen; none when the call fails.
*/
export const fetchUnseenNews = async () => {
try {
const response = await fetchNews();
return response.ok ? (await response.json()).results.filter((entry) => !entry.seen) : [];
} catch {
return [];
}
};
/** /**
* All entries published until now count as seen for this user. * All entries published until now count as seen for this user.
*/ */
+8
View File
@@ -35,3 +35,11 @@ export const trendPoints = (days, width, height) => {
const span = dayNumber(days[days.length - 1].day) - first; const span = dayNumber(days[days.length - 1].day) - first;
return days.map((d) => `${((dayNumber(d.day) - first) / span) * width},${y(d)}`).join(' '); return days.map((d) => `${((dayNumber(d.day) - first) / span) * width},${y(d)}`).join(' ');
}; };
/** The change of the pile from the first to the last day; 0 for fewer than two days. */
export const trendNet = (days) => (days.length < 2 ? 0 : days[days.length - 1].backlogGames - days[0].backlogGames);
export const trendRange = (days) => {
const sizes = days.map((d) => d.backlogGames);
return sizes.length ? {min: Math.min(...sizes), max: Math.max(...sizes)} : {min: 0, max: 0};
};
+20 -1
View File
@@ -1,5 +1,5 @@
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
import {genreKey, trendPoints} from './stats.js'; import {genreKey, trendNet, trendPoints, trendRange} from './stats.js';
describe('genreKey', () => { describe('genreKey', () => {
it('turns an IGDB genre name into an i18n key', () => { it('turns an IGDB genre name into an i18n key', () => {
@@ -31,3 +31,22 @@ describe('trendPoints', () => {
expect(trendPoints([], 100, 40)).toBe(''); expect(trendPoints([], 100, 40)).toBe('');
}); });
}); });
describe('trendNet and trendRange', () => {
const day = (date, backlogGames) => ({day: date, backlogGames});
it('trendNet_IsLastMinusFirst', () => {
expect(trendNet([day('2026-09-01', 10), day('2026-09-02', 14), day('2026-09-03', 12)])).toBe(2);
expect(trendNet([day('2026-09-01', 10), day('2026-09-03', 7)])).toBe(-3);
});
it('trendNet_IsZeroForFewerThanTwoDays', () => {
expect(trendNet([day('2026-09-01', 10)])).toBe(0);
expect(trendNet([])).toBe(0);
});
it('trendRange_IsSmallestAndBiggestPile', () => {
expect(trendRange([day('2026-09-01', 10), day('2026-09-02', 14), day('2026-09-03', 12)])).toEqual({min: 10, max: 14});
expect(trendRange([])).toEqual({min: 0, max: 0});
});
});
+15
View File
@@ -0,0 +1,15 @@
/**
* The `tab` search parameter when it is one of `tabs`, else `fallback`.
*/
export const tabOf = (params, tabs, fallback = tabs[0]) => {
const tab = params.get('tab');
return tabs.includes(tab) ? tab : fallback;
};
/**
* The `TabNav` tabs of a page that picks its tab by the `tab` search parameter.
*
* @param meta `{name: {labelKey, icon}}` in tab order
*/
export const searchTabs = (path, meta, active) =>
Object.entries(meta).map(([name, tab]) => ({...tab, to: `${path}?tab=${name}`, active: name === active}));
+31
View File
@@ -0,0 +1,31 @@
import {describe, expect, it} from 'vitest';
import {searchTabs, tabOf} from './tabs.js';
const TABS = ['a', 'b', 'c'];
describe('tabOf', () => {
it('knownTab_IsReturned', () => {
expect(tabOf(new URLSearchParams('tab=b'), TABS)).toBe('b');
});
it('missingOrUnknownTab_IsTheFirstTab', () => {
expect(tabOf(new URLSearchParams(''), TABS)).toBe('a');
expect(tabOf(new URLSearchParams('tab=x'), TABS)).toBe('a');
});
it('fallback_ReplacesTheFirstTab', () => {
expect(tabOf(new URLSearchParams('tab=x'), TABS, 'c')).toBe('c');
});
});
describe('searchTabs', () => {
it('linksEachTabByTheSearchParameterInMetaOrder', () => {
const meta = {a: {labelKey: 'A'}, b: {labelKey: 'B'}};
expect(searchTabs('/x', meta, 'b')).toEqual([
{labelKey: 'A', to: '/x?tab=a', active: false},
{labelKey: 'B', to: '/x?tab=b', active: true},
]);
});
});