Public Access
feat(nav): simplify navigation and terms (#57)
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:
1 parent
45f5087227
commit
3712e3905f
80 files changed
+2179
-1131
No files matched your search
@@ -20,6 +20,12 @@ To Be Played is split into four repositories:
|
||||
|
||||
## 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
|
||||
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
|
||||
@@ -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
|
||||
the details page, in the stat game lists and on the public profile. The pile counts only the games Core sends in
|
||||
`pileGames`, so coming games are not on it; Core never picks them.
|
||||
- **What to play next** (`/play-next`): seven rows of up to 3 backlog games each: up next (your own backlog order),
|
||||
- **What to play next** (`/play-next`): seven rows of up to 3 backlog games each: queue (your own order),
|
||||
next in series (the next part of a series you played), because you played (a similar game for each of your 3 most
|
||||
played), same studio (a developer you played), from people you follow (a backlog game someone you follow finished or played 5 h or more), longest on your list, and mood picks. A time
|
||||
budget (up to 5 / 15 / 40 h or any) filters all rows; a mood (story, co-op, chill, action, scary from IGDB; speedrun
|
||||
from speedrun.com) only the mood row. Each row has its own reroll without repeats, and "Start playing" in one click.
|
||||
On a phone each row scrolls sideways.
|
||||
- **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
|
||||
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
|
||||
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.
|
||||
@@ -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
|
||||
views the status counts and the pile link to the library with the filter instead. Not clickable: sums (playtime),
|
||||
averages, rates and the moon trips. The list always uses the same rules as the number.
|
||||
- **Pile Points** (`/points`): points for playing games off your pile (start, finish by time to beat, 100 %, month and
|
||||
- **Pile Points** (`/points`): a three-line summary on top (start +5, finish +30 to +80, 100 % +40; points buy looks), then the shop, then the detailed rules in a closed section and the history. Points for playing games off your pile (start, finish by time to beat, 100 %, month and
|
||||
streak bonuses; Core checks proof of play), spent in a small shop on an avatar border, a badge, a premium accent, a
|
||||
banner frame, an effect for 100 % games and an animated page or banner background (CSS and SVG in `effects.css`, two canvas engines in `EffectCanvas`; layers follow the mouse and the phone tilt, on iPhone after the switch in the settings),
|
||||
shown on the dashboard and the public profile. Twelve more items are unlock-only: they show locked with a progress bar
|
||||
@@ -94,7 +100,7 @@ flowchart LR
|
||||
- **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit,
|
||||
a platform and optional hours. Works without a Steam ID. Games already in ToBePlayed come first. Check several
|
||||
hits to add them at once through the import review.
|
||||
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".
|
||||
- **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
|
||||
@@ -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
|
||||
verify it; unlinking is possible. After a successful link the settings page opens the dashboard (success toast),
|
||||
where the first sync starts; the other outcomes (in use, cancelled, failed) stay on the settings page.
|
||||
- **Public profile** at `/u/<display name>`, opt-in in the settings, 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
|
||||
"Your stats"), and sets its look in the settings: accent, corners, banner and background from own game art
|
||||
("Profile art" on a game's page). Visitors see the owner's look on that page only; light or dark mode stays theirs.
|
||||
- **Settings** (`/settings`) have tabs: Account & security, Appearance (with dark mode), Connections (Steam, PlayStation), Profile & community, About (build version). A switch or select saves at once, shows the new value right away, makes no success toast and goes back with an error toast when the save fails; a form with a Save button (name, password) keeps its success toast, and the name field asks before the user leaves with unsaved text.
|
||||
- **Appearance**: accent color, corner style, wide layout, reduce animations, page background and banner from your own game art.
|
||||
Saved in Auth, so it follows you to every device.
|
||||
- **Language**: kept in the browser and saved in Auth; after the login Auth's language wins when one is saved. Dark
|
||||
@@ -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
|
||||
path or https; anything else stays plain text); `HintText` renders React elements, never HTML.
|
||||
- **Follow users** (#27): a logged-in visitor follows the owner of a public profile with "Follow" on `/u/<name>`; the
|
||||
settings page lists who you follow ("Unfollow") and who follows you ("Remove", a private follower shows as "Private
|
||||
user"). Core writes one inbox note per followed user and batch (new games, achievements, status changes, wishlist
|
||||
adds; what the profile hides is left out). `/inbox` (bell in the header, with the unread count) lists the newest 50 notes
|
||||
and marks them read; a digest note opens inline (click) and lists its games, achievements, status changes and wishlist
|
||||
Community page (`/community`) has the tabs "Following" ("Unfollow"), "Followers" ("Remove", a private follower shows as
|
||||
"Private user"), "Find users" (search public profiles by name, `GET /api/follows/players?q=` in Core, 30 searches per
|
||||
minute, only public profiles) and "Community stats" (the numbers of all users, also on the landing page). Core writes one inbox note per followed user and batch (new games, achievements, status changes, wishlist
|
||||
adds; what the profile hides is left out). `/inbox` (the bell in the header) is the one place for what is new: an "N open questions" row that links to `/questions`,
|
||||
the news the user has not seen (marked seen on open, "All news" links to `/whats-new`) and the newest 50 notes (marked
|
||||
read on open). The bell badge is the sum of unread notes, unseen news and open questions (`useNotifications`, loaded on
|
||||
every page change; 0 on `/inbox` itself, and open questions count again after the visit until answered); a digest note opens inline (click) and lists its games, achievements, status changes and wishlist
|
||||
items, while you still follow the person and the part is public (5 achievements per game, then "show all"); a user
|
||||
who goes private ends the follow with a note. Two more notes come from the user's own
|
||||
PlayStation link (`PSN_EXPIRING` about 2 days before it ends, `PSN_EXPIRED` when it has ended), with a link to the
|
||||
@@ -202,12 +212,11 @@ flowchart LR
|
||||
game above the tiles (`ProfileGameCard`: status, playtime, achievements, only what the profile shares; one message
|
||||
when it hides or does not have the game). A game you do not own opens a catalog view (`CatalogGameCard`, `GET
|
||||
/api/library/games/<appid>/preview`) with an "Add to my library" button that opens the add search with the name filled
|
||||
in. `/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
|
||||
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"
|
||||
marks them seen in Auth; closing the dialog any other way shows them again on the next dashboard visit. `/whats-new`
|
||||
(menu) lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`.
|
||||
- **What's new**: unseen news entries show on `/inbox` (no dialog on the dashboard) and are marked seen in Auth when the
|
||||
inbox opens. `/whats-new` (avatar menu) is the archive: it lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`.
|
||||
- **Broadcast mail**: admins write a mail in both languages on `/admin/broadcasts`, send it to all active users or to
|
||||
picked ones, send a test to themselves, confirm with the real recipient count, and see the history with the counts.
|
||||
- **Welcome page**: `/welcome` opens once after the first login (Auth `settings.welcomeSeenAt` is null; only the
|
||||
@@ -313,11 +322,12 @@ flowchart LR
|
||||
Dialog -->|"community row, login"| Owners["Core /api/stats/owners"]
|
||||
Landing["Landing user tiles, login"] --> Users["Auth /api/stats/users"]
|
||||
```
|
||||
- `/questions` (`QuestionsPage`, nav item with the open count; the badge is hidden on the page, whose title shows the
|
||||
live count) lists one card per game with an open question (Start it? / Finished? / Dropped? / Rate it?). Core decides
|
||||
- `/questions` (`QuestionsPage`, reached from the bell and the inbox row "N open questions"; the title shows the live
|
||||
count) starts with a short intro (where questions come from, what an answer gives, what "No" does) and lists one card per game with an open question (Start it? / Finished? / Dropped? / Rate it?). Core decides
|
||||
which question is open; `src/utils/questions.js` only loads the list and sends the answer. A yes to Finished? gets
|
||||
the Rate it? card back at once. `409` / `404` reload the list. `RatingStars` is shared with `GameHeroCard`.
|
||||
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
|
||||
manual game has an Undo that restores it the same way.
|
||||
|
||||
@@ -350,10 +360,12 @@ flowchart LR
|
||||
|
||||
```mermaid
|
||||
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 -->|"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
|
||||
(a link to `/u/<name>`), everyone else as "+N private", the viewer as "You". The setting "show me in owner lists" is
|
||||
|
||||
@@ -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
@@ -16,7 +16,7 @@
|
||||
"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_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_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.",
|
||||
@@ -41,7 +41,7 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"TITLE": "Passwort zurücksetzen",
|
||||
@@ -53,9 +53,9 @@
|
||||
"DONE": "Dein Passwort ist geändert. Melde dich mit dem neuen an.",
|
||||
"TO_LOGIN": "Anmelden",
|
||||
"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_TOO_MANY": "Zu viele Versuche. Bitte versuche es in {{minutes}} Minuten noch einmal.",
|
||||
"ERROR_GENERIC": "Das hat nicht geklappt. Bitte versuche es noch einmal."
|
||||
"ERROR_REJECTED": "Der Server hat dieses Passwort nicht akzeptiert. Prüfe die Regeln und versuche es noch einmal.",
|
||||
"ERROR_TOO_MANY": "Zu viele Versuche. Versuche es in {{minutes}} Minuten noch einmal.",
|
||||
"ERROR_GENERIC": "Das hat nicht geklappt. Versuche es noch einmal."
|
||||
},
|
||||
"CONFIRM_EMAIL": {
|
||||
"TITLE": "E-Mail-Adresse bestätigen",
|
||||
@@ -63,7 +63,7 @@
|
||||
"SUBMIT": "Bestätigen",
|
||||
"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:",
|
||||
"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": {
|
||||
"PROFILE_STYLE": "Aussehen des Profils",
|
||||
@@ -75,13 +75,13 @@
|
||||
"TITLE": "Einstellungen",
|
||||
"LOADING": "Einstellungen werden geladen …",
|
||||
"ACCOUNT": "Konto",
|
||||
"PREFERENCES": "Darstellung & Sprache",
|
||||
"PREFERENCES": "Sprache & Plattform",
|
||||
"STEAM": "Steam",
|
||||
"EMAIL": "E-Mail",
|
||||
"ROLE": "Rolle",
|
||||
"CREATED_DATE": "Mitglied seit",
|
||||
"LAST_LOGIN": "Letzter Login",
|
||||
"OPEN_WELCOME": "Willkommensseite erneut anzeigen",
|
||||
"OPEN_WELCOME": "Willkommensseite noch einmal anzeigen",
|
||||
"DARK_THEME": "Dunkles Design",
|
||||
"LANGUAGE": "Sprache",
|
||||
"LANGUAGE_OPTIONS": {
|
||||
@@ -125,7 +125,7 @@
|
||||
"REDUCE_MOTION_HINT": "Shop-Effekte und Konfetti stehen still, auch auf fremden Profilen.",
|
||||
"TILT": "Neigen bewegt Effekte",
|
||||
"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_HINT": "Öffne ein Spiel in deiner Bibliothek und wähle „Als Hintergrund“.",
|
||||
"BACKGROUND_REMOVE": "Hintergrund entfernen",
|
||||
@@ -148,7 +148,7 @@
|
||||
"LINKED": "Steam-Konto verknüpft.",
|
||||
"IN_USE": "Dieses Steam-Konto ist schon mit einem anderen Konto verknüpft.",
|
||||
"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.",
|
||||
"UNLINKED": "Steam-Konto getrennt."
|
||||
},
|
||||
@@ -173,8 +173,8 @@
|
||||
"UNLINK_CONFIRM": "Ja, trennen",
|
||||
"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.",
|
||||
"UNAVAILABLE": "PlayStation hat nicht geantwortet. Bitte versuche es später noch einmal.",
|
||||
"BUSY": "Eine andere PlayStation-Anfrage von dir läuft noch. Warte einen Moment und versuche es erneut.",
|
||||
"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 noch einmal.",
|
||||
"ERROR": "Mit der PlayStation-Verknüpfung ist etwas schiefgelaufen."
|
||||
},
|
||||
"VERSION": "Version",
|
||||
@@ -209,7 +209,14 @@
|
||||
"FAVORITE_PLATFORM": "Lieblingsplattform",
|
||||
"FAVORITE_PLATFORM_HINT": "Wird zuerst gewählt, wenn du ein Spiel für mehrere Plattformen hinzufügst.",
|
||||
"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": {
|
||||
"EMAIL_REQUIRED": "E-Mail ist erforderlich.",
|
||||
@@ -240,23 +247,23 @@
|
||||
"WELCOME": "Willkommen",
|
||||
"EMPTY": {
|
||||
"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",
|
||||
"IMPORT": "Spiele importieren"
|
||||
},
|
||||
"CURRENTLY_PLAYING": "Spiele ich gerade",
|
||||
"NOTHING_PLAYING": "Gerade nichts. Setze ein Spiel auf „Spiele ich“.",
|
||||
"UP_NEXT": "Als Nächstes",
|
||||
"UP_NEXT_EMPTY": "Dein Backlog ist leer. Schön!",
|
||||
"UP_NEXT": "Warteschlange",
|
||||
"UP_NEXT_EMPTY": "Deine Warteschlange ist leer. Schön!",
|
||||
"PILE_OF_SHAME": "Pile of Shame",
|
||||
"PILE_SHARE": "{{percent}} % deiner Bibliothek",
|
||||
"NEVER_PLAYED_one": "{{count}} Spiel nie gestartet",
|
||||
"NEVER_PLAYED_other": "{{count}} Spiele nie gestartet",
|
||||
"SUGGESTION": "Wie wäre es mit",
|
||||
"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.",
|
||||
"RETRY": "Erneut versuchen",
|
||||
"RETRY": "Noch einmal versuchen",
|
||||
"ACTIVITY": "Spielzeit",
|
||||
"ACTIVITY_TOTAL": "{{time}} in {{days}} Tagen",
|
||||
"ACTIVITY_EMPTY": "Noch keine Spielzeit aufgezeichnet. Ab jetzt zählt jeder Sync mit.",
|
||||
@@ -281,7 +288,7 @@
|
||||
"TITLE": "Spielebibliothek",
|
||||
"LOADING": "Lade Spiele …",
|
||||
"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.",
|
||||
"COMING": "Erscheint am {{date}}",
|
||||
"COMING_SOON": "Erscheint bald",
|
||||
@@ -298,19 +305,19 @@
|
||||
"NEXT": "Weiter",
|
||||
"PAGE_OF": "Seite {{current}} von {{total}}",
|
||||
"GAMES": "Spiele",
|
||||
"RESYNC": "Jetzt synchronisieren",
|
||||
"RESYNC": "Sync starten",
|
||||
"SYNC_STARTED": "Sync gestartet …",
|
||||
"SYNC_COOLDOWN": "In {{seconds}}s erneut versuchen",
|
||||
"SYNCING": "Synchronisiere deine Bibliothek …",
|
||||
"SYNC_COOLDOWN": "In {{seconds}}s noch einmal versuchen",
|
||||
"SYNCING": "Sync läuft …",
|
||||
"SYNCING_FIRST_TIME": "Erster Sync läuft. Deine Spiele erscheinen gleich.",
|
||||
"SYNC_QUEUED": "Erster Sync ist eingereiht …",
|
||||
"SYNC_DURATION_HINT": "Meist unter einer Minute, große Bibliotheken länger.",
|
||||
"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_NOT_STARTED": "Der erste Sync ist nicht gestartet. Versuche „Jetzt synchronisieren“.",
|
||||
"LAST_SYNCED": "Zuletzt synchronisiert {{when}}",
|
||||
"SYNC_NOT_STARTED": "Der erste Sync ist nicht gestartet. Versuche „Sync starten“.",
|
||||
"LAST_SYNCED": "Letzter Sync {{when}}",
|
||||
"FILTER_ALL": "Alle",
|
||||
"SEARCH_PLACEHOLDER": "Spiel suchen …",
|
||||
"SEARCH_PLACEHOLDER": "In deiner Bibliothek suchen …",
|
||||
"NO_MATCH": "Keine Spiele für diesen Filter.",
|
||||
"SORT_RATING": "Beste Bewertung",
|
||||
"SORT_PRIORITY": "Höchste Priorität",
|
||||
@@ -318,7 +325,10 @@
|
||||
"FILTER_HIDDEN": "Ausgeblendet",
|
||||
"NO_HIDDEN": "Keine ausgeblendeten Spiele. Blende ein Spiel auf seiner Detailseite aus, z. B. einen Playtest oder ein Tool.",
|
||||
"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": {
|
||||
"MORE": "Weitere Aktionen",
|
||||
@@ -402,16 +412,14 @@
|
||||
"NAV": {
|
||||
"DASHBOARD": "Dashboard",
|
||||
"LIBRARY": "Bibliothek",
|
||||
"SEARCH": "Suche",
|
||||
"SETTINGS": "Einstellungen",
|
||||
"TO_DASHBOARD": "Zum Dashboard",
|
||||
"MENU": "Menü",
|
||||
"LANGUAGE": "Sprache",
|
||||
"DARK_MODE": "Dunkles Design",
|
||||
"LIGHT_MODE": "Helles Design",
|
||||
"PLAY_NEXT": "Nächstes Spiel",
|
||||
"PLAY_NEXT": "Als Nächstes",
|
||||
"STATS": "Statistik",
|
||||
"FOLLOWING": "Folge ich",
|
||||
"COMMUNITY": "Community",
|
||||
"POINTS": "Pile Points",
|
||||
"ADMIN": "Admin",
|
||||
@@ -424,9 +432,6 @@
|
||||
"INBOX": "Postfach",
|
||||
"INBOX_UNREAD_one": "Postfach, {{count}} neue Nachricht",
|
||||
"INBOX_UNREAD_other": "Postfach, {{count}} neue Nachrichten",
|
||||
"QUESTIONS": "Fragen",
|
||||
"QUESTIONS_OPEN_one": "Fragen, {{count}} offen",
|
||||
"QUESTIONS_OPEN_other": "Fragen, {{count}} offen",
|
||||
"WHATS_NEW": "Neuigkeiten",
|
||||
"ADMIN_NEWS": "Neuigkeiten verwalten",
|
||||
"ADMIN_BROADCASTS": "Rundmail",
|
||||
@@ -434,10 +439,11 @@
|
||||
"PENDING_REQUESTS_other": "{{count}} Registrierungen warten",
|
||||
"LINK_QUEUE": "Verknüpfungen",
|
||||
"AUDIT_LOG": "Protokoll",
|
||||
"GAME_CATALOG": "Spielekatalog"
|
||||
"GAME_CATALOG": "Spielekatalog",
|
||||
"MY_PROFILE": "Mein öffentliches Profil"
|
||||
},
|
||||
"BACKLOG": {
|
||||
"TITLE": "Mein Backlog",
|
||||
"TITLE": "Mein Fortschritt",
|
||||
"STATUS_LABEL": "Status",
|
||||
"PRIORITY_LABEL": "Priorität",
|
||||
"RATING_LABEL": "Bewertung",
|
||||
@@ -464,10 +470,10 @@
|
||||
}
|
||||
},
|
||||
"BACKLOG_ORDER": {
|
||||
"TITLE": "Backlog",
|
||||
"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.",
|
||||
"EMPTY": "Dein Backlog ist leer. Schön!",
|
||||
"LOAD_FAILED": "Der Backlog konnte nicht geladen werden.",
|
||||
"TITLE": "Warteschlange",
|
||||
"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": "Deine Warteschlange ist leer. Schön!",
|
||||
"LOAD_FAILED": "Die Warteschlange konnte nicht geladen werden.",
|
||||
"SAVE_FAILED": "Die Reihenfolge konnte nicht gespeichert werden. Die Liste wird neu geladen.",
|
||||
"MOVE_UP": "Nach oben",
|
||||
"MOVE_DOWN": "Nach unten",
|
||||
@@ -499,11 +505,15 @@
|
||||
"PERFECT_GAMES": "Perfekte Spiele",
|
||||
"AVERAGE_COMPLETION": "Ø Fortschritt",
|
||||
"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": {
|
||||
"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": {
|
||||
"SIMILAR": "Weil du {{name}} gespielt hast ({{playtime}})",
|
||||
"SERIES": "Reihe: {{name}}",
|
||||
@@ -513,7 +523,7 @@
|
||||
"LONGEST": "In deiner Bibliothek seit {{date}}"
|
||||
},
|
||||
"CATEGORY": {
|
||||
"NEXT_UP": "Als Nächstes",
|
||||
"NEXT_UP": "Warteschlange",
|
||||
"SERIES": "Nächster Teil der Reihe",
|
||||
"SIMILAR": "Weil du gespielt hast",
|
||||
"STUDIO": "Vom selben Studio",
|
||||
@@ -550,7 +560,7 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"ADD_BUTTON": "Spiel hinzufügen",
|
||||
@@ -596,7 +606,7 @@
|
||||
},
|
||||
"PILE": {
|
||||
"TITLE": "Pile of Shame",
|
||||
"GAMES": "Spiele auf dem Stapel",
|
||||
"GAMES": "Spiele im Pile of Shame",
|
||||
"NORMAL": "Zum Durchspielen",
|
||||
"COMPLETIONIST": "Für 100 %",
|
||||
"KNOWN_one": "IGDB kennt {{count}} davon",
|
||||
@@ -608,10 +618,12 @@
|
||||
"SINCE": "seit {{date}}"
|
||||
},
|
||||
"TREND": {
|
||||
"TITLE": "Stapel-Verlauf",
|
||||
"TITLE": "Verlauf des Pile of Shame",
|
||||
"THIS_MONTH": "Diesen Monat:",
|
||||
"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."
|
||||
},
|
||||
"TOP_GAMES": {
|
||||
@@ -695,9 +707,9 @@
|
||||
"IN_LIBRARIES_one": "in {{count}} Bibliothek",
|
||||
"IN_LIBRARIES_other": "in {{count}} Bibliotheken",
|
||||
"BEFORE_TRACKING": "Vor Beginn des Status-Verlaufs durchgespielt",
|
||||
"TREND_MOVES": "Stapel-Änderungen der letzten 90 Tage",
|
||||
"TREND_ADDED": "Diesen Monat zum Stapel hinzugekommen",
|
||||
"TREND_DONE": "Diesen Monat vom Stapel gegangen",
|
||||
"TREND_MOVES": "Änderungen im Pile of Shame in den letzten 90 Tagen",
|
||||
"TREND_ADDED": "Diesen Monat zum Pile of Shame hinzugekommen",
|
||||
"TREND_DONE": "Diesen Monat aus dem Pile of Shame gegangen",
|
||||
"NEW_IN_LIBRARY": "Neu",
|
||||
"GAMES_one": "{{count}} Spiel",
|
||||
"GAMES_other": "{{count}} Spiele"
|
||||
@@ -934,12 +946,12 @@
|
||||
"DO_NOT_SHOW_AGAIN": "Nicht mehr anzeigen",
|
||||
"OK": "OK",
|
||||
"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_PILE_GREW_one": "Dein Stapel 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_LINK_QUEUE_one": "**{{count}}** Paar wartet in der [Verknüpfungs-Warteschlange](/audit/links).",
|
||||
"AUTO_LINK_QUEUE_other": "**{{count}}** Paare warten in der [Verknüpfungs-Warteschlange](/audit/links)."
|
||||
"AUTO_PILE_GREW_one": "Dein Pile of Shame ist diesen Monat um **{{count}}** Spiel 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 unter [Verknüpfungen](/audit/links).",
|
||||
"AUTO_LINK_QUEUE_other": "**{{count}}** Paare warten unter [Verknüpfungen](/audit/links)."
|
||||
},
|
||||
"ADMIN_HINTS": {
|
||||
"NEW": "Neuer Hinweis",
|
||||
@@ -984,17 +996,26 @@
|
||||
"AUTO_TITLE": "Automatische Hinweise",
|
||||
"AUTO_NO_STEAM": "Kein Steam-Konto verknüpft",
|
||||
"AUTO_EMPTY_LIBRARY": "Leere Bibliothek",
|
||||
"AUTO_PILE_GREW": "Der Stapel ist diesen Monat gewachsen",
|
||||
"AUTO_LINK_QUEUE": "Offene Paare in der Verknüpfungs-Warteschlange (Auditoren)",
|
||||
"AUTO_PILE_GREW": "Der Pile of Shame ist diesen Monat gewachsen",
|
||||
"AUTO_LINK_QUEUE": "Offene Paare unter Verknüpfungen (Auditoren)",
|
||||
"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": {
|
||||
"TITLE": "Folgen",
|
||||
"TITLE": "Ich folge",
|
||||
"FOLLOW": "Folgen",
|
||||
"UNFOLLOW": "Entfolgen",
|
||||
"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_FOLLOWERS": "Dir folgt noch niemand.",
|
||||
"PRIVATE_USER": "Privater Nutzer",
|
||||
@@ -1004,7 +1025,8 @@
|
||||
"LOAD_ERROR": "Die Liste konnte nicht geladen werden.",
|
||||
"LAST_ACTIVITY": "Letzte Aktivität {{date}}",
|
||||
"NO_ACTIVITY": "Noch keine Aktivität",
|
||||
"SHOW_ALL": "Alle anzeigen"
|
||||
"SHOW_ALL": "Alle anzeigen",
|
||||
"FIND_PLAYERS": "Nutzer finden"
|
||||
},
|
||||
"INBOX": {
|
||||
"TITLE": "Postfach",
|
||||
@@ -1031,12 +1053,13 @@
|
||||
"DETAIL_WISHLIST": "Wunschliste",
|
||||
"SHOW_ALL": "Alle {{count}} anzeigen",
|
||||
"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": {
|
||||
"TITLE": "Neuigkeiten",
|
||||
"GOT_IT": "Verstanden",
|
||||
"SAVE_FAILED": "Konnte nicht speichern, dass du die Neuigkeiten gelesen hast.",
|
||||
"LOAD_ERROR": "Die Neuigkeiten konnten nicht geladen werden.",
|
||||
"EMPTY": "Noch nichts Neues."
|
||||
},
|
||||
@@ -1048,7 +1071,7 @@
|
||||
"FEATURE_STATUS": "Status",
|
||||
"FEATURE_STATUS_TEXT": "Markiere ein Spiel als „Spiele ich“, „Durchgespielt“, „100 %“ oder „Abgebrochen“, mit Bewertung und Notizen.",
|
||||
"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_TEXT": "Teile deine Statistiken und was du spielst per Link. Aus, bis du es einschaltest.",
|
||||
"FEATURE_IMPORT": "Import",
|
||||
@@ -1211,7 +1234,7 @@
|
||||
"DONE": "Fertig: {{added}} Spiele hinzugefügt, {{skipped}} übersprungen.",
|
||||
"FAILED_one": "Der Import ist nach {{count}} Versuch 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.",
|
||||
"ATTEMPT": "Versuch {{attempt}}: der letzte Versuch kam nicht weiter.",
|
||||
"SOURCE_GALAXY": "GOG Galaxy",
|
||||
@@ -1239,15 +1262,15 @@
|
||||
"EQUIP": "Anlegen",
|
||||
"UNEQUIP": "Ablegen",
|
||||
"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.",
|
||||
"HOW_TO": "So bekommst du Punkte",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
@@ -1299,7 +1322,7 @@
|
||||
"ITEMS": {
|
||||
"BORDER_GOLD": "Goldener Avatarrahmen",
|
||||
"BORDER_NEON": "Neon-Avatarrahmen",
|
||||
"BADGE_PILE_SLAYER": "Abzeichen Stapel-Bezwinger",
|
||||
"BADGE_PILE_SLAYER": "Abzeichen Pile Slayer",
|
||||
"BADGE_COMPLETIONIST": "Abzeichen Komplettierer",
|
||||
"ACCENT_GOLD": "Akzent Gold",
|
||||
"ACCENT_TEAL": "Akzent Türkis",
|
||||
@@ -1362,7 +1385,7 @@
|
||||
"FINISHED_EARLY": "Durchgespielt (frühe Punkte)",
|
||||
"FINISHED_TOPUP": "Durchgespielt (Rest nach Nachweis)",
|
||||
"COMPLETED": "100-%-Bonus",
|
||||
"MONTH": "Kleinerer Stapel im",
|
||||
"MONTH": "Kleinerer Pile of Shame im",
|
||||
"STREAK": "Serien-Woche",
|
||||
"STARTER": "Startpunkte",
|
||||
"BUY": "Gekauft",
|
||||
@@ -1373,9 +1396,12 @@
|
||||
"GAMES_FINISHED": "{{count}} / {{target}} Spiele durchgespielt",
|
||||
"GAMES_COMPLETED": "{{count}} / {{target}} Spiele zu 100 %",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"CONFIRM": "Du hast Änderungen, die nicht gespeichert sind. Seite verlassen und die Änderungen verwerfen?"
|
||||
@@ -1409,7 +1435,6 @@
|
||||
}
|
||||
},
|
||||
"REMOVED_GAMES": {
|
||||
"TITLE_COUNT": "Entfernte Spiele ({{count}})",
|
||||
"RESTORE": "Wiederherstellen",
|
||||
"RESTORED": "Spiel wiederhergestellt.",
|
||||
"HINT": "Wiederhergestellte Spiele behalten Status, Bewertung und Notizen."
|
||||
@@ -1424,7 +1449,8 @@
|
||||
"SKIP": "Überspringen",
|
||||
"START": {
|
||||
"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": {
|
||||
"ASK": "Durchgespielt?",
|
||||
@@ -1439,6 +1465,9 @@
|
||||
"RATE": {
|
||||
"ASK": "Bewerten?",
|
||||
"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
@@ -14,8 +14,8 @@
|
||||
"ERROR_EMAIL": "Enter an email address with '@'.",
|
||||
"ERROR_PASSWORD": "The password must not be empty.",
|
||||
"ERROR_INVALID_CREDENTIALS": "Email or password is wrong.",
|
||||
"ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes.",
|
||||
"ERROR_GENERIC": "Login failed. Please try again later.",
|
||||
"ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Try again in {{minutes}} minutes.",
|
||||
"ERROR_GENERIC": "Login failed. Try again later.",
|
||||
"SESSION_EXPIRED": "Your session has expired. Please log in again.",
|
||||
"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.",
|
||||
@@ -35,13 +35,13 @@
|
||||
"RESEND": "Send the link again",
|
||||
"RESEND_SENT": "If this address has an unconfirmed registration, a new link is on its way.",
|
||||
"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.",
|
||||
"DISPLAY_NAME": "Display name (optional)",
|
||||
"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.",
|
||||
"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": {
|
||||
"TITLE": "Reset password",
|
||||
@@ -54,8 +54,8 @@
|
||||
"TO_LOGIN": "Log in",
|
||||
"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_TOO_MANY": "Too many attempts. Please try again in {{minutes}} minutes.",
|
||||
"ERROR_GENERIC": "That did not work. Please try again."
|
||||
"ERROR_TOO_MANY": "Too many attempts. Try again in {{minutes}} minutes.",
|
||||
"ERROR_GENERIC": "That did not work. Try again."
|
||||
},
|
||||
"CONFIRM_EMAIL": {
|
||||
"TITLE": "Confirm your email address",
|
||||
@@ -75,7 +75,7 @@
|
||||
"TITLE": "Settings",
|
||||
"LOADING": "Loading settings …",
|
||||
"ACCOUNT": "Account",
|
||||
"PREFERENCES": "Preferences",
|
||||
"PREFERENCES": "Language & platform",
|
||||
"STEAM": "Steam",
|
||||
"EMAIL": "Email",
|
||||
"ROLE": "Role",
|
||||
@@ -148,7 +148,7 @@
|
||||
"LINKED": "Steam account linked.",
|
||||
"IN_USE": "This Steam account is already linked to another account.",
|
||||
"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.",
|
||||
"UNLINKED": "Steam account unlinked."
|
||||
},
|
||||
@@ -173,7 +173,7 @@
|
||||
"UNLINK_CONFIRM": "Yes, unlink",
|
||||
"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.",
|
||||
"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.",
|
||||
"ERROR": "Something went wrong with the PlayStation link."
|
||||
},
|
||||
@@ -201,15 +201,22 @@
|
||||
"CANCEL": "Cancel",
|
||||
"DONE": "Your account is deleted.",
|
||||
"WRONG_PASSWORD": "The password is wrong.",
|
||||
"TOO_MANY": "Too many wrong passwords. Please try again in {{minutes}} minutes.",
|
||||
"ERROR": "The account could not be deleted. Please try again later.",
|
||||
"TOO_MANY": "Too many wrong passwords. Try again in {{minutes}} minutes.",
|
||||
"ERROR": "The account could not be deleted. Try again later.",
|
||||
"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\".",
|
||||
"FAVORITE_PLATFORM": "Favorite platform",
|
||||
"FAVORITE_PLATFORM_HINT": "Selected first when you add a game on more than one platform.",
|
||||
"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": {
|
||||
"EMAIL_REQUIRED": "Email is required.",
|
||||
@@ -246,15 +253,15 @@
|
||||
},
|
||||
"CURRENTLY_PLAYING": "Currently playing",
|
||||
"NOTHING_PLAYING": "Nothing right now. Set a game to \"Playing\".",
|
||||
"UP_NEXT": "Up next",
|
||||
"UP_NEXT_EMPTY": "Your backlog is empty. Nice!",
|
||||
"UP_NEXT": "Queue",
|
||||
"UP_NEXT_EMPTY": "Your queue is empty. Nice!",
|
||||
"PILE_OF_SHAME": "Pile of Shame",
|
||||
"PILE_SHARE": "{{percent}}% of your library",
|
||||
"NEVER_PLAYED_one": "{{count}} game never started",
|
||||
"NEVER_PLAYED_other": "{{count}} games never started",
|
||||
"SUGGESTION": "How about",
|
||||
"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.",
|
||||
"RETRY": "Try again",
|
||||
"ACTIVITY": "Playtime",
|
||||
@@ -301,16 +308,16 @@
|
||||
"RESYNC": "Sync now",
|
||||
"SYNC_STARTED": "Sync started …",
|
||||
"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.",
|
||||
"SYNC_QUEUED": "First sync queued …",
|
||||
"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_NOT_STARTED": "The first sync did not start. Try \"Sync now\".",
|
||||
"LAST_SYNCED": "Last synced {{when}}",
|
||||
"LAST_SYNCED": "Last sync {{when}}",
|
||||
"FILTER_ALL": "All",
|
||||
"SEARCH_PLACEHOLDER": "Search games …",
|
||||
"SEARCH_PLACEHOLDER": "Search your library …",
|
||||
"NO_MATCH": "No games match this filter.",
|
||||
"SORT_RATING": "Best rated",
|
||||
"SORT_PRIORITY": "Highest priority",
|
||||
@@ -318,7 +325,10 @@
|
||||
"FILTER_HIDDEN": "Hidden",
|
||||
"NO_HIDDEN": "No hidden games. Hide a game on its detail page, e.g. a playtest or a tool.",
|
||||
"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": {
|
||||
"MORE": "More actions",
|
||||
@@ -393,7 +403,7 @@
|
||||
},
|
||||
"MAIN": {
|
||||
"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",
|
||||
"APPROVAL_HINT": "An admin approves every new account before the first login.",
|
||||
"LOGIN": "Log in",
|
||||
@@ -402,7 +412,6 @@
|
||||
"NAV": {
|
||||
"DASHBOARD": "Dashboard",
|
||||
"LIBRARY": "Library",
|
||||
"SEARCH": "Search",
|
||||
"SETTINGS": "Settings",
|
||||
"TO_DASHBOARD": "Go to dashboard",
|
||||
"MENU": "Menu",
|
||||
@@ -411,7 +420,6 @@
|
||||
"LIGHT_MODE": "Light mode",
|
||||
"PLAY_NEXT": "Play next",
|
||||
"STATS": "Stats",
|
||||
"FOLLOWING": "Following",
|
||||
"COMMUNITY": "Community",
|
||||
"POINTS": "Pile Points",
|
||||
"ADMIN": "Admin",
|
||||
@@ -424,9 +432,6 @@
|
||||
"INBOX": "Inbox",
|
||||
"INBOX_UNREAD_one": "Inbox, {{count}} new message",
|
||||
"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",
|
||||
"ADMIN_NEWS": "Manage news",
|
||||
"ADMIN_BROADCASTS": "Broadcast email",
|
||||
@@ -434,10 +439,11 @@
|
||||
"PENDING_REQUESTS_other": "{{count}} registrations waiting",
|
||||
"LINK_QUEUE": "Link queue",
|
||||
"AUDIT_LOG": "Audit log",
|
||||
"GAME_CATALOG": "Game catalog"
|
||||
"GAME_CATALOG": "Game catalog",
|
||||
"MY_PROFILE": "My public profile"
|
||||
},
|
||||
"BACKLOG": {
|
||||
"TITLE": "My backlog",
|
||||
"TITLE": "My progress",
|
||||
"STATUS_LABEL": "Status",
|
||||
"PRIORITY_LABEL": "Priority",
|
||||
"RATING_LABEL": "Rating",
|
||||
@@ -448,7 +454,7 @@
|
||||
"SAVE": "Save",
|
||||
"RESET": "Discard",
|
||||
"SAVED": "Saved",
|
||||
"SAVE_FAILED": "Saving failed. Please try again.",
|
||||
"SAVE_FAILED": "Saving failed. Try again.",
|
||||
"STATUS": {
|
||||
"BACKLOG": "Backlog",
|
||||
"PLAYING": "Playing",
|
||||
@@ -464,10 +470,10 @@
|
||||
}
|
||||
},
|
||||
"BACKLOG_ORDER": {
|
||||
"TITLE": "Backlog",
|
||||
"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.",
|
||||
"EMPTY": "Your backlog is empty. Nice!",
|
||||
"LOAD_FAILED": "Could not load the backlog.",
|
||||
"TITLE": "Queue",
|
||||
"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 queue is empty. Nice!",
|
||||
"LOAD_FAILED": "Could not load the queue.",
|
||||
"SAVE_FAILED": "Could not save the order. Reloading the list.",
|
||||
"MOVE_UP": "Move up",
|
||||
"MOVE_DOWN": "Move down",
|
||||
@@ -499,11 +505,15 @@
|
||||
"PERFECT_GAMES": "Perfect games",
|
||||
"AVERAGE_COMPLETION": "Avg. completion",
|
||||
"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": {
|
||||
"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": {
|
||||
"SIMILAR": "Because you played {{name}} ({{playtime}})",
|
||||
"SERIES": "Series: {{name}}",
|
||||
@@ -513,7 +523,7 @@
|
||||
"LONGEST": "In your library since {{date}}"
|
||||
},
|
||||
"CATEGORY": {
|
||||
"NEXT_UP": "Up next",
|
||||
"NEXT_UP": "Queue",
|
||||
"SERIES": "Next in series",
|
||||
"SIMILAR": "Because you played",
|
||||
"STUDIO": "Same studio",
|
||||
@@ -596,7 +606,7 @@
|
||||
},
|
||||
"PILE": {
|
||||
"TITLE": "Pile of Shame",
|
||||
"GAMES": "Games on the pile",
|
||||
"GAMES": "Games in the Pile of Shame",
|
||||
"NORMAL": "To finish them",
|
||||
"COMPLETIONIST": "To get them to 100%",
|
||||
"KNOWN_one": "IGDB knows {{count}} of them",
|
||||
@@ -608,10 +618,12 @@
|
||||
"SINCE": "since {{date}}"
|
||||
},
|
||||
"TREND": {
|
||||
"TITLE": "Pile trend",
|
||||
"TITLE": "Pile of Shame trend",
|
||||
"THIS_MONTH": "This month:",
|
||||
"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."
|
||||
},
|
||||
"TOP_GAMES": {
|
||||
@@ -695,9 +707,9 @@
|
||||
"IN_LIBRARIES_one": "in {{count}} library",
|
||||
"IN_LIBRARIES_other": "in {{count}} libraries",
|
||||
"BEFORE_TRACKING": "Finished before the status history started",
|
||||
"TREND_MOVES": "Pile moves in the last 90 days",
|
||||
"TREND_ADDED": "Added to the pile this month",
|
||||
"TREND_DONE": "Off the pile this month",
|
||||
"TREND_MOVES": "Pile of Shame changes in the last 90 days",
|
||||
"TREND_ADDED": "Added to the Pile of Shame this month",
|
||||
"TREND_DONE": "Off the Pile of Shame this month",
|
||||
"NEW_IN_LIBRARY": "New",
|
||||
"GAMES_one": "{{count}} game",
|
||||
"GAMES_other": "{{count}} games"
|
||||
@@ -934,10 +946,10 @@
|
||||
"DO_NOT_SHOW_AGAIN": "Do not show again",
|
||||
"OK": "OK",
|
||||
"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_PILE_GREW_one": "Your pile 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_one": "Your Pile of Shame grew by **{{count}}** game 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_other": "**{{count}}** pairs are waiting in the [link queue](/audit/links)."
|
||||
},
|
||||
@@ -984,17 +996,26 @@
|
||||
"AUTO_TITLE": "Automatic hints",
|
||||
"AUTO_NO_STEAM": "No Steam account linked",
|
||||
"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_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": {
|
||||
"TITLE": "Following",
|
||||
"FOLLOW": "Follow",
|
||||
"UNFOLLOW": "Unfollow",
|
||||
"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_FOLLOWERS": "Nobody follows you yet.",
|
||||
"PRIVATE_USER": "Private user",
|
||||
@@ -1004,7 +1025,8 @@
|
||||
"LOAD_ERROR": "The list could not be loaded.",
|
||||
"LAST_ACTIVITY": "Last activity {{date}}",
|
||||
"NO_ACTIVITY": "No activity yet",
|
||||
"SHOW_ALL": "Show all"
|
||||
"SHOW_ALL": "Show all",
|
||||
"FIND_PLAYERS": "Find users"
|
||||
},
|
||||
"INBOX": {
|
||||
"TITLE": "Inbox",
|
||||
@@ -1031,12 +1053,13 @@
|
||||
"DETAIL_WISHLIST": "Wishlist",
|
||||
"SHOW_ALL": "Show all {{count}}",
|
||||
"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": {
|
||||
"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.",
|
||||
"EMPTY": "Nothing new yet."
|
||||
},
|
||||
@@ -1044,11 +1067,11 @@
|
||||
"TITLE": "Welcome to ToBePlayed",
|
||||
"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_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_TEXT": "Mark a game as \"Playing\", \"Finished\", \"100%\" or \"Dropped\", with rating and notes.",
|
||||
"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_TEXT": "Share your stats and what you play with a link. Off until you switch it on.",
|
||||
"FEATURE_IMPORT": "Import",
|
||||
@@ -1239,15 +1262,15 @@
|
||||
"EQUIP": "Equip",
|
||||
"UNEQUIP": "Take off",
|
||||
"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.",
|
||||
"HOW_TO": "How to earn points",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
@@ -1362,7 +1385,7 @@
|
||||
"FINISHED_EARLY": "Finished (early points)",
|
||||
"FINISHED_TOPUP": "Finished (rest after proof)",
|
||||
"COMPLETED": "100% bonus",
|
||||
"MONTH": "Smaller pile in",
|
||||
"MONTH": "Smaller Pile of Shame in",
|
||||
"STREAK": "Streak week",
|
||||
"STARTER": "Starter points",
|
||||
"BUY": "Bought",
|
||||
@@ -1373,9 +1396,12 @@
|
||||
"GAMES_FINISHED": "{{count}} / {{target}} games finished",
|
||||
"GAMES_COMPLETED": "{{count}} / {{target}} games at 100%",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"CONFIRM": "You have changes that are not saved. Leave this page and lose them?"
|
||||
@@ -1409,7 +1435,6 @@
|
||||
}
|
||||
},
|
||||
"REMOVED_GAMES": {
|
||||
"TITLE_COUNT": "Removed games ({{count}})",
|
||||
"RESTORE": "Restore",
|
||||
"RESTORED": "Game restored.",
|
||||
"HINT": "Restored games keep their status, rating and notes."
|
||||
@@ -1424,7 +1449,8 @@
|
||||
"SKIP": "Skip",
|
||||
"START": {
|
||||
"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": {
|
||||
"ASK": "Finished?",
|
||||
@@ -1439,6 +1465,9 @@
|
||||
"RATE": {
|
||||
"ASK": "Rate 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
@@ -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 {useEffect} from 'react';
|
||||
|
||||
@@ -15,7 +15,7 @@ import AdminHintsPage from './pages/AdminHintsPage.jsx';
|
||||
import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx';
|
||||
import AdminNewsPage from './pages/AdminNewsPage.jsx';
|
||||
import InboxPage from './pages/InboxPage.jsx';
|
||||
import FollowingPage from './pages/FollowingPage.jsx';
|
||||
import CommunityPage from './pages/CommunityPage.jsx';
|
||||
import QuestionsPage from './pages/QuestionsPage.jsx';
|
||||
import WhatsNewPage from './pages/WhatsNewPage.jsx';
|
||||
import WelcomePage from './pages/WelcomePage.jsx';
|
||||
@@ -35,7 +35,9 @@ import GameSearchPage from './pages/GameSearchPage.jsx';
|
||||
import PointsPage from './pages/PointsPage.jsx';
|
||||
import PublicProfilePage from './pages/PublicProfilePage.jsx';
|
||||
import AppHeader from './components/AppHeader.jsx';
|
||||
import BottomNav from './components/BottomNav.jsx';
|
||||
import RequireSession from './components/RequireSession.jsx';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {Toaster} from '@/components/ui/sonner';
|
||||
import {TooltipProvider} from '@/components/ui/tooltip';
|
||||
import PageBackground from './components/PageBackground.jsx';
|
||||
@@ -79,7 +81,7 @@ function App() {
|
||||
)}
|
||||
<AppHeader isAuthenticated={isAuthenticated} onLogout={handleLogout}/>
|
||||
|
||||
<main className="grow flex flex-col">
|
||||
<main className={cn('grow flex flex-col', isAuthenticated && 'max-md:pb-16')}>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage/>}/>
|
||||
<Route path="/login" element={<LoginPage/>}/>
|
||||
@@ -95,7 +97,8 @@ function App() {
|
||||
<Route path="/backlog" element={<BacklogPage/>}/>
|
||||
<Route path="/play-next" element={<PlayNextPage/>}/>
|
||||
<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="/points" element={<PointsPage/>}/>
|
||||
<Route path="/settings" element={<SettingsPage/>}/>
|
||||
@@ -122,9 +125,10 @@ function App() {
|
||||
{t('IMPRINT.TITLE')}
|
||||
</Link>
|
||||
</footer>
|
||||
{isAuthenticated && <BottomNav/>}
|
||||
</div>
|
||||
<UnsavedChangesGuard/>
|
||||
<Toaster richColors position="bottom-right"/>
|
||||
<Toaster richColors position="bottom-right" mobileOffset={{bottom: 72}} className="max-md:![--offset-bottom:72px]"/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
+16
-1
@@ -24,6 +24,21 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('App', () => {
|
||||
it('redirectsTheOldFollowingUrlToTheCommunityTab', async () => {
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, {settings: {}}),
|
||||
'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {}, history: []}),
|
||||
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||
'GET /api/news': json(200, {results: []}),
|
||||
'GET /api/library/questions/count': json(200, {count: 0}),
|
||||
'GET /api/follows': json(200, {following: [], followers: []}),
|
||||
'GET /api/library/steam-profile': json(200, null),
|
||||
});
|
||||
const {router} = renderWithProviders(<App/>, {entries: ['/following']});
|
||||
|
||||
await waitFor(() => expect(router.state.location.pathname + router.state.location.search).toBe('/community?tab=following'));
|
||||
});
|
||||
|
||||
it('sets the document language to the UI language', () => {
|
||||
fetchRoutes({'GET /api/users/me': json(200, {settings: {}})});
|
||||
localStorage.clear();
|
||||
@@ -43,8 +58,8 @@ describe('App', () => {
|
||||
'GET /api/library/steam-profile': json(200, null),
|
||||
'POST /api/auth/refresh': json(401, {}),
|
||||
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||
'GET /api/news': json(200, {results: []}),
|
||||
'GET /api/library/questions/count': json(200, {count: 0}),
|
||||
'GET /api/library/removed-games': json(200, {results: []}),
|
||||
});
|
||||
const {router} = renderWithProviders(<App/>, {entries: ['/library']});
|
||||
await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy());
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 {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 {Badge} from '@/components/ui/badge';
|
||||
@@ -19,29 +19,17 @@ import {
|
||||
import {usePreferences} from '@/context/PreferencesContext.jsx';
|
||||
import {cn} from '@/lib/utils';
|
||||
import Logo from './Logo.jsx';
|
||||
import {isNavActive, NAV_ITEMS} from './navItems.js';
|
||||
import PageContainer from './PageContainer.jsx';
|
||||
import SteamAvatar from './SteamAvatar.jsx';
|
||||
import useCurrentUser from '../hooks/useCurrentUser.js';
|
||||
import useNotifications from '../hooks/useNotifications.js';
|
||||
import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||
import {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js';
|
||||
import {canCorrect} from '../utils/corrections.js';
|
||||
import {fetchInbox} from '../utils/inbox.js';
|
||||
import {fetchRemovedGames} from '../utils/manualGames.js';
|
||||
import {fetchQuestionCount} from '../utils/questions.js';
|
||||
import {profileHref} from '../utils/people.js';
|
||||
import {expireSession, markSession} from '../utils/auth.js';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
|
||||
// The wishlist 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}) => (
|
||||
<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">
|
||||
@@ -50,7 +38,7 @@ const CountBadge = ({count}) => (
|
||||
);
|
||||
|
||||
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
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||
@@ -84,45 +72,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
||||
}, [seesAdminPage, pathname, menuOpenings]);
|
||||
const shownCount = seesAdminPage ? pendingCount : 0;
|
||||
|
||||
// Loaded again on every page change. The inbox page marks the notes read, so it shows no count.
|
||||
const [unread, setUnread] = useState(0);
|
||||
useEffect(() => {
|
||||
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';
|
||||
// The inbox page lists all of it, so it shows no count.
|
||||
const {total} = useNotifications(isAuthenticated, pathname);
|
||||
const shownTotal = pathname === '/inbox' ? 0 : total;
|
||||
|
||||
const switchRole = async (role) => {
|
||||
try {
|
||||
@@ -166,18 +118,15 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
||||
</Link>
|
||||
|
||||
{isAuthenticated && (
|
||||
<nav className="flex items-center sm:gap-1">
|
||||
{NAV_ITEMS.map(({to, labelKey, Icon, alsoActiveOn = [], iconOnly}) => {
|
||||
const count = to === '/questions' ? shownQuestions : 0;
|
||||
<nav aria-label={t('NAV.MENU')} className="hidden min-w-0 items-center gap-0.5 md:flex lg:gap-1">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = isNavActive(item, pathname);
|
||||
return (
|
||||
<NavLink key={to} to={to} title={t(labelKey)}
|
||||
aria-label={count > 0 ? t(`${labelKey}_OPEN`, {count}) : undefined}
|
||||
className={({isActive}) => cn(navLinkClass(isActive || alsoActiveOn.includes(pathname)), 'relative',
|
||||
to === '/questions' && questionsIconHidden && 'max-sm:hidden')}>
|
||||
<Icon className="size-4"/>
|
||||
{!iconOnly && <span className="hidden lg:inline">{t(labelKey)}</span>}
|
||||
{count > 0 && <CountBadge count={count}/>}
|
||||
</NavLink>
|
||||
<Link key={item.to} to={item.to} title={t(item.labelKey)} aria-current={active ? 'page' : undefined}
|
||||
className={navLinkClass(active)}>
|
||||
<item.Icon className="size-4 shrink-0"/>
|
||||
<span className="sr-only lg:not-sr-only lg:truncate">{t(item.labelKey)}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
@@ -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>
|
||||
)}
|
||||
{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}`}>
|
||||
<Coins className="text-primary"/>
|
||||
<span className="min-w-0 truncate tabular-nums">{wallet.balance}</span>
|
||||
@@ -198,9 +147,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
||||
)}
|
||||
{isAuthenticated && (
|
||||
<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/>
|
||||
{shownUnread > 0 && <CountBadge count={shownUnread}/>}
|
||||
{shownTotal > 0 && <CountBadge count={shownTotal}/>}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
@@ -212,22 +161,24 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
||||
<SteamAvatar url={steamProfile?.avatarUrl}/>
|
||||
{shownCount > 0 && (
|
||||
<span aria-label={t('NAV.PENDING_REQUESTS', {count: shownCount})}
|
||||
className="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full bg-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}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{currentUser?.displayName && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={profileHref(currentUser.displayName)}><UserRound/>{t('NAV.MY_PROFILE')}</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/points"><Coins/>{t('NAV.POINTS')}</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/inbox"><Bell/>{t('NAV.INBOX')}</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/whats-new"><Newspaper/>{t('NAV.WHATS_NEW')}</Link>
|
||||
</DropdownMenuItem>
|
||||
@@ -249,7 +200,7 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/admin/users">
|
||||
<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>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
|
||||
@@ -18,14 +18,14 @@ function Load() {
|
||||
return <button onClick={reloadWallet}>load</button>;
|
||||
}
|
||||
|
||||
async function loadChip(count) {
|
||||
async function loadChip() {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, {role: 'USER', activeRole: 'USER'}),
|
||||
'GET /api/library/steam-profile': json(200, {}),
|
||||
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||
'GET /api/library/questions/count': json(200, {count}),
|
||||
'GET /api/library/removed-games': json(200, {results: []}),
|
||||
'GET /api/news': json(200, {results: []}),
|
||||
'GET /api/library/questions/count': json(200, {count: 0}),
|
||||
'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}),
|
||||
});
|
||||
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
|
||||
@@ -34,16 +34,8 @@ async function loadChip(count) {
|
||||
}
|
||||
|
||||
describe('AppHeader points chip', () => {
|
||||
it('hides the chip below sm while the questions icon shows', async () => {
|
||||
const chip = await loadChip(2);
|
||||
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');
|
||||
it('showsTheChipAtEveryWidth', async () => {
|
||||
const chip = await loadChip();
|
||||
|
||||
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/library/steam-profile': json(200, {}),
|
||||
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||
'GET /api/news': json(200, {results: []}),
|
||||
'GET /api/library/questions/count': json(200, {count: 0}),
|
||||
'GET /api/library/removed-games': json(200, {results: []}),
|
||||
'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}),
|
||||
});
|
||||
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
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 {useTranslation} from 'react-i18next';
|
||||
import AppHeader from './AppHeader.jsx';
|
||||
@@ -20,8 +20,9 @@ const loggedInAs = (me, routes = {}, options = {}) => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
fetchRoutes({'GET /api/users/me': json(200, me), 'GET /api/library/steam-profile': json(200, {}),
|
||||
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||
'GET /api/news': json(200, {results: []}),
|
||||
'GET /api/library/questions/count': json(200, {count: 0}),
|
||||
'GET /api/library/removed-games': json(200, {results: []}), ...routes});
|
||||
...routes});
|
||||
renderWithProviders(<AppHeader isAuthenticated onLogout={() => {}}/>, options);
|
||||
};
|
||||
|
||||
@@ -46,41 +47,59 @@ describe('AppHeader', () => {
|
||||
screen.getByText('ToBePlayed').style.display = 'none';
|
||||
|
||||
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'});
|
||||
|
||||
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'});
|
||||
|
||||
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'});
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
|
||||
openMenu();
|
||||
|
||||
const link = screen.getByTitle('NAV.COMMUNITY');
|
||||
|
||||
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');
|
||||
await screen.findByText('NAV.SETTINGS');
|
||||
expect(screen.queryByText('NAV.MY_PROFILE')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows how many inbox notes are unread', async () => {
|
||||
@@ -92,6 +111,24 @@ describe('AppHeader', () => {
|
||||
expect(link.textContent).toBe('3');
|
||||
});
|
||||
|
||||
it('theBellCountsUnreadNotesUnseenNewsAndOpenQuestions', async () => {
|
||||
loggedInAs({role: 'USER', activeRole: 'USER'}, {
|
||||
'GET /api/inbox': json(200, {results: [], unread: 2}),
|
||||
'GET /api/news': json(200, {results: [{id: 1, seen: false}, {id: 2, seen: true}]}),
|
||||
'GET /api/library/questions/count': json(200, {count: 4}),
|
||||
});
|
||||
|
||||
expect((await screen.findByRole('link', {name: 'NAV.INBOX_UNREAD:7'})).textContent).toBe('7');
|
||||
});
|
||||
|
||||
it('theInboxPageShowsNoBellCount', async () => {
|
||||
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 4})},
|
||||
{entries: ['/inbox']});
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/library/questions/count'), expect.anything()));
|
||||
|
||||
expect(screen.getByRole('link', {name: 'NAV.INBOX'}).textContent).toBe('');
|
||||
});
|
||||
|
||||
it('shows no count without unread notes', async () => {
|
||||
loggedInAs({role: 'USER', activeRole: 'USER'});
|
||||
|
||||
@@ -100,62 +137,6 @@ describe('AppHeader', () => {
|
||||
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 () => {
|
||||
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'},
|
||||
{'GET /api/admin/users/pending-count': json(200, {count: 0})});
|
||||
@@ -176,7 +157,8 @@ describe('AppHeader', () => {
|
||||
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'},
|
||||
{'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();
|
||||
expect((await screen.findByRole('menuitem', {name: /^NAV\.ADMIN ?3$/})).textContent).toContain('3');
|
||||
});
|
||||
|
||||
@@ -9,17 +9,26 @@ import {Switch} from '@/components/ui/switch';
|
||||
import {AccentPicker, CornersPicker} from './StylePickers.jsx';
|
||||
import {equippedSkin} from '../utils/rewards.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import usePersistSettings from '../hooks/usePersistSettings.js';
|
||||
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
||||
import {coverUrl} from '../utils/games.js';
|
||||
import {disableTilt, requestTilt, tiltAllowed, tiltNeedsPermission} from '../utils/parallax.js';
|
||||
import {saveSettings} from '../utils/settings.js';
|
||||
|
||||
export default function AppearanceCard() {
|
||||
const {t} = useTranslation();
|
||||
const {appearance, equipped} = usePreferences();
|
||||
const {appearance, equipped, isDarkMode, setDarkMode} = usePreferences();
|
||||
const persistSettings = usePersistSettings();
|
||||
const saveAppearance = useSaveAppearance();
|
||||
const [tilt, setTilt] = useState(tiltAllowed);
|
||||
const [tiltRefused, setTiltRefused] = useState(false);
|
||||
|
||||
const changeDarkMode = async (next) => {
|
||||
setDarkMode(next);
|
||||
const {ok} = await persistSettings(() => saveSettings({useDarkTheme: next}), {silent: true});
|
||||
if (!ok) setDarkMode(!next);
|
||||
};
|
||||
|
||||
// requestTilt() starts inside the tap, as iOS demands.
|
||||
const changeTilt = (on) => {
|
||||
if (!on) {
|
||||
@@ -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>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="useDarkTheme">{t('SETTINGS.DARK_THEME')}</Label>
|
||||
<Switch id="useDarkTheme" checked={isDarkMode} onCheckedChange={changeDarkMode}/>
|
||||
</div>
|
||||
<AccentPicker id="accent" value={appearance.accent} onChange={(accent) => saveAppearance({accent})}/>
|
||||
<CornersPicker id="corners" value={appearance.corners} onChange={(corners) => saveAppearance({corners})}/>
|
||||
{equippedSkin(equipped) && <p className="text-xs text-muted-foreground">{t('SETTINGS.SKIN_OVERRIDES')}</p>}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -119,6 +119,6 @@ export default function DashboardHint({user, canSync, summary, trend}) {
|
||||
<HintCard key={hint.id} hint={hint} hideForever={hideForever} onHideForeverChange={setHideForever}
|
||||
onClose={(event) => event.detail < 2 && close(hideForever, true)}
|
||||
onAccept={(event) => event.detail < 2 && close(true, true)}
|
||||
className="fixed inset-x-4 bottom-4 z-50 mx-auto max-w-md"/>
|
||||
className="fixed inset-x-4 bottom-4 z-50 mx-auto max-w-md max-md:bottom-20"/>
|
||||
);
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forc
|
||||
</Badge>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function EmptyLibraryCard({reason}) {
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{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'}>
|
||||
<Link to="/import">{t('DASHBOARD.EMPTY.IMPORT')}</Link>
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('EmptyLibraryCard', () => {
|
||||
it('NO_SOURCE offers to link a platform and to import', () => {
|
||||
renderCard('NO_SOURCE');
|
||||
|
||||
expect(screen.getByRole('link', {name: 'Link Steam / PlayStation'}).getAttribute('href')).toBe('/settings');
|
||||
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');
|
||||
});
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function FollowingCard({limit}) {
|
||||
{t('FOLLOW.TITLE')}
|
||||
</h2>
|
||||
<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>
|
||||
</div>
|
||||
<div className="px-5 py-2">
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('FollowingCard', () => {
|
||||
|
||||
expect(screen.queryByRole('link', {name: 'D'})).toBeNull();
|
||||
expect(screen.getAllByRole('button', {name: 'FOLLOW.UNFOLLOW'})).toHaveLength(3);
|
||||
expect(screen.getByRole('link', {name: /FOLLOW.SHOW_ALL/}).getAttribute('href')).toBe('/following');
|
||||
expect(screen.getByRole('link', {name: /FOLLOW.SHOW_ALL/}).getAttribute('href')).toBe('/community?tab=following');
|
||||
});
|
||||
|
||||
it('showsTheEmptyStateWhenNobodyIsFollowed', async () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,11 +5,11 @@ import {Badge} from '@/components/ui/badge';
|
||||
import {cn} from '@/lib/utils';
|
||||
|
||||
export const STATUS_META = {
|
||||
BACKLOG: {icon: Inbox, text: 'text-muted-foreground', badge: 'bg-secondary text-secondary-foreground', bar: 'bg-zinc-400 dark:bg-zinc-500'},
|
||||
PLAYING: {icon: Gamepad2, text: 'text-primary', badge: 'bg-primary/15 text-primary', bar: 'bg-primary'},
|
||||
FINISHED: {icon: CircleCheck, text: 'text-emerald-600 dark:text-emerald-400', badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400', bar: 'bg-emerald-500'},
|
||||
COMPLETED: {icon: Trophy, text: 'text-amber-600 dark:text-amber-400', badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400', bar: 'bg-amber-500'},
|
||||
DROPPED: {icon: Ban, text: 'text-rose-600 dark:text-rose-400', badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400', bar: 'bg-rose-500'},
|
||||
BACKLOG: {icon: Inbox, color: 'var(--color-zinc-400)', text: 'text-muted-foreground', badge: 'bg-secondary text-secondary-foreground', bar: 'bg-zinc-400 dark:bg-zinc-500'},
|
||||
PLAYING: {icon: Gamepad2, color: 'var(--primary)', text: 'text-primary', badge: 'bg-primary/15 text-primary', bar: 'bg-primary'},
|
||||
FINISHED: {icon: CircleCheck, color: 'var(--color-emerald-500)', text: 'text-emerald-600 dark:text-emerald-400', badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400', bar: 'bg-emerald-500'},
|
||||
COMPLETED: {icon: Trophy, color: 'var(--color-amber-500)', text: 'text-amber-600 dark:text-amber-400', badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400', bar: 'bg-amber-500'},
|
||||
DROPPED: {icon: Ban, color: 'var(--color-rose-500)', text: 'text-rose-600 dark:text-rose-400', badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400', bar: 'bg-rose-500'},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {NavLink} from 'react-router-dom';
|
||||
import {useLocation} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Gift, Library, ListOrdered} from 'lucide-react';
|
||||
|
||||
import {cn} from '@/lib/utils';
|
||||
import TabNav from './TabNav.jsx';
|
||||
|
||||
const TABS = [
|
||||
{to: '/library', labelKey: 'LIBRARY.TITLE', icon: Library},
|
||||
@@ -12,28 +12,6 @@ const TABS = [
|
||||
|
||||
export default function LibraryTabs() {
|
||||
const {t} = useTranslation();
|
||||
return (
|
||||
<nav aria-label={t('LIBRARY.TITLE')} className="inline-flex max-w-full rounded-lg bg-muted p-1 text-sm">
|
||||
{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>
|
||||
);
|
||||
const {pathname} = useLocation();
|
||||
return <TabNav label={t('LIBRARY.TITLE')} tabs={TABS.map((tab) => ({...tab, active: pathname === tab.to}))}/>;
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {SlidersHorizontal} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import usePersistSettings from '../hooks/usePersistSettings.js';
|
||||
import useSaveResponse from '../hooks/useSaveResponse.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.
|
||||
const NO_FAVORITE = 'NONE';
|
||||
@@ -19,26 +17,22 @@ const NO_FAVORITE = 'NONE';
|
||||
export default function PreferencesCard() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const {user: profile, updateUser: updateProfile} = useCurrentUserContext();
|
||||
const {isDarkMode, setDarkMode, setLanguage} = usePreferences();
|
||||
const {setLanguage} = usePreferences();
|
||||
const persistSettings = usePersistSettings();
|
||||
const runSave = useSaveResponse();
|
||||
|
||||
const handleDarkThemeToggle = async () => {
|
||||
const next = !isDarkMode;
|
||||
setDarkMode(next); // apply on the fly
|
||||
await persistSettings(() => saveSettings({useDarkTheme: 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 platform = value === NO_FAVORITE ? null : value;
|
||||
if (await runSave(() => saveFavoritePlatform(platform))) {
|
||||
updateProfile((p) => ({...p, settings: {...p.settings, favoritePlatform: platform}}));
|
||||
toast.success(t('SETTINGS.SAVED'));
|
||||
}
|
||||
const previous = profile?.settings?.favoritePlatform ?? null;
|
||||
const setPlatform = (next) => updateProfile((p) => ({...p, settings: {...p.settings, favoritePlatform: next}}));
|
||||
setPlatform(platform);
|
||||
if (!await runSave(() => saveFavoritePlatform(platform))) setPlatform(previous);
|
||||
};
|
||||
|
||||
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>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="useDarkTheme">{t('SETTINGS.DARK_THEME')}</Label>
|
||||
<Switch id="useDarkTheme" checked={isDarkMode} onCheckedChange={handleDarkThemeToggle}/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="language">{t('SETTINGS.LANGUAGE')}</Label>
|
||||
<Select value={i18n.language} onValueChange={handleLanguageChange}>
|
||||
|
||||
@@ -3,7 +3,6 @@ import {useTranslation} from 'react-i18next';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {Copy, Gamepad2, Loader2, Save} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
@@ -14,6 +13,8 @@ import FormField from './FormField.jsx';
|
||||
import ProfileStyleSettings from './ProfileStyleSettings.jsx';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
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 {normalizeProfileStyle} from '../utils/profileStyle.js';
|
||||
import {saveOwnerLists, saveSettings} from '../utils/settings.js';
|
||||
@@ -25,32 +26,37 @@ export default function ProfileCard({displayName}) {
|
||||
const {t} = useTranslation();
|
||||
const {user: profile, updateUser: updateProfile} = useCurrentUserContext();
|
||||
const persistSettings = usePersistSettings();
|
||||
const copyProfileLink = useCopyLink();
|
||||
const saveStyle = useSaveProfileStyle();
|
||||
const [savingProfileStyle, setSavingProfileStyle] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: {errors, isSubmitting},
|
||||
reset,
|
||||
formState: {errors, isSubmitting, isDirty},
|
||||
} = useForm({
|
||||
resolver: zodResolver(settingsSchema),
|
||||
defaultValues: {displayName},
|
||||
});
|
||||
|
||||
const handlePublicProfileToggle = async () => {
|
||||
const next = !profile?.settings?.publicProfile;
|
||||
const result = await persistSettings(() => saveSettings({publicProfile: next}));
|
||||
if (result.ok) {
|
||||
updateProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}}));
|
||||
}
|
||||
useUnsavedChanges(isDirty);
|
||||
|
||||
const toggleSetting = async (key, next, save) => {
|
||||
const show = (value) => updateProfile((p) => ({...p, settings: {...p.settings, [key]: value}}));
|
||||
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 result = await persistSettings(() => saveOwnerLists(next));
|
||||
if (result.ok) {
|
||||
updateProfile((p) => ({...p, settings: {...p.settings, showInOwnerLists: next}}));
|
||||
}
|
||||
return toggleSetting('showInOwnerLists', next, () => saveOwnerLists(next));
|
||||
};
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
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) => {
|
||||
// Without a name the profile has no link, so removing the name also makes it private.
|
||||
const publicProfile = !!profile?.settings?.publicProfile && !!values.displayName;
|
||||
const result = await persistSettings(() => saveSettings({displayName: values.displayName, publicProfile}));
|
||||
if (result.ok) {
|
||||
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">
|
||||
<ProfileStyleSettings style={normalizeProfileStyle(profile?.settings?.profileStyle)}
|
||||
disabled={!publicProfile || savingProfileStyle}
|
||||
privateProfile={!publicProfile} disabled={!publicProfile || savingProfileStyle}
|
||||
onChange={handleProfileStyleChange}/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -33,15 +33,17 @@ function ArtRow({id, label, appid, onRemove, disabled}) {
|
||||
* page itself.
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
export default function ProfileStyleSettings({style, disabled, onChange}) {
|
||||
export default function ProfileStyleSettings({style, disabled, privateProfile, onChange}) {
|
||||
const {t} = useTranslation();
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-medium">{t('SETTINGS.PROFILE_STYLE')}</span>
|
||||
<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>
|
||||
<AccentPicker id="profile-accent" value={style.accent} disabled={disabled}
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -3,18 +3,26 @@ import {useTranslation} from 'react-i18next';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {cn} from '@/lib/utils';
|
||||
import FitText from './FitText.jsx';
|
||||
import GameCover from './GameCover.jsx';
|
||||
import RatingStars from './RatingStars.jsx';
|
||||
import {formatDate} from '../utils/format.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.
|
||||
*
|
||||
* @param item a result of `GET /api/library/questions`
|
||||
* @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
|
||||
*/
|
||||
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) : '',
|
||||
});
|
||||
const askId = `question-${appid}`;
|
||||
const points = pointsText(question, expectedPoints, t);
|
||||
|
||||
return (
|
||||
<Card className="flex-row items-start gap-3 p-4">
|
||||
@@ -39,9 +48,9 @@ export default function QuestionCard({item, busy, expectedPoints, onAnswer}) {
|
||||
</Link>
|
||||
<p id={askId} className="text-base font-semibold">{t(`QUESTIONS.${question}.ASK`)}</p>
|
||||
<p className="text-sm text-muted-foreground">{reason}</p>
|
||||
{question === 'FINISHED' && expectedPoints > 0 && (
|
||||
<p className="text-sm font-medium text-primary">{t('QUESTIONS.FINISHED.POINTS', {count: expectedPoints})}</p>
|
||||
)}
|
||||
<p className={cn('text-sm font-medium', points ? 'text-primary' : 'text-muted-foreground')}>
|
||||
{points ?? t('QUESTIONS.NO_POINTS')}
|
||||
</p>
|
||||
{question === 'RATE' ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<RatingStars value={0} labelledBy={askId} disabled={busy}
|
||||
|
||||
@@ -48,4 +48,30 @@ describe('QuestionCard', () => {
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+14
-16
@@ -3,7 +3,7 @@ import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import {toast} from 'sonner';
|
||||
import RemovedGamesSection from './RemovedGamesSection.jsx';
|
||||
import RemovedGamesList from './RemovedGamesList.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
@@ -14,7 +14,7 @@ const LIST = 'GET /api/library/removed-games';
|
||||
|
||||
const renderSection = (routes) => {
|
||||
fetchRoutes(routes);
|
||||
return render(<MemoryRouter><RemovedGamesSection/></MemoryRouter>);
|
||||
return render(<MemoryRouter><RemovedGamesList/></MemoryRouter>);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
@@ -22,14 +22,14 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('RemovedGamesSection', () => {
|
||||
it('isClosedByDefaultAndListsTheGames', async () => {
|
||||
const {container} = renderSection({[LIST]: json(200, {results: [removed(-1, 'Zelda'), removed(-2, 'Mario')]})});
|
||||
describe('RemovedGamesList', () => {
|
||||
it('listsTheGamesWithTheHint', async () => {
|
||||
renderSection({[LIST]: json(200, {results: [removed(-1, 'Zelda'), removed(-2, 'Mario')]})});
|
||||
|
||||
expect(await screen.findByText('REMOVED_GAMES.TITLE_COUNT:2')).toBeTruthy();
|
||||
expect(container.querySelector('details').open).toBe(false);
|
||||
expect(screen.getByText('Zelda')).toBeTruthy();
|
||||
expect(await screen.findByText('Zelda')).toBeTruthy();
|
||||
expect(screen.getByText('Mario')).toBeTruthy();
|
||||
expect(screen.getByText('REMOVED_GAMES.HINT')).toBeTruthy();
|
||||
expect(screen.queryByText('LIBRARY.NO_REMOVED')).toBeNull();
|
||||
});
|
||||
|
||||
it('showsNoLinkToTheGamePage', async () => {
|
||||
@@ -66,17 +66,15 @@ describe('RemovedGamesSection', () => {
|
||||
expect(screen.getByText('Zelda')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rendersNothingForAnEmptyList', async () => {
|
||||
const {container} = renderSection({[LIST]: json(200, {results: []})});
|
||||
it('saysSoForAnEmptyList', async () => {
|
||||
renderSection({[LIST]: json(200, {results: []})});
|
||||
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||
expect(container.innerHTML).toBe('');
|
||||
expect(await screen.findByText('LIBRARY.NO_REMOVED')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rendersNothingWhenTheListFails', async () => {
|
||||
const {container} = renderSection({[LIST]: json(500, {})});
|
||||
it('saysSoWhenTheListFails', async () => {
|
||||
renderSection({[LIST]: json(500, {})});
|
||||
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||
expect(container.innerHTML).toBe('');
|
||||
expect(await screen.findByText('LIBRARY.NO_REMOVED')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,22 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {StatCount} from './StatDrill.jsx';
|
||||
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 {cn} from '@/lib/utils';
|
||||
|
||||
const WIDTH = 300;
|
||||
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.
|
||||
*
|
||||
@@ -16,8 +26,18 @@ const HEIGHT = 60;
|
||||
export default function PileTrend({trend, chart = 'LINE'}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
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 (
|
||||
<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">
|
||||
<span>{t('STATS.TREND.THIS_MONTH')}</span>{' '}
|
||||
<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>
|
||||
{days.length > 1 ? (
|
||||
<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')}
|
||||
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"
|
||||
role="img" aria-label={t('STATS.TREND.TITLE')}>
|
||||
{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"
|
||||
strokeWidth={2} vectorEffect="non-scaling-stroke" className="stroke-rose-500"/>
|
||||
strokeWidth={2} vectorEffect="non-scaling-stroke" className={direction.stroke}/>
|
||||
)}
|
||||
</svg>
|
||||
</StatCount>
|
||||
</div>
|
||||
<table className="sr-only">
|
||||
<tbody>
|
||||
{days.map((d) => (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 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 colorOf `(slice) => color`: the colors of the slices with games, instead of the palette
|
||||
* @param center shown in the hole of a donut
|
||||
* @param note a line under the legend
|
||||
*
|
||||
* 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}) {
|
||||
const colors = sliceColors(slices);
|
||||
export default function SliceChart({slices, donut = false, drillKey, label = (name) => name, linkOf, hrefOf, valueText, colorOf, center, note}) {
|
||||
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);
|
||||
return (
|
||||
<div className="@container space-y-2">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
import {STATUS_META} from '../GameStatusBadge.jsx';
|
||||
import SliceChart from './SliceChart.jsx';
|
||||
import {StatCount} from './StatDrill.jsx';
|
||||
import {libraryLink} from '../../utils/statGames.js';
|
||||
@@ -27,6 +28,7 @@ export default function StatusDonut({byStatus, totalParams = {key: 'TOTAL'}}) {
|
||||
);
|
||||
return (
|
||||
<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}/>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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)};
|
||||
}
|
||||
@@ -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,11 +6,14 @@ const BACKEND_ERROR_KEY = {
|
||||
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() {
|
||||
const {t} = useTranslation();
|
||||
|
||||
return useCallback(async (send) => {
|
||||
return useCallback(async (send, {silent = false} = {}) => {
|
||||
try {
|
||||
const response = await send();
|
||||
|
||||
@@ -23,7 +26,7 @@ export default function usePersistSettings() {
|
||||
return {ok: false};
|
||||
}
|
||||
|
||||
toast.success(t('SETTINGS.SAVED'));
|
||||
if (!silent) toast.success(t('SETTINGS.SAVED'));
|
||||
return {ok: true};
|
||||
} catch (err) {
|
||||
console.error('Settings save error:', err);
|
||||
|
||||
@@ -105,3 +105,34 @@ describe('people texts', () => {
|
||||
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([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,6 @@ import PileOfShameCard from '../components/PileOfShameCard.jsx';
|
||||
import SteamGameCount from '../components/SteamGameCount';
|
||||
import UpNextCard from '../components/UpNextCard.jsx';
|
||||
import SyncChangesBanner from '../components/SyncChangesBanner.jsx';
|
||||
import WhatsNewDialog from '../components/WhatsNewDialog.jsx';
|
||||
import useDashboardData from '../hooks/useDashboardData.js';
|
||||
import useLibrarySync from '../hooks/useLibrarySync.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}
|
||||
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. */}
|
||||
<DashboardHint user={userData} canSync={canSync} summary={summaryFailed ? null : summary ?? undefined}
|
||||
trend={stats.failed ? null : stats.stats ? stats.stats.trend ?? null : undefined}/>
|
||||
|
||||
@@ -19,7 +19,6 @@ const empty = {content: [], total: 0, byStatus: {}};
|
||||
const quietRoutes = {
|
||||
'GET /api/library/summary': json(200, empty),
|
||||
'GET /api/hints': json(200, {results: []}),
|
||||
'GET /api/news': json(200, {results: []}),
|
||||
'GET /api/library/sync-changes': json(204, {}),
|
||||
};
|
||||
|
||||
@@ -240,7 +239,7 @@ describe('Dashboard', () => {
|
||||
renderDashboard();
|
||||
|
||||
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.queryByText('DASHBOARD.WELCOME_BACK')).toBeNull();
|
||||
expect(screen.getByText('DASHBOARD.WELCOME')).toBeTruthy();
|
||||
|
||||
@@ -29,7 +29,6 @@ describe('Dashboard sync on a visit', () => {
|
||||
fetchRoutes({
|
||||
'GET /api/library/summary': json(200, empty),
|
||||
'GET /api/hints': json(200, {results: []}),
|
||||
'GET /api/news': json(200, {results: []}),
|
||||
'GET /api/library/sync-changes': () => syncChanges,
|
||||
'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000'}),
|
||||
'GET /api/library/dashboard-games': json(200, {top: [], playing: []}),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -7,7 +7,7 @@ import useForceSync from '../hooks/useForceSync.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import {libraryQuery, parseSources, parseStatus} from '../utils/backlog.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 {Button} from '@/components/ui/button';
|
||||
@@ -28,6 +28,7 @@ import useSetStatus from '../hooks/useSetStatus.js';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import HintText from '../components/HintText.jsx';
|
||||
import PlatformIcons from '../components/PlatformIcons.jsx';
|
||||
import RemovedGamesList from '../components/RemovedGamesList.jsx';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
import {coverEffect} from '../utils/rewards.js';
|
||||
@@ -136,14 +137,17 @@ export default function GameLibrary() {
|
||||
const status = parseStatus(searchParams.get('status'));
|
||||
const q = searchParams.get('q') ?? '';
|
||||
const shortOnly = searchParams.get('maxMinutes') === String(SHORT_GAME_MINUTES);
|
||||
const hiddenOnly = searchParams.get('hidden') === 'true';
|
||||
// The hidden list stands alone, also for a hand-made URL: its status and short filters are ignored.
|
||||
const listStatus = hiddenOnly ? null : status;
|
||||
const listShortOnly = !hiddenOnly && shortOnly;
|
||||
// The removed and the hidden list stand alone, also for a hand-made URL: their status, short and source filters
|
||||
// are ignored. Removed wins over hidden.
|
||||
const removedOnly = searchParams.get('removed') === 'true';
|
||||
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 listSources = hiddenOnly ? [] : sources;
|
||||
const listSources = standalone ? [] : sources;
|
||||
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 [adding, setAdding] = useState(false);
|
||||
// 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 cached = cachedFor(query);
|
||||
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 nextSignal = useAbortPrevious();
|
||||
const load = useCallback(async () => {
|
||||
const signal = nextSignal();
|
||||
if (removedOnly) {
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
setIsLoading(!cachedFor(query));
|
||||
setError(null);
|
||||
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()};
|
||||
setData(json);
|
||||
}, [nextSignal, query]);
|
||||
}, [nextSignal, query, removedOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -233,16 +242,20 @@ export default function GameLibrary() {
|
||||
}, [search, q, updateParams]);
|
||||
|
||||
const onStatusChange = (value) => {
|
||||
updateParams({status: value, hidden: null, page: 0});
|
||||
updateParams({status: value, hidden: null, removed: null, page: 0});
|
||||
};
|
||||
|
||||
const toggleSource = (source) => {
|
||||
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 = () => {
|
||||
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) => {
|
||||
@@ -268,6 +281,7 @@ export default function GameLibrary() {
|
||||
|
||||
const syncing = syncBusy || sync.isRunning;
|
||||
const ready = !isLoading && !error;
|
||||
const listing = ready && !removedOnly;
|
||||
// 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));
|
||||
|
||||
@@ -299,6 +313,9 @@ export default function GameLibrary() {
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/import"><Upload/>{t('IMPORT.OPEN')}</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/search"><Search/>{t('GAME_SEARCH.TITLE')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
@@ -335,8 +352,8 @@ export default function GameLibrary() {
|
||||
<Button
|
||||
key={s ?? 'ALL'}
|
||||
size="sm"
|
||||
variant={listStatus === s && !hiddenOnly ? 'default' : 'outline'}
|
||||
aria-pressed={listStatus === s && !hiddenOnly}
|
||||
variant={listStatus === s && !standalone ? 'default' : 'outline'}
|
||||
aria-pressed={listStatus === s && !standalone}
|
||||
className="h-7 rounded-full px-3 text-xs pointer-coarse:h-11 pointer-coarse:px-4"
|
||||
onClick={() => onStatusChange(s)}
|
||||
>
|
||||
@@ -348,7 +365,7 @@ export default function GameLibrary() {
|
||||
variant={listShortOnly ? 'default' : 'outline'}
|
||||
aria-pressed={listShortOnly}
|
||||
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')}
|
||||
</Button>
|
||||
@@ -361,6 +378,15 @@ export default function GameLibrary() {
|
||||
>
|
||||
<EyeOff className="size-3"/>{t('LIBRARY.FILTER_HIDDEN')}
|
||||
</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>
|
||||
{sourceChips.length >= 2 && (
|
||||
<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>}
|
||||
|
||||
{ready && data.content.length === 0 && (
|
||||
{ready && removedOnly && <RemovedGamesList/>}
|
||||
|
||||
{listing && data.content.length === 0 && (
|
||||
<Card className="items-center py-16 text-center text-muted-foreground">
|
||||
{filtered ? <SearchX className="size-10"/> : <Library className="size-10"/>}
|
||||
<p className="max-w-sm text-sm">
|
||||
@@ -415,10 +443,15 @@ export default function GameLibrary() {
|
||||
: sync.isRunning || sync.queued ? t('LIBRARY.SYNCING_FIRST_TIME')
|
||||
: privateProfile ? <HintText text={t('LIBRARY.EMPTY_PRIVATE')}/> : t('LIBRARY.EMPTY')}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{ready && data.content.length > 0 && (
|
||||
{listing && data.content.length > 0 && (
|
||||
<Card className="gap-0 py-0">
|
||||
<ul className="divide-y">
|
||||
{data.content.map((g) => (
|
||||
@@ -428,7 +461,7 @@ export default function GameLibrary() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{ready && data.totalPages > 1 && (
|
||||
{listing && data.totalPages > 1 && (
|
||||
<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')}`}/>
|
||||
)}
|
||||
|
||||
@@ -633,3 +633,63 @@ describe('GameLibrary session', () => {
|
||||
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
@@ -1,24 +1,30 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
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 {cn} from '@/lib/utils';
|
||||
import NewsEntry from '../components/NewsEntry.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {formatDate} from '../utils/format.js';
|
||||
import {profileGameHref, profileHref} from '../utils/people.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 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() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const [notes, setNotes] = useState([]);
|
||||
const [news, setNews] = useState([]);
|
||||
const [questions, setQuestions] = useState(0);
|
||||
const [status, setStatus] = useState('loading');
|
||||
const [open, setOpen] = useState(null);
|
||||
// note id -> {status: 'loading' | 'error' | 'ready', results}
|
||||
@@ -27,16 +33,19 @@ export default function InboxPage() {
|
||||
|
||||
useEffect(() => {
|
||||
(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 (!ok) {
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
setNotes(data.results);
|
||||
setNews(unseen);
|
||||
setQuestions(count.ok ? count.data.count : 0);
|
||||
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 (unseen.length > 0) markNewsSeen().catch(() => undefined);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
@@ -136,9 +145,27 @@ export default function InboxPage() {
|
||||
<CardTitle>{t('INBOX.TITLE')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="divide-y">
|
||||
{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 === '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 && (
|
||||
<ul className="divide-y">
|
||||
{notes.map((note) => (
|
||||
@@ -149,7 +176,7 @@ export default function InboxPage() {
|
||||
<p className={cn('break-words text-sm', !note.read && 'font-semibold')}>
|
||||
{noteLine(note)}
|
||||
{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>
|
||||
{open === note.id && <div className="space-y-2 pt-1">{detailBlock(note)}</div>}
|
||||
@@ -157,6 +184,7 @@ export default function InboxPage() {
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
|
||||
@@ -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,
|
||||
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');
|
||||
|
||||
afterEach(() => {
|
||||
@@ -18,7 +19,7 @@ afterEach(() => {
|
||||
|
||||
describe('InboxPage', () => {
|
||||
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)});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
|
||||
@@ -27,7 +28,7 @@ describe('InboxPage', () => {
|
||||
});
|
||||
|
||||
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)});
|
||||
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) => {
|
||||
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)});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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/>);
|
||||
|
||||
expect(await screen.findByText('INBOX.VISIBILITY')).toBeTruthy();
|
||||
@@ -53,7 +54,7 @@ describe('InboxPage', () => {
|
||||
});
|
||||
|
||||
it('saysSoWhenThereAreNoNotesAndMarksNothing', async () => {
|
||||
fetchRoutes({'GET /api/inbox': json(200, {results: [], unread: 0})});
|
||||
fetchRoutes({...quiet, 'GET /api/inbox': json(200, {results: [], unread: 0})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
|
||||
expect(await screen.findByText('INBOX.EMPTY')).toBeTruthy();
|
||||
@@ -61,7 +62,7 @@ describe('InboxPage', () => {
|
||||
});
|
||||
|
||||
it('showsAnErrorAndMarksNothingWhenTheLoadFails', async () => {
|
||||
fetchRoutes({'GET /api/inbox': json(500, {})});
|
||||
fetchRoutes({...quiet, 'GET /api/inbox': json(500, {})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
|
||||
expect(await screen.findByText('INBOX.LOAD_ERROR')).toBeTruthy();
|
||||
@@ -70,7 +71,7 @@ describe('InboxPage', () => {
|
||||
|
||||
describe('links', () => {
|
||||
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(2, {newGames: 2, displayName: 'Gone', profileName: null})], unread: 0})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
@@ -81,7 +82,7 @@ describe('InboxPage', () => {
|
||||
});
|
||||
|
||||
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})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
|
||||
@@ -92,7 +93,7 @@ describe('InboxPage', () => {
|
||||
});
|
||||
|
||||
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})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
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 detailCalls = () => fetch.mock.calls.filter(([url]) => String(url).includes('/details'));
|
||||
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})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
};
|
||||
@@ -152,7 +153,7 @@ describe('InboxPage', () => {
|
||||
});
|
||||
|
||||
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/>);
|
||||
|
||||
expect(await screen.findByText('INBOX.DIGEST')).toBeTruthy();
|
||||
@@ -169,7 +170,7 @@ describe('InboxPage', () => {
|
||||
});
|
||||
|
||||
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, {})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
|
||||
@@ -179,7 +180,7 @@ describe('InboxPage', () => {
|
||||
});
|
||||
|
||||
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, {})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
|
||||
@@ -199,4 +200,50 @@ describe('InboxPage', () => {
|
||||
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
@@ -1,123 +1,18 @@
|
||||
import {useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {
|
||||
Archive, ArrowRight, Blocks, Clock, Gamepad2, Library, Link2, Sparkles, Trophy, Users,
|
||||
} from 'lucide-react';
|
||||
import {ArrowRight} from 'lucide-react';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import FitText from '../components/FitText.jsx';
|
||||
import CommunityStats from '../components/CommunityStats.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 {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() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const {data, isLoading} = usePublicStats();
|
||||
const {t} = useTranslation();
|
||||
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="grow">
|
||||
<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"/>
|
||||
@@ -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">
|
||||
<h2 className="text-center text-2xl font-bold tracking-tight">{t('LANDING.STATS_TITLE')}</h2>
|
||||
|
||||
<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}/>
|
||||
<CommunityStats/>
|
||||
</section>
|
||||
</div>
|
||||
{people && (
|
||||
<PeopleDialog title={people.title} onClose={() => setPeople(null)}
|
||||
load={(page) => fetchUsers({key: people.key, page})}/>
|
||||
)}
|
||||
</StatDrillProvider>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {formatDate, formatDay} from '../utils/format.js';
|
||||
import {buyItem, equipItem, LOOKS, SLOTS} from '../utils/rewards.js';
|
||||
|
||||
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',
|
||||
'STREAK', 'STARTER', 'UNLOCK'];
|
||||
|
||||
@@ -86,9 +87,8 @@ export default function PointsPage() {
|
||||
</div>
|
||||
|
||||
<Card className="gap-2 p-5">
|
||||
<h2 className="font-semibold">{t('POINTS.HOW_TO')}</h2>
|
||||
<ul className="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 className="space-y-1 text-sm">
|
||||
{SUMMARY.map((kind) => <li key={kind}>{t(`POINTS.SUMMARY_${kind}`)}</li>)}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
@@ -131,6 +131,13 @@ export default function PointsPage() {
|
||||
))}
|
||||
</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">
|
||||
<h2 className="text-xl font-semibold">{t('POINTS.HISTORY')}</h2>
|
||||
{wallet.history.length === 0 ? (
|
||||
|
||||
@@ -61,6 +61,23 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
await renderPage();
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Link, useParams, useSearchParams} from 'react-router-dom';
|
||||
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 {Card} from '@/components/ui/card';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
@@ -14,12 +15,14 @@ import ComingBadge from '../components/ComingBadge.jsx';
|
||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||
import ProfileGameCard from '../components/ProfileGameCard.jsx';
|
||||
import {formatDay} from '../utils/format.js';
|
||||
import {profileHref} from '../utils/people.js';
|
||||
import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, equippedSkin, lookClass} from '../utils/rewards.js';
|
||||
import PointsBadge from '../components/PointsBadge.jsx';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import useSession from '../hooks/useSession.js';
|
||||
import {normalizeProfileStyle} from '../utils/profileStyle.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 useSaveResponse from '../hooks/useSaveResponse.js';
|
||||
import useStatCharts from '../hooks/useStatCharts.js';
|
||||
@@ -41,6 +44,7 @@ const FINISHED_GAMES = {S: 6, M: 12};
|
||||
*/
|
||||
export default function PublicProfilePage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const copyLink = useCopyLink();
|
||||
const {name} = useParams();
|
||||
const [params] = useSearchParams();
|
||||
// Only a game key goes into the request address.
|
||||
@@ -110,12 +114,16 @@ export default function PublicProfilePage() {
|
||||
}, [name, reloads]);
|
||||
|
||||
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 (
|
||||
<PageContainer className="p-4 md:p-8">
|
||||
<Card className="items-center py-16 text-center text-muted-foreground">
|
||||
<UserRoundX className="size-10"/>
|
||||
<p className="max-w-sm text-sm">{t(error)}</p>
|
||||
<Link to="/" className="text-sm font-medium text-primary hover:underline">{t('PROFILE.TO_START')}</Link>
|
||||
<p className="max-w-sm text-sm">{t(ownPrivate ? 'PROFILE.OWN_PRIVATE' : error)}</p>
|
||||
{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>
|
||||
</PageContainer>
|
||||
);
|
||||
@@ -189,9 +197,13 @@ export default function PublicProfilePage() {
|
||||
</div>
|
||||
{viewer && !owner && <FollowButton displayName={profile.displayName}/>}
|
||||
{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')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
{game && <ProfileGameCard ticket={profile.ticket} appid={game}/>}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
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 {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||
@@ -594,4 +595,55 @@ describe('PublicProfilePage', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import {toast} from 'sonner';
|
||||
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import QuestionCard from '../components/QuestionCard.jsx';
|
||||
import RemovedGamesSection from '../components/RemovedGamesSection.jsx';
|
||||
import usePointsCheck from '../hooks/usePointsCheck.js';
|
||||
import {answerQuestion, fetchQuestions} from '../utils/questions.js';
|
||||
import {loadExpected} from '../utils/rewards.js';
|
||||
@@ -76,6 +75,7 @@ export default function QuestionsPage() {
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{status === 'ready' && items.length > 0 ? t('QUESTIONS.TITLE_COUNT', {count: items.length}) : t('QUESTIONS.TITLE')}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">{t('QUESTIONS.INTRO')}</p>
|
||||
{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 === '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]}
|
||||
onAnswer={(choice) => answer(item, choice)}/>
|
||||
))}
|
||||
<RemovedGamesSection/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -41,14 +41,12 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('QuestionsPage', () => {
|
||||
it('showsTheRemovedGamesSectionAlsoWithoutQuestions', async () => {
|
||||
renderPage({
|
||||
'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'}]}),
|
||||
});
|
||||
it('showsTheIntroAndNoRemovedGamesSection', async () => {
|
||||
renderPage({'GET /api/library/questions': json(200, {results: []})});
|
||||
|
||||
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 () => {
|
||||
|
||||
+37
-12
@@ -1,21 +1,33 @@
|
||||
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 {Cable, Info, Palette, ShieldCheck, UserRound} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import AccountInfoCard from '../components/AccountInfoCard.jsx';
|
||||
import AppearanceCard from '../components/AppearanceCard.jsx';
|
||||
import BuildVersionCard from '../components/BuildVersionCard.jsx';
|
||||
import ChangePasswordCard from '../components/ChangePasswordCard.jsx';
|
||||
import DeleteAccountCard from '../components/DeleteAccountCard.jsx';
|
||||
import FollowsCard from '../components/FollowsCard.jsx';
|
||||
import PreferencesCard from '../components/PreferencesCard.jsx';
|
||||
import ProfileCard from '../components/ProfileCard.jsx';
|
||||
import PsnAccountCard from '../components/PsnAccountCard.jsx';
|
||||
import SteamAccountCard from '../components/SteamAccountCard.jsx';
|
||||
import TabNav from '../components/TabNav.jsx';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
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.
|
||||
const STEAM_LINK_TOASTS = {
|
||||
@@ -33,6 +45,8 @@ export default function SettingsPage() {
|
||||
// Read once: removing the parameter below must not load the profile again.
|
||||
const [steamOutcome] = useState(() => searchParams.get('steam'));
|
||||
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.
|
||||
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">
|
||||
<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/>
|
||||
|
||||
<PreferencesCard/>
|
||||
<ChangePasswordCard/>
|
||||
<DeleteAccountCard email={profile?.email}/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<AppearanceCard/>
|
||||
{tab === 'appearance' && <AppearanceCard/>}
|
||||
|
||||
{tab === 'connections' && (
|
||||
<>
|
||||
<SteamAccountCard steamId={profile?.steamId} verified={!!profile?.steamIdVerified}
|
||||
onUnlinked={() => updateProfile((p) => ({...p, steamId: null, steamIdVerified: false}))}/>
|
||||
|
||||
<PsnAccountCard/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'profile' && (
|
||||
<>
|
||||
<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/>
|
||||
|
||||
<BuildVersionCard/>
|
||||
|
||||
<ChangePasswordCard/>
|
||||
|
||||
<DeleteAccountCard email={profile?.email}/>
|
||||
{tab === 'about' && <BuildVersionCard/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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'); }};
|
||||
|
||||
function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings', unlinkAnswer = noBody} = {}) {
|
||||
function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings?tab=connections', unlinkAnswer = noBody} = {}) {
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, me),
|
||||
'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 () => {
|
||||
renderPage(profile());
|
||||
renderPage(profile(), {entry: '/settings?tab=appearance'});
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.DARK_THEME'}));
|
||||
|
||||
|
||||
+229
-22
@@ -5,6 +5,7 @@ import SettingsPage from './SettingsPage.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||
import {toast} from 'sonner';
|
||||
import {UnsavedChangesGuard} from '../hooks/useUnsavedChanges.js';
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
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}),
|
||||
});
|
||||
|
||||
function renderPage(me) {
|
||||
function renderPage(me, tab = 'account') {
|
||||
fetchRoutes(authRoutes(() => me));
|
||||
renderWithProviders(<SettingsPage/>);
|
||||
renderWithProviders(<SettingsPage/>, {entries: [`/settings?tab=${tab}`]});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,7 +70,7 @@ describe('SettingsPage public profile', () => {
|
||||
});
|
||||
|
||||
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'});
|
||||
|
||||
@@ -78,7 +79,7 @@ describe('SettingsPage public profile', () => {
|
||||
});
|
||||
|
||||
it('savesTheOwnerListSwitchThroughItsOwnEndpoint', async () => {
|
||||
renderPage(profile());
|
||||
renderPage(profile(), 'profile');
|
||||
|
||||
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 () => {
|
||||
renderPage(profile());
|
||||
renderPage(profile(), 'appearance');
|
||||
|
||||
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 () => {
|
||||
const auth = fakeAuth(profile());
|
||||
renderWithProviders(<SettingsPage/>);
|
||||
renderWithProviders(<SettingsPage/>, {entries: ['/settings?tab=profile']});
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}));
|
||||
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 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());
|
||||
});
|
||||
|
||||
@@ -119,7 +121,7 @@ describe('SettingsPage public profile', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
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 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'))
|
||||
.map(([, init]) => JSON.parse(init.body));
|
||||
|
||||
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');
|
||||
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 () => {
|
||||
renderPage(profile());
|
||||
renderPage(profile(), 'profile');
|
||||
|
||||
expect(await screen.findByText('SETTINGS.PROFILE_TILES_HINT')).toBeTruthy();
|
||||
});
|
||||
|
||||
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'}));
|
||||
|
||||
@@ -162,7 +164,7 @@ describe('SettingsPage public profile', () => {
|
||||
});
|
||||
|
||||
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');
|
||||
expect(screen.queryByText('SETTINGS.PROFILE_TILES_HINT')).toBeNull();
|
||||
@@ -170,7 +172,7 @@ describe('SettingsPage public profile', () => {
|
||||
});
|
||||
|
||||
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'});
|
||||
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 () => {
|
||||
renderPage(profile());
|
||||
renderPage(profile(), 'profile');
|
||||
|
||||
const name = await screen.findByLabelText('SETTINGS.DISPLAY_NAME');
|
||||
fireEvent.change(name, {target: {value: ''}});
|
||||
@@ -203,7 +205,7 @@ describe('SettingsPage appearance', () => {
|
||||
.map(([, init]) => JSON.parse(init.body));
|
||||
|
||||
it('saves a picked accent and corner style and marks them as picked', async () => {
|
||||
renderPage(profile());
|
||||
renderPage(profile(), 'appearance');
|
||||
|
||||
await screen.findByText('SETTINGS.APPEARANCE');
|
||||
fireEvent.click(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'}));
|
||||
@@ -216,7 +218,7 @@ describe('SettingsPage appearance', () => {
|
||||
});
|
||||
|
||||
it('saves the reduce animations switch', async () => {
|
||||
renderPage(profile());
|
||||
renderPage(profile(), 'appearance');
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.REDUCE_MOTION'}));
|
||||
|
||||
@@ -226,7 +228,7 @@ describe('SettingsPage appearance', () => {
|
||||
|
||||
it('removes the background', async () => {
|
||||
localStorage.setItem('tbp_appearance', JSON.stringify({backgroundAppid: 620}));
|
||||
renderPage(profile());
|
||||
renderPage(profile(), 'appearance');
|
||||
|
||||
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 () => {
|
||||
localStorage.setItem('tbp_appearance', JSON.stringify({bannerAppid: 620}));
|
||||
renderPage(profile());
|
||||
renderPage(profile(), 'appearance');
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.BANNER_AUTO'}));
|
||||
await waitFor(() => expect(appearancePuts()).toHaveLength(1));
|
||||
@@ -321,13 +323,218 @@ describe('SettingsPage load order', () => {
|
||||
...authRoutes(() => 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 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');
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -160,15 +160,16 @@ describe('StatsPage', () => {
|
||||
const overview = () => screen.getByText('STATS.OVERVIEW.TITLE').closest('[data-slot="card"]');
|
||||
const paths = () => overview().querySelectorAll('path.stroke-card');
|
||||
|
||||
it('drawsOneColoredSlicePerStatus', async () => {
|
||||
it('drawsOneSlicePerStatusInTheStatusColor', async () => {
|
||||
answerStats(json(200, stats()));
|
||||
renderPage();
|
||||
|
||||
await screen.findByText('STATS.OVERVIEW.TITLE');
|
||||
|
||||
expect(paths()).toHaveLength(5);
|
||||
expect(paths()[0].getAttribute('style')).toContain('var(--chart-1)');
|
||||
expect(paths()[4].getAttribute('style')).toContain('var(--chart-5)');
|
||||
expect(paths()[0].getAttribute('style')).toContain('var(--color-zinc-400)');
|
||||
expect(paths()[1].getAttribute('style')).toContain('var(--primary)');
|
||||
expect(paths()[4].getAttribute('style')).toContain('var(--color-rose-500)');
|
||||
});
|
||||
|
||||
it('putsTheTotalInTheHoleAsALink', async () => {
|
||||
|
||||
@@ -15,7 +15,7 @@ const FEATURES = [
|
||||
{key: 'LIBRARY', to: '/library', icon: Library},
|
||||
{key: 'STATUS', to: '/library', icon: ListChecks},
|
||||
{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: 'DASHBOARD', to: null, icon: LayoutGrid},
|
||||
];
|
||||
@@ -41,7 +41,7 @@ export default function WelcomePage() {
|
||||
}, []);
|
||||
|
||||
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},
|
||||
];
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('WelcomePage', () => {
|
||||
|
||||
expect(screen.getAllByRole('heading', {level: 2})).toHaveLength(6);
|
||||
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 () => {
|
||||
|
||||
@@ -9,6 +9,13 @@ const FOLLOWS = () => `${import.meta.env.VITE_CORE_API_URL}/api/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)}`;
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,3 +47,6 @@ export const groupDetails = (details) =>
|
||||
});
|
||||
return {kind, games: [...games.values()]};
|
||||
}).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;
|
||||
@@ -1,5 +1,5 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {digestParts, groupDetails} from './inbox.js';
|
||||
import {digestParts, groupDetails, notificationTotal} from './inbox.js';
|
||||
|
||||
describe('digestParts', () => {
|
||||
it('keepsOnlyTheNonZeroCountsInAFixedOrder', () => {
|
||||
@@ -38,3 +38,10 @@ describe('groupDetails', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,18 @@ const JSON_HEADERS = {'Content-Type': 'application/json'};
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -35,3 +35,11 @@ export const trendPoints = (days, width, height) => {
|
||||
const span = dayNumber(days[days.length - 1].day) - first;
|
||||
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
@@ -1,5 +1,5 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {genreKey, trendPoints} from './stats.js';
|
||||
import {genreKey, trendNet, trendPoints, trendRange} from './stats.js';
|
||||
|
||||
describe('genreKey', () => {
|
||||
it('turns an IGDB genre name into an i18n key', () => {
|
||||
@@ -31,3 +31,22 @@ describe('trendPoints', () => {
|
||||
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});
|
||||
});
|
||||
});
|
||||
@@ -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}));
|
||||
@@ -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},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user