Compare commits

...
2 Commits
Author SHA1 Message Date
lobo e7b08cfb47 feat(psn): show trophy sets on game detail and dashboard
Build, Test & Push Frontend / docker (push) Successful in 33s
Build, Test & Push Frontend / quality-check (push) Successful in 35s
Detail page groups achievements by set: Steam, then PSN trophies.
Tiles show the real count; a perfect game gets an amber trophy.
Dashboard card shows for PSN-only users; CSP allows PSN icons.
2026-10-08 09:18:15 +02:00
lobo 27c402fa34 feat(psn): replace PSN import button with sync review
Remove the PSN import tab; linking now starts a sync.
Show the PSN review link, last played date and PSN expiry notes.
PSN rows hide the hours field; unchecked titles you saw are dismissed,
unseen ones stay open.
2026-10-08 08:15:37 +02:00
34 changed files with 653 additions and 357 deletions

No files matched your search

+13 -4
View File
@@ -24,7 +24,8 @@ To Be Played is split into three repositories:
in Auth, so it follows the user to the phone; "Reset" goes back to the default. Tiles: most played games, games in progress, "Pile of Shame" with backlog hours, its trend of the last 90 in Auth, so it follows the user to the phone; "Reset" goes back to the default. Tiles: most played games, games in progress, "Pile of Shame" with backlog hours, its trend of the last 90
days ("this month: +N added, -N off the pile") and a random suggestion, days ("this month: +N added, -N off the pile") and a random suggestion,
Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked
total, perfect games, average completion and the 5 games closest to 100 %. total, perfect games, average completion and the 5 games closest to 100 %. The card counts PlayStation trophies too
(platinum = perfect) and also shows for a user with only a PlayStation link.
- **Coming games**: a game that is not released yet (and nobody played) shows a "Coming <date>" badge in the library, on - **Coming games**: a game that is not released yet (and nobody played) shows a "Coming <date>" badge in the library, on
the details page, in the stat game lists and on the public profile. The pile counts only the games Core sends in the details page, in the stat game lists and on the public profile. The pile counts only the games Core sends in
`pileGames`, so coming games are not on it; Core never picks them. `pileGames`, so coming games are not on it; Core never picks them.
@@ -87,6 +88,10 @@ To Be Played is split into three repositories:
- **Platform everywhere**: each game shows its platform ("Steam" for Steam games). The favorite platform from the - **Platform everywhere**: each game shows its platform ("Steam" for Steam games). The favorite platform from the
settings is preselected when a game is on it; otherwise a game on one platform gets that one, and a game on settings is preselected when a game is on it; otherwise a game on one platform gets that one, and a game on
several waits for the user's pick. several waits for the user's pick.
- **PlayStation trophies** (slice 3): the game detail shows one card per set, Steam first, then PS5 and PS4
(`GameAchievements`, grouped by `achievementSets`). A trophy has its type (bronze to platinum), rarity and a DLC tag;
a hidden trophy shows "Hidden trophy" until it is earned; a set whose list is not stored yet says it loads with the
next sync. The sync banner and inbox text say "achievements or trophies". Library tiles show the best set of a game.
- **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %), - **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %),
a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a
game still in the backlog, and one to set the status to 100 % when game still in the backlog, and one to set the status to 100 % when
@@ -146,7 +151,9 @@ To Be Played is split into three repositories:
settings page lists who you follow ("Unfollow") and who follows you ("Remove", a private follower shows as "Private 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 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 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 user who goes private ends the follow with a note. Notes older than 90 days are deleted. and marks them read; a user who goes private ends the follow with a note. Two more notes come from the user's own
PlayStation link (`PSN_EXPIRING` about 2 days before it ends, `PSN_EXPIRED` when it has ended), with a link to the
settings. Notes older than 90 days are deleted.
- **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it" - **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` 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`. (menu) lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`.
@@ -200,7 +207,8 @@ flowchart LR
Core --> Igdb["IGDB API"] Core --> Igdb["IGDB API"]
Core -->|"OAuth token"| Twitch["Twitch"] Core -->|"OAuth token"| Twitch["Twitch"]
Core --> Speedrun["speedrun.com API"] Core --> Speedrun["speedrun.com API"]
Browser -.->|"game art"| Images["Steam and IGDB image hosts"] Core -->|"NPSSO sign-in, played titles, trophies"| Psn["PlayStation API (unofficial)"]
Browser -.->|"game art, trophy icons"| Images["Steam, IGDB and PlayStation (psnobj) image hosts"]
Browser -.->|"Sign in through Steam"| SteamLogin["steamcommunity.com (OpenID)"] Browser -.->|"Sign in through Steam"| SteamLogin["steamcommunity.com (OpenID)"]
Browser -.->|"bookmarklet, the user's login"| Stores["PlayStation, Xbox, Nintendo, Epic, GOG"] Browser -.->|"bookmarklet, the user's login"| Stores["PlayStation, Xbox, Nintendo, Epic, GOG"]
Auth -->|"check_authentication"| SteamLogin Auth -->|"check_authentication"| SteamLogin
@@ -208,7 +216,8 @@ flowchart LR
- `useDragSort` (`src/hooks/useDragSort.js`) sorts a list with pointer events. Only the drag handle has `touch-action: none`, so the page still scrolls from a row. - `useDragSort` (`src/hooks/useDragSort.js`) sorts a list with pointer events. Only the drag handle has `touch-action: none`, so the page still scrolls from a row.
- nginx serves the built app. It has no backend of its own: the browser calls Auth and Core directly - nginx serves the built app. It has no backend of its own: the browser calls Auth and Core directly
(`VITE_API_URL`, `VITE_CORE_API_URL`). The Content Security Policy allows only these two hosts and the image hosts. (`VITE_API_URL`, `VITE_CORE_API_URL`). The Content Security Policy allows only these two hosts and the image hosts (Steam, IGDB and, for trophy icons,
`psnobj.prod.dl.playstation.net`).
- Auth and Core each have their own database. Core verifies Auth's tokens with Auth's public keys (JWKS). - Auth and Core each have their own database. Core verifies Auth's tokens with Auth's public keys (JWKS).
- Calls for the logged-in user go through `src/utils/api.js`. `clientFetch` sends the HttpOnly cookies and the - Calls for the logged-in user go through `src/utils/api.js`. `clientFetch` sends the HttpOnly cookies and the
`X-TBP-Client` header. `apiFetch` answers a `401` with one `POST /api/auth/refresh` and sends the request again. `X-TBP-Client` header. `apiFetch` answers a `401` with one `POST /api/auth/refresh` and sends the request again.
+1 -1
View File
@@ -3,7 +3,7 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
# An nginx template: AUTH_HOST and API_HOST come from the container env at start (see Dockerfile). # An nginx template: AUTH_HOST and API_HOST come from the container env at start (see Dockerfile).
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.steamstatic.com https://steamcdn-a.akamaihd.net https://media.steampowered.com https://images.igdb.com; font-src 'self' data:; connect-src 'self' https://${AUTH_HOST} https://${API_HOST}; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.steamstatic.com https://steamcdn-a.akamaihd.net https://media.steampowered.com https://images.igdb.com https://psnobj.prod.dl.playstation.net; font-src 'self' data:; connect-src 'self' https://${AUTH_HOST} https://${API_HOST}; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
+27 -17
View File
@@ -152,11 +152,12 @@
"WHAT_IT_GIVES": "Der NPSSO-Code ist deine PlayStation-Anmeldesitzung. Wer ihn hat, kann PlayStation-Seiten als du nutzen. Wir tauschen ihn sofort gegen ein begrenztes Token (Umfang der PlayStation-App) ein, speichern nur dieses Token verschlüsselt und verwerfen den Code. Du kannst das jederzeit beenden: hier trennen oder in den PlayStation-Kontoeinstellungen auf allen Geräten abmelden. Das nutzt dieselbe inoffizielle Schnittstelle wie Trophäen-Seiten von Drittanbietern; Sony unterstützt sie nicht und kann sie sperren.", "WHAT_IT_GIVES": "Der NPSSO-Code ist deine PlayStation-Anmeldesitzung. Wer ihn hat, kann PlayStation-Seiten als du nutzen. Wir tauschen ihn sofort gegen ein begrenztes Token (Umfang der PlayStation-App) ein, speichern nur dieses Token verschlüsselt und verwerfen den Code. Du kannst das jederzeit beenden: hier trennen oder in den PlayStation-Kontoeinstellungen auf allen Geräten abmelden. Das nutzt dieselbe inoffizielle Schnittstelle wie Trophäen-Seiten von Drittanbietern; Sony unterstützt sie nicht und kann sie sperren.",
"NPSSO_LABEL": "NPSSO-Code", "NPSSO_LABEL": "NPSSO-Code",
"LINK": "PlayStation-Konto verknüpfen", "LINK": "PlayStation-Konto verknüpfen",
"LINKED": "PlayStation-Konto verknüpft.", "LINKED": "PlayStation-Konto verknüpft, die Synchronisierung startet jetzt.",
"LINKED_SINCE": "Verknüpft seit {{date}}", "LINKED_SINCE": "Verknüpft seit {{date}}",
"EXPIRES_ON": "Verknüpfung endet am {{date}}", "EXPIRES_ON": "Verknüpfung endet am {{date}}",
"EXPIRED": "Die Verknüpfung ist beendet. Füge einen neuen NPSSO-Code ein, um sie zu erneuern.", "EXPIRED": "Die Verknüpfung ist beendet. Füge einen neuen NPSSO-Code ein, um sie zu erneuern.",
"IMPORT": "Gespielte Spiele importieren", "SYNC_HINT": "Neue gespielte Titel kommen mit jeder Synchronisierung. Sichere Treffer werden hinzugefügt, unsichere warten im Import-Review.",
"REVIEW": "Wartende Titel prüfen",
"UNLINK": "Trennen", "UNLINK": "Trennen",
"UNLINKED": "PlayStation-Konto getrennt. Importierte Spiele bleiben in deiner Bibliothek.", "UNLINKED": "PlayStation-Konto getrennt. Importierte Spiele bleiben in deiner Bibliothek.",
"INVALID": "PlayStation hat den Code nicht akzeptiert. Er ist vielleicht abgelaufen: melde dich neu an und kopiere einen neuen.", "INVALID": "PlayStation hat den Code nicht akzeptiert. Er ist vielleicht abgelaufen: melde dich neu an und kopiere einen neuen.",
@@ -298,6 +299,7 @@
"ERROR_LOAD": "Spiel konnte nicht geladen werden.", "ERROR_LOAD": "Spiel konnte nicht geladen werden.",
"NOT_FOUND": "Spiel nicht in deiner Bibliothek.", "NOT_FOUND": "Spiel nicht in deiner Bibliothek.",
"PLAYTIME": "Spielzeit: {{time}}", "PLAYTIME": "Spielzeit: {{time}}",
"LAST_PLAYED": "Zuletzt gespielt {{date}}",
"OPEN_IN_STEAM": "Auf Steam ansehen", "OPEN_IN_STEAM": "Auf Steam ansehen",
"HIDE": "Spiel ausblenden", "HIDE": "Spiel ausblenden",
"UNHIDE": "Spiel wieder anzeigen", "UNHIDE": "Spiel wieder anzeigen",
@@ -310,8 +312,19 @@
"UNLOCKED": "Erreicht", "UNLOCKED": "Erreicht",
"REMAINING": "Offen", "REMAINING": "Offen",
"COMPLETION": "Fortschritt", "COMPLETION": "Fortschritt",
"NO_ACHIEVEMENTS": "Dieses Spiel hat keine Erfolge auf Steam.", "NO_ACHIEVEMENTS": "Dieses Spiel hat keine Erfolge oder Trophäen.",
"STATS_HIDDEN": "Statistiken von Steam verborgen — Profil und Spieldetails auf öffentlich stellen, um Erfolge zu sehen.", "STATS_HIDDEN": "Statistiken von Steam verborgen — Profil und Spieldetails auf öffentlich stellen, um Erfolge zu sehen.",
"TROPHIES": "{{platform}}-Trophäen",
"TROPHIES_PENDING": "Die Trophäenliste wird mit dem nächsten Sync geladen.",
"TROPHY_TYPE": {
"BRONZE": "Bronze",
"SILVER": "Silber",
"GOLD": "Gold",
"PLATINUM": "Platin"
},
"HIDDEN_TROPHY": "Versteckte Trophäe",
"RARITY": "{{percent}} % der Spieler",
"DLC": "DLC",
"PLAYED_RECENTLY": "Laut Steam hast du das Spiel in den letzten 2 Wochen {{time}} gespielt, es steht aber noch im Backlog.", "PLAYED_RECENTLY": "Laut Steam hast du das Spiel in den letzten 2 Wochen {{time}} gespielt, es steht aber noch im Backlog.",
"SET_PLAYING": "Auf „Spiele ich“ setzen", "SET_PLAYING": "Auf „Spiele ich“ setzen",
"TTB": "Spieldauer laut IGDB", "TTB": "Spieldauer laut IGDB",
@@ -526,7 +539,7 @@
"REMOVE_YES": "Ja, entfernen", "REMOVE_YES": "Ja, entfernen",
"REMOVE_NO": "Abbrechen", "REMOVE_NO": "Abbrechen",
"REMOVED": "Spiel entfernt.", "REMOVED": "Spiel entfernt.",
"NO_ACHIEVEMENTS": "Erfolge gibt es nur für Steam-Spiele.", "NO_ACHIEVEMENTS": "Erfolge und Trophäen gibt es nur für Spiele von Steam oder PlayStation.",
"SELECT": "{{name}} auswählen", "SELECT": "{{name}} auswählen",
"SELECTED": "{{count}} ausgewählt", "SELECTED": "{{count}} ausgewählt",
"ADD_SELECTED": "Prüfen und hinzufügen" "ADD_SELECTED": "Prüfen und hinzufügen"
@@ -931,10 +944,13 @@
"LOAD_ERROR": "Das Postfach konnte nicht geladen werden.", "LOAD_ERROR": "Das Postfach konnte nicht geladen werden.",
"DIGEST": "{{name}}: {{parts}}", "DIGEST": "{{name}}: {{parts}}",
"VISIBILITY": "{{name}} hat die Sichtbarkeit geändert, du folgst nicht mehr.", "VISIBILITY": "{{name}} hat die Sichtbarkeit geändert, du folgst nicht mehr.",
"PSN_EXPIRING": "Deine PlayStation-Verknüpfung endet in etwa 2 Tagen. Füge in den Einstellungen einen neuen NPSSO-Code ein, damit die Synchronisierung weiterläuft.",
"PSN_EXPIRED": "Deine PlayStation-Verknüpfung ist beendet. Die Synchronisierung überspringt PlayStation, bis du einen neuen NPSSO-Code einfügst.",
"OPEN_SETTINGS": "Einstellungen öffnen",
"PART_NEW_GAMES_one": "{{count}} neues Spiel", "PART_NEW_GAMES_one": "{{count}} neues Spiel",
"PART_NEW_GAMES_other": "{{count}} neue Spiele", "PART_NEW_GAMES_other": "{{count}} neue Spiele",
"PART_ACHIEVEMENTS_one": "{{count}} Erfolg", "PART_ACHIEVEMENTS_one": "{{count}} Erfolg oder Trophäe",
"PART_ACHIEVEMENTS_other": "{{count}} Erfolge", "PART_ACHIEVEMENTS_other": "{{count}} Erfolge oder Trophäen",
"PART_STATUS_CHANGES_one": "{{count}} Statuswechsel", "PART_STATUS_CHANGES_one": "{{count}} Statuswechsel",
"PART_STATUS_CHANGES_other": "{{count}} Statuswechsel", "PART_STATUS_CHANGES_other": "{{count}} Statuswechsel",
"PART_WISHLIST_ADDS_one": "{{count}} Wunschlisten-Eintrag", "PART_WISHLIST_ADDS_one": "{{count}} Wunschlisten-Eintrag",
@@ -1049,6 +1065,7 @@
"SOURCE": "Eingabe", "SOURCE": "Eingabe",
"SOURCE_TEXT": "Liste", "SOURCE_TEXT": "Liste",
"SOURCE_CSV": "CSV-Datei", "SOURCE_CSV": "CSV-Datei",
"SOURCE_PSN": "PlayStation",
"SOURCE_SEARCH": "Suche", "SOURCE_SEARCH": "Suche",
"TEXT_LABEL": "Spieltitel", "TEXT_LABEL": "Spieltitel",
"TEXT_PLACEHOLDER": "Ein Spiel pro Zeile", "TEXT_PLACEHOLDER": "Ein Spiel pro Zeile",
@@ -1060,13 +1077,7 @@
"START": "Spiele suchen", "START": "Spiele suchen",
"START_FAILED": "Der Import konnte nicht starten.", "START_FAILED": "Der Import konnte nicht starten.",
"RUNNING": "Ein anderer Import wird noch gesucht. Warte, bis er fertig ist.", "RUNNING": "Ein anderer Import wird noch gesucht. Warte, bis er fertig ist.",
"PSN_HINT": "Importiert deine gespielten PS4- und PS5-Titel mit Spielzeit. Titel, die du gekauft, aber nie gestartet hast, stehen nicht in der Liste.", "PSN_UNCHECKED_DISMISSED": "Nicht angehakte PlayStation-Titel werden dir nicht noch einmal angeboten.",
"PSN_NOT_LINKED": "Verknüpfe zuerst dein PlayStation-Konto in den Einstellungen.",
"PSN_RELINK": "Die PlayStation-Verknüpfung ist beendet. Füge in den Einstellungen einen neuen NPSSO-Code ein.",
"PSN_NO_TITLES": "PlayStation listet für dieses Konto keine gespielten Titel.",
"PSN_UNAVAILABLE": "PlayStation hat nicht geantwortet. Bitte versuche es später noch einmal.",
"PSN_BUSY": "Eine andere PlayStation-Anfrage von dir läuft noch. Warte einen Moment und versuche es erneut.",
"PSN_SETTINGS": "Einstellungen öffnen",
"LOAD_FAILED": "Der Import konnte nicht geladen werden.", "LOAD_FAILED": "Der Import konnte nicht geladen werden.",
"MATCHING": "Spiele werden gesucht: {{matched}} / {{total}}", "MATCHING": "Spiele werden gesucht: {{matched}} / {{total}}",
"MATCHING_HINT": "Du kannst die Seite verlassen und später wiederkommen.", "MATCHING_HINT": "Du kannst die Seite verlassen und später wiederkommen.",
@@ -1130,7 +1141,6 @@
"SOURCE_HLTB": "HowLongToBeat", "SOURCE_HLTB": "HowLongToBeat",
"SOURCE_PLAYNITE": "Playnite", "SOURCE_PLAYNITE": "Playnite",
"SOURCE_BOOKMARKLET": "Store-Seite", "SOURCE_BOOKMARKLET": "Store-Seite",
"SOURCE_PSN": "PlayStation",
"FORMAT_READ": "Gelesen als: {{format}}", "FORMAT_READ": "Gelesen als: {{format}}",
"STORE_HINT": "Zieh den Button unten in deine Lesezeichenleiste. Öffne deine Spielebibliothek auf einer dieser Seiten, melde dich an und klick das Lesezeichen: {{sites}}. Es liest dort die Liste und bringt sie hierher. Die Liste geht nur durch deinen Browser.", "STORE_HINT": "Zieh den Button unten in deine Lesezeichenleiste. Öffne deine Spielebibliothek auf einer dieser Seiten, melde dich an und klick das Lesezeichen: {{sites}}. Es liest dort die Liste und bringt sie hierher. Die Liste geht nur durch deinen Browser.",
"STORE_LINK": "To Be Played Import", "STORE_LINK": "To Be Played Import",
@@ -1265,8 +1275,8 @@
"REMOVED_one": "{{count}} Spiel entfernt", "REMOVED_one": "{{count}} Spiel entfernt",
"REMOVED_other": "{{count}} Spiele entfernt", "REMOVED_other": "{{count}} Spiele entfernt",
"PLAYTIME": "{{time}} gespielt", "PLAYTIME": "{{time}} gespielt",
"ACHIEVEMENTS_one": "{{count}} neuer Erfolg", "ACHIEVEMENTS_one": "{{count}} neuer Erfolg oder Trophäe",
"ACHIEVEMENTS_other": "{{count}} neue Erfolge", "ACHIEVEMENTS_other": "{{count}} neue Erfolge oder Trophäen",
"MOVES_one": "{{count}} Statuswechsel", "MOVES_one": "{{count}} Statuswechsel",
"MOVES_other": "{{count}} Statuswechsel", "MOVES_other": "{{count}} Statuswechsel",
"DETAILS": "Details", "DETAILS": "Details",
@@ -1277,7 +1287,7 @@
"ADDED": "Neue Spiele", "ADDED": "Neue Spiele",
"REMOVED": "Entfernte Spiele", "REMOVED": "Entfernte Spiele",
"PLAYTIME": "Spielzeit", "PLAYTIME": "Spielzeit",
"ACHIEVEMENTS": "Neue Erfolge", "ACHIEVEMENTS": "Neue Erfolge oder Trophäen",
"STATUS": "Statuswechsel" "STATUS": "Statuswechsel"
} }
} }
+27 -17
View File
@@ -152,11 +152,12 @@
"WHAT_IT_GIVES": "The NPSSO code is your PlayStation sign-in session. Whoever has it can use PlayStation sites as you. We exchange it at once for a limited token (PlayStation app scope), store only that token encrypted, and drop the code. You can end it any time: unlink here, or sign out of all devices in your PlayStation account settings. This uses the same unofficial interface as third-party trophy sites; Sony does not support it and may block it.", "WHAT_IT_GIVES": "The NPSSO code is your PlayStation sign-in session. Whoever has it can use PlayStation sites as you. We exchange it at once for a limited token (PlayStation app scope), store only that token encrypted, and drop the code. You can end it any time: unlink here, or sign out of all devices in your PlayStation account settings. This uses the same unofficial interface as third-party trophy sites; Sony does not support it and may block it.",
"NPSSO_LABEL": "NPSSO code", "NPSSO_LABEL": "NPSSO code",
"LINK": "Link PlayStation account", "LINK": "Link PlayStation account",
"LINKED": "PlayStation account linked.", "LINKED": "PlayStation account linked, the sync starts now.",
"LINKED_SINCE": "Linked since {{date}}", "LINKED_SINCE": "Linked since {{date}}",
"EXPIRES_ON": "Link ends on {{date}}", "EXPIRES_ON": "Link ends on {{date}}",
"EXPIRED": "The link has ended. Paste a new NPSSO code to link again.", "EXPIRED": "The link has ended. Paste a new NPSSO code to link again.",
"IMPORT": "Import played games", "SYNC_HINT": "New played titles come with every sync. Sure matches are added; unsure ones wait in the import review.",
"REVIEW": "Review waiting titles",
"UNLINK": "Unlink", "UNLINK": "Unlink",
"UNLINKED": "PlayStation account unlinked. Imported games stay in your library.", "UNLINKED": "PlayStation account unlinked. Imported games stay in your library.",
"INVALID": "PlayStation did not accept the code. It may have expired: sign in again and copy a new one.", "INVALID": "PlayStation did not accept the code. It may have expired: sign in again and copy a new one.",
@@ -298,6 +299,7 @@
"ERROR_LOAD": "Could not load this game.", "ERROR_LOAD": "Could not load this game.",
"NOT_FOUND": "Game not in your library.", "NOT_FOUND": "Game not in your library.",
"PLAYTIME": "Playtime: {{time}}", "PLAYTIME": "Playtime: {{time}}",
"LAST_PLAYED": "Last played {{date}}",
"OPEN_IN_STEAM": "View on Steam", "OPEN_IN_STEAM": "View on Steam",
"HIDE": "Hide game", "HIDE": "Hide game",
"UNHIDE": "Show game again", "UNHIDE": "Show game again",
@@ -310,8 +312,19 @@
"UNLOCKED": "Unlocked", "UNLOCKED": "Unlocked",
"REMAINING": "Remaining", "REMAINING": "Remaining",
"COMPLETION": "Completion", "COMPLETION": "Completion",
"NO_ACHIEVEMENTS": "This game has no achievements on Steam.", "NO_ACHIEVEMENTS": "This game has no achievements or trophies.",
"STATS_HIDDEN": "Stats hidden by Steam — set your profile and game details to public to see achievements.", "STATS_HIDDEN": "Stats hidden by Steam — set your profile and game details to public to see achievements.",
"TROPHIES": "{{platform}} trophies",
"TROPHIES_PENDING": "The trophy list loads with the next sync.",
"TROPHY_TYPE": {
"BRONZE": "Bronze",
"SILVER": "Silver",
"GOLD": "Gold",
"PLATINUM": "Platinum"
},
"HIDDEN_TROPHY": "Hidden trophy",
"RARITY": "{{percent}} % of players",
"DLC": "DLC",
"PLAYED_RECENTLY": "Steam says you played this game for {{time}} in the last 2 weeks, but it is still in your backlog.", "PLAYED_RECENTLY": "Steam says you played this game for {{time}} in the last 2 weeks, but it is still in your backlog.",
"SET_PLAYING": "Set to \"Playing\"", "SET_PLAYING": "Set to \"Playing\"",
"TTB": "Time to beat (IGDB)", "TTB": "Time to beat (IGDB)",
@@ -526,7 +539,7 @@
"REMOVE_YES": "Yes, remove", "REMOVE_YES": "Yes, remove",
"REMOVE_NO": "Cancel", "REMOVE_NO": "Cancel",
"REMOVED": "Game removed.", "REMOVED": "Game removed.",
"NO_ACHIEVEMENTS": "Achievements exist only for Steam games.", "NO_ACHIEVEMENTS": "Achievements and trophies exist only for games from Steam or PlayStation.",
"SELECT": "Select {{name}}", "SELECT": "Select {{name}}",
"SELECTED": "{{count}} selected", "SELECTED": "{{count}} selected",
"ADD_SELECTED": "Check and add" "ADD_SELECTED": "Check and add"
@@ -931,10 +944,13 @@
"LOAD_ERROR": "The inbox could not be loaded.", "LOAD_ERROR": "The inbox could not be loaded.",
"DIGEST": "{{name}}: {{parts}}", "DIGEST": "{{name}}: {{parts}}",
"VISIBILITY": "{{name}} changed visibility, you no longer follow them.", "VISIBILITY": "{{name}} changed visibility, you no longer follow them.",
"PSN_EXPIRING": "Your PlayStation link ends in about 2 days. Paste a new NPSSO in the settings to keep the sync.",
"PSN_EXPIRED": "Your PlayStation link has ended. The sync skips PlayStation until you paste a new NPSSO.",
"OPEN_SETTINGS": "Open settings",
"PART_NEW_GAMES_one": "{{count}} new game", "PART_NEW_GAMES_one": "{{count}} new game",
"PART_NEW_GAMES_other": "{{count}} new games", "PART_NEW_GAMES_other": "{{count}} new games",
"PART_ACHIEVEMENTS_one": "{{count}} achievement", "PART_ACHIEVEMENTS_one": "{{count}} achievement or trophy",
"PART_ACHIEVEMENTS_other": "{{count}} achievements", "PART_ACHIEVEMENTS_other": "{{count}} achievements or trophies",
"PART_STATUS_CHANGES_one": "{{count}} status change", "PART_STATUS_CHANGES_one": "{{count}} status change",
"PART_STATUS_CHANGES_other": "{{count}} status changes", "PART_STATUS_CHANGES_other": "{{count}} status changes",
"PART_WISHLIST_ADDS_one": "{{count}} wishlist add", "PART_WISHLIST_ADDS_one": "{{count}} wishlist add",
@@ -1049,6 +1065,7 @@
"SOURCE": "Input", "SOURCE": "Input",
"SOURCE_TEXT": "List", "SOURCE_TEXT": "List",
"SOURCE_CSV": "CSV file", "SOURCE_CSV": "CSV file",
"SOURCE_PSN": "PlayStation",
"SOURCE_SEARCH": "Search", "SOURCE_SEARCH": "Search",
"TEXT_LABEL": "Game titles", "TEXT_LABEL": "Game titles",
"TEXT_PLACEHOLDER": "One game per line", "TEXT_PLACEHOLDER": "One game per line",
@@ -1060,13 +1077,7 @@
"START": "Search games", "START": "Search games",
"START_FAILED": "Could not start the import.", "START_FAILED": "Could not start the import.",
"RUNNING": "Another import is still being matched. Wait until it is done.", "RUNNING": "Another import is still being matched. Wait until it is done.",
"PSN_HINT": "Imports your played PS4 and PS5 titles with playtime. Titles you bought but never started are not listed.", "PSN_UNCHECKED_DISMISSED": "Unchecked PlayStation titles are not offered again.",
"PSN_NOT_LINKED": "Link your PlayStation account in the settings first.",
"PSN_RELINK": "The PlayStation link has ended. Paste a new NPSSO code in the settings.",
"PSN_NO_TITLES": "PlayStation lists no played titles for this account.",
"PSN_UNAVAILABLE": "PlayStation did not answer. Please try again later.",
"PSN_BUSY": "Another PlayStation request of yours is still running. Wait a moment and try again.",
"PSN_SETTINGS": "Open settings",
"LOAD_FAILED": "Could not load the import.", "LOAD_FAILED": "Could not load the import.",
"MATCHING": "Searching games: {{matched}} / {{total}}", "MATCHING": "Searching games: {{matched}} / {{total}}",
"MATCHING_HINT": "You can leave this page and come back later.", "MATCHING_HINT": "You can leave this page and come back later.",
@@ -1130,7 +1141,6 @@
"SOURCE_HLTB": "HowLongToBeat", "SOURCE_HLTB": "HowLongToBeat",
"SOURCE_PLAYNITE": "Playnite", "SOURCE_PLAYNITE": "Playnite",
"SOURCE_BOOKMARKLET": "Store page", "SOURCE_BOOKMARKLET": "Store page",
"SOURCE_PSN": "PlayStation",
"FORMAT_READ": "Read as: {{format}}", "FORMAT_READ": "Read as: {{format}}",
"STORE_HINT": "Drag the button below to your bookmarks bar. Open your game library on one of these sites, log in, and click the bookmark: {{sites}}. It reads the list there and brings it back here. The list goes only through your browser.", "STORE_HINT": "Drag the button below to your bookmarks bar. Open your game library on one of these sites, log in, and click the bookmark: {{sites}}. It reads the list there and brings it back here. The list goes only through your browser.",
"STORE_LINK": "To Be Played import", "STORE_LINK": "To Be Played import",
@@ -1265,8 +1275,8 @@
"REMOVED_one": "{{count}} game removed", "REMOVED_one": "{{count}} game removed",
"REMOVED_other": "{{count}} games removed", "REMOVED_other": "{{count}} games removed",
"PLAYTIME": "{{time}} played", "PLAYTIME": "{{time}} played",
"ACHIEVEMENTS_one": "{{count}} new achievement", "ACHIEVEMENTS_one": "{{count}} new achievement or trophy",
"ACHIEVEMENTS_other": "{{count}} new achievements", "ACHIEVEMENTS_other": "{{count}} new achievements or trophies",
"MOVES_one": "{{count}} status change", "MOVES_one": "{{count}} status change",
"MOVES_other": "{{count}} status changes", "MOVES_other": "{{count}} status changes",
"DETAILS": "Details", "DETAILS": "Details",
@@ -1277,7 +1287,7 @@
"ADDED": "New games", "ADDED": "New games",
"REMOVED": "Removed games", "REMOVED": "Removed games",
"PLAYTIME": "Playtime", "PLAYTIME": "Playtime",
"ACHIEVEMENTS": "New achievements", "ACHIEVEMENTS": "New achievements or trophies",
"STATUS": "Status changes" "STATUS": "Status changes"
} }
} }
+5 -4
View File
@@ -33,10 +33,11 @@ const rememberClosed = (hintId) => {
* hint hides it for 7 days, "Do not show again" for good. * hint hides it for 7 days, "Do not show again" for good.
* *
* @param user the current user * @param user the current user
* @param canSync a Steam ID or a PlayStation link: then the NO_STEAM hint has nothing to say; undefined while loading
* @param summary Core's library summary; undefined while loading, null when it failed * @param summary Core's library summary; undefined while loading, null when it failed
* @param trend the TREND group of the stats; undefined while loading, null when it failed * @param trend the TREND group of the stats; undefined while loading, null when it failed
*/ */
export default function DashboardHint({user, summary, trend}) { export default function DashboardHint({user, canSync, summary, trend}) {
const {t} = useTranslation(); const {t} = useTranslation();
const [data, setData] = useState({hints: [], automatic: []}); const [data, setData] = useState({hints: [], automatic: []});
const [closed, setClosed] = useState(closedIds); const [closed, setClosed] = useState(closedIds);
@@ -67,12 +68,12 @@ export default function DashboardHint({user, summary, trend}) {
const needsSummary = hints.some((h) => h.emptyLibraryOnly) || automatic.includes('EMPTY_LIBRARY'); const needsSummary = hints.some((h) => h.emptyLibraryOnly) || automatic.includes('EMPTY_LIBRARY');
// Show nothing until every fact the cards need has settled, so a later fact does not swap the card. // Show nothing until every fact the cards need has settled, so a later fact does not swap the card.
if ((needsSummary && summary === undefined) || (automatic.includes('PILE_GREW') && trend === undefined) if ((needsSummary && summary === undefined) || (automatic.includes('PILE_GREW') && trend === undefined)
|| (automatic.includes('NO_STEAM') && !user) || links === undefined) { || (automatic.includes('NO_STEAM') && (!user || canSync === undefined)) || links === undefined) {
return undefined; return undefined;
} }
const libraryEmpty = summary?.total === 0; const libraryEmpty = summary?.total === 0;
const growth = pileGrowth(trend); const growth = pileGrowth(trend);
const condition = {NO_STEAM: !user?.steamId, EMPTY_LIBRARY: libraryEmpty, LINK_QUEUE: links > 0, PILE_GREW: growth > 0}; const condition = {NO_STEAM: !canSync, EMPTY_LIBRARY: libraryEmpty, LINK_QUEUE: links > 0, PILE_GREW: growth > 0};
const count = {LINK_QUEUE: links, PILE_GREW: growth}; const count = {LINK_QUEUE: links, PILE_GREW: growth};
return [ return [
...hints.filter((h) => !h.emptyLibraryOnly || libraryEmpty), ...hints.filter((h) => !h.emptyLibraryOnly || libraryEmpty),
@@ -80,7 +81,7 @@ export default function DashboardHint({user, summary, trend}) {
id: `auto:${key}`, auto: key, kind: 'DISMISS', text: t(`HINT.AUTO_${key}`, {count: count[key]}), id: `auto:${key}`, auto: key, kind: 'DISMISS', text: t(`HINT.AUTO_${key}`, {count: count[key]}),
})), })),
].find((h) => !closed.includes(h.id)); ].find((h) => !closed.includes(h.id));
}, [data, closed, summary, trend, user, linkCount, wantsLinkQueue, t]); }, [data, closed, summary, trend, user, canSync, linkCount, wantsLinkQueue, t]);
// A failed save only shows an error: the hint comes back in the next browser session. // A failed save only shows an error: the hint comes back in the next browser session.
const close = useCallback(async (forever, byUser) => { const close = useCallback(async (forever, byUser) => {
+21 -9
View File
@@ -148,7 +148,7 @@ describe('DashboardHint, automatic hints', () => {
}); });
it('shows the admin hints before the automatic ones', async () => { it('shows the admin hints before the automatic ones', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}}, [DISMISS]); await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: false}, [DISMISS]);
expect(screen.getByText('Heute Nacht Update')).toBeTruthy(); expect(screen.getByText('Heute Nacht Update')).toBeTruthy();
fireEvent.click(screen.getByLabelText('HINT.CLOSE')); fireEvent.click(screen.getByLabelText('HINT.CLOSE'));
@@ -156,14 +156,26 @@ describe('DashboardHint, automatic hints', () => {
expect(screen.getByText('HINT.AUTO_NO_STEAM')).toBeTruthy(); expect(screen.getByText('HINT.AUTO_NO_STEAM')).toBeTruthy();
}); });
it('shows NO_STEAM only without a Steam ID', async () => { it('shows NO_STEAM only when nothing can sync', async () => {
await showAuto(['NO_STEAM']); await showAuto(['NO_STEAM'], {canSync: true});
expect(screen.queryByRole('status')).toBeNull();
});
it('shows no NO_STEAM hint to a user with a PlayStation link and no Steam ID', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: true});
expect(screen.queryByRole('status')).toBeNull();
});
it('waits with NO_STEAM until it is known whether the user can sync', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: undefined});
expect(screen.queryByRole('status')).toBeNull(); expect(screen.queryByRole('status')).toBeNull();
}); });
it('hides an automatic hint for 7 days with X', async () => { it('hides an automatic hint for 7 days with X', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}}); await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: false});
await act(async () => fireEvent.click(screen.getByLabelText('HINT.CLOSE'))); await act(async () => fireEvent.click(screen.getByLabelText('HINT.CLOSE')));
@@ -172,7 +184,7 @@ describe('DashboardHint, automatic hints', () => {
}); });
it('hides an automatic hint for good with the tick', async () => { it('hides an automatic hint for good with the tick', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}}); await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: false});
fireEvent.click(screen.getByLabelText('HINT.DO_NOT_SHOW_AGAIN')); fireEvent.click(screen.getByLabelText('HINT.DO_NOT_SHOW_AGAIN'));
await act(async () => fireEvent.click(screen.getByLabelText('HINT.CLOSE'))); await act(async () => fireEvent.click(screen.getByLabelText('HINT.CLOSE')));
@@ -181,7 +193,7 @@ describe('DashboardHint, automatic hints', () => {
}); });
it('does not store the auto close of an automatic hint and shows it again next session', async () => { it('does not store the auto close of an automatic hint and shows it again next session', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}}); await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: false});
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS)); await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS));
@@ -189,12 +201,12 @@ describe('DashboardHint, automatic hints', () => {
expect(doneCalls()).toEqual([]); expect(doneCalls()).toEqual([]);
cleanup(); cleanup();
sessionStorage.clear(); sessionStorage.clear();
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}}); await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: false});
expect(screen.getByText('HINT.AUTO_NO_STEAM')).toBeTruthy(); expect(screen.getByText('HINT.AUTO_NO_STEAM')).toBeTruthy();
}); });
it('hides the auto closed hint for good when the tick is set', async () => { it('hides the auto closed hint for good when the tick is set', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}}); await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: false});
fireEvent.click(screen.getByLabelText('HINT.DO_NOT_SHOW_AGAIN')); fireEvent.click(screen.getByLabelText('HINT.DO_NOT_SHOW_AGAIN'));
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS)); await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS));
@@ -265,7 +277,7 @@ describe('DashboardHint, automatic hints', () => {
it('shows only the admin hints when Auth sends no automatic list', async () => { it('shows only the admin hints when Auth sends no automatic list', async () => {
hintRoutes(json(200, {results: [DISMISS]})); hintRoutes(json(200, {results: [DISMISS]}));
mount({user: {...USER, steamId: null}}); mount({user: {...USER, steamId: null}, canSync: false});
await act(async () => undefined); await act(async () => undefined);
expect(screen.getByText('Heute Nacht Update')).toBeTruthy(); expect(screen.getByText('Heute Nacht Update')).toBeTruthy();
+4 -2
View File
@@ -14,10 +14,12 @@ import SyncStatusBadge from './SyncStatusBadge.jsx';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {bannerEffect, lookClass} from '../utils/rewards.js'; import {bannerEffect, lookClass} from '../utils/rewards.js';
export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forceSync, editing, onCustomize, equipped}) { // canSync: a Steam ID or a PlayStation link; the sync controls follow it, the Steam ID badge only the Steam ID.
export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forceSync, canSync, editing, onCustomize, equipped}) {
const {t} = useTranslation(); const {t} = useTranslation();
const {user: userData} = useCurrentUserContext(); const {user: userData} = useCurrentUserContext();
const hasSteamId = !!userData?.steamId; const hasSteamId = !!userData?.steamId;
const showSync = canSync ?? hasSteamId;
const syncBusy = forceSync.busy || sync.isRunning; const syncBusy = forceSync.busy || sync.isRunning;
return ( return (
@@ -53,7 +55,7 @@ export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forc
</div> </div>
<div className="relative flex flex-col items-start md:items-end gap-3 shrink-0"> <div className="relative flex flex-col items-start md:items-end gap-3 shrink-0">
{hasSteamId && ( {showSync && (
<> <>
<SyncStatusBadge <SyncStatusBadge
status={sync.status} status={sync.status}
+122 -46
View File
@@ -1,12 +1,21 @@
import {useMemo, useState} from 'react'; import {useMemo, useState} from 'react';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {Trophy} from 'lucide-react'; import {ShieldOff, Trophy} from 'lucide-react';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js'; import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
import {formatDate} from '../utils/format.js'; import {formatDate} from '../utils/format.js';
const STEAM_SET = 'steam';
const TROPHY_COLORS = {
BRONZE: 'text-amber-700',
SILVER: 'text-slate-400',
GOLD: 'text-yellow-500',
PLATINUM: 'text-cyan-400',
};
const formatUnlockedAt = (instant, locale) => { const formatUnlockedAt = (instant, locale) => {
if (!instant) return ''; if (!instant) return '';
try { try {
@@ -16,55 +25,60 @@ const formatUnlockedAt = (instant, locale) => {
} }
}; };
function AchievementIcon({appid, src, unlocked}) { // Only Steam icons can be repaired: the broken-image report goes to the Steam icon store.
function AchievementIcon({appid, src, unlocked, steam}) {
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
if (!src || failed) return <div className="size-12 rounded-md bg-muted"/>; if (!src || failed) return <div className="size-12 rounded-md bg-muted"/>;
return ( return (
<img src={src} alt="" className={cn('size-12 rounded-md', !unlocked && 'grayscale')} <img src={src} alt="" className={cn('size-12 rounded-md', !unlocked && 'grayscale')}
onError={() => { onError={() => {
reportBrokenImage(appid, ACHIEVEMENT_ICONS); if (steam) reportBrokenImage(appid, ACHIEVEMENT_ICONS);
setFailed(true); setFailed(true);
}}/> }}/>
); );
} }
export default function GameAchievements({game}) { // The base game (no group) first, then the DLC groups in their order; inside a group the earned ones first.
const {t, i18n} = useTranslation(); function sortAchievements(achievements) {
return [...achievements].sort((a, b) => {
const sortedAchievements = useMemo(() => { if ((a.group ?? '') !== (b.group ?? '')) return (a.group ?? '').localeCompare(b.group ?? '');
if (!game.achievements) return [];
return [...game.achievements].sort((a, b) => {
if (a.unlocked !== b.unlocked) return a.unlocked ? -1 : 1; if (a.unlocked !== b.unlocked) return a.unlocked ? -1 : 1;
if (a.unlocked && b.unlocked) { if (a.unlocked && b.unlocked) {
return (b.unlockedAt ?? '').localeCompare(a.unlockedAt ?? ''); return (b.unlockedAt ?? '').localeCompare(a.unlockedAt ?? '');
} }
return (a.displayName ?? a.apiName).localeCompare(b.displayName ?? b.apiName); return (a.displayName ?? a.apiName).localeCompare(b.displayName ?? b.apiName);
}); });
}, [game.achievements]); }
function SetHeader({set}) {
const {t} = useTranslation();
const steam = set.id === STEAM_SET;
return (
<div className="flex items-baseline justify-between gap-3">
<h2 className="flex min-w-0 items-center gap-2 text-lg font-semibold">
<Trophy className="size-4 shrink-0 text-amber-500"/>
<span className="truncate">
{steam ? t('GAME_DETAIL.ACHIEVEMENTS') : t('GAME_DETAIL.TROPHIES', {platform: set.platform})}
</span>
</h2>
<span className="text-sm text-muted-foreground tabular-nums">{set.unlocked} / {set.total}</span>
</div>
);
}
function SetSummary({set}) {
const {t} = useTranslation();
const percent = set.perfect ? 100 : set.total === 0 ? 0 : Math.round((set.unlocked * 1000) / set.total) / 10;
const stats = [ const stats = [
[t('GAME_DETAIL.UNLOCKED'), game.achievementsUnlocked], [t('GAME_DETAIL.UNLOCKED'), set.unlocked],
[t('GAME_DETAIL.REMAINING'), game.achievementsRemaining], [t('GAME_DETAIL.REMAINING'), Math.max(0, set.total - set.unlocked)],
[t('GAME_DETAIL.COMPLETION'), `${game.completionPercent}%`], [t('GAME_DETAIL.COMPLETION'), `${percent}%`],
]; ];
return ( return (
<> <>
<Card className="p-6 gap-5">
<div className="flex items-baseline justify-between">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Trophy className="size-4 text-amber-500"/>
{t('GAME_DETAIL.ACHIEVEMENTS')}
</h2>
<span className="text-sm text-muted-foreground tabular-nums">
{game.achievementsUnlocked} / {game.achievementsTotal}
</span>
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-muted"> <div className="h-2.5 w-full overflow-hidden rounded-full bg-muted">
<div <div className="h-full rounded-full bg-primary transition-all"
className="h-full rounded-full bg-primary transition-all" style={{width: `${Math.min(100, percent)}%`}}/>
style={{width: `${Math.min(100, game.completionPercent)}%`}}
/>
</div> </div>
<div className="grid grid-cols-3 gap-3 text-center"> <div className="grid grid-cols-3 gap-3 text-center">
{stats.map(([label, value]) => ( {stats.map(([label, value]) => (
@@ -74,35 +88,97 @@ export default function GameAchievements({game}) {
</div> </div>
))} ))}
</div> </div>
</Card> </>
);
}
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3"> function AchievementItem({game, achievement}) {
{sortedAchievements.map((a) => { const {t, i18n} = useTranslation();
const icon = a.unlocked ? a.iconUrl : a.iconGrayUrl; const steam = achievement.kind !== 'PSN';
const icon = achievement.unlocked ? achievement.iconUrl : achievement.iconGrayUrl;
const name = achievement.displayName
?? (achievement.hidden && !steam ? t('GAME_DETAIL.HIDDEN_TROPHY') : achievement.apiName);
const trophyLabel = achievement.trophyType && t(`GAME_DETAIL.TROPHY_TYPE.${achievement.trophyType}`);
return ( return (
<li <li className={cn('flex items-start gap-3 rounded-xl border bg-card p-3',
key={a.apiName} achievement.unlocked ? 'border-primary/40' : 'opacity-60')}>
className={cn( <AchievementIcon key={icon} appid={game.appid} src={icon} unlocked={achievement.unlocked} steam={steam}/>
'flex items-start gap-3 rounded-xl border bg-card p-3',
a.unlocked ? 'border-primary/40' : 'opacity-60',
)}
>
<AchievementIcon key={icon} appid={game.appid} src={icon} unlocked={a.unlocked}/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate font-medium">{a.displayName ?? a.apiName}</div> <div className="flex items-center gap-1.5">
{a.description && ( {achievement.trophyType && (
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{a.description}</div> <Trophy className={cn('size-3.5 shrink-0', TROPHY_COLORS[achievement.trophyType])}
aria-label={trophyLabel}>
<title>{trophyLabel}</title>
</Trophy>
)} )}
{a.unlocked && a.unlockedAt && ( <div className="truncate font-medium">{name}</div>
{achievement.group && (
<span className="shrink-0 rounded bg-muted px-1.5 text-[10px] font-semibold">
{t('GAME_DETAIL.DLC')}
</span>
)}
</div>
{achievement.description && (
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{achievement.description}</div>
)}
{achievement.rarity != null && (
<div className="mt-1 text-xs text-muted-foreground tabular-nums">
{t('GAME_DETAIL.RARITY', {percent: Math.round(achievement.rarity * 10) / 10})}
</div>
)}
{achievement.unlocked && achievement.unlockedAt && (
<div className="mt-1 text-xs text-primary tabular-nums"> <div className="mt-1 text-xs text-primary tabular-nums">
{formatUnlockedAt(a.unlockedAt, i18n.language)} {formatUnlockedAt(achievement.unlockedAt, i18n.language)}
</div> </div>
)} )}
</div> </div>
</li> </li>
); );
})} }
function AchievementSet({game, set, achievements}) {
const {t} = useTranslation();
const sorted = useMemo(() => sortAchievements(achievements), [achievements]);
const steam = set.id === STEAM_SET;
if (steam && !set.visible) {
return (
<Card className="items-center py-10 text-center text-sm text-muted-foreground">
<ShieldOff className="size-8"/>
{t('GAME_DETAIL.STATS_HIDDEN')}
</Card>
);
}
return (
<>
<Card className="p-6 gap-5">
<SetHeader set={set}/>
{!steam && set.name && <p className="-mt-3 truncate text-sm text-muted-foreground">{set.name}</p>}
<SetSummary set={set}/>
{!set.listed && <p className="text-sm text-muted-foreground">{t('GAME_DETAIL.TROPHIES_PENDING')}</p>}
</Card>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{sorted.map((achievement) => (
<AchievementItem key={`${achievement.setId ?? STEAM_SET}:${achievement.apiName}`} game={game}
achievement={achievement}/>
))}
</ul> </ul>
</> </>
); );
} }
export default function GameAchievements({game}) {
const bySet = useMemo(() => {
const groups = new Map();
for (const a of game.achievements ?? []) {
const id = a.setId ?? STEAM_SET;
if (!groups.has(id)) groups.set(id, []);
groups.get(id).push(a);
}
return groups;
}, [game.achievements]);
return (game.achievementSets ?? []).map((set) => (
<AchievementSet key={set.id} game={game} set={set} achievements={bySet.get(set.id) ?? []}/>
));
}
+113
View File
@@ -0,0 +1,113 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
import GameAchievements from './GameAchievements.jsx';
import {reportBrokenImage} from '../utils/brokenImages.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('../utils/brokenImages.js', () => ({ACHIEVEMENT_ICONS: 'ACHIEVEMENT_ICONS', reportBrokenImage: vi.fn()}));
const set = (id, extra = {}) => ({id, kind: id === 'steam' ? 'STEAM' : 'PSN', platform: null, name: null, total: 2,
unlocked: 1, perfect: false, visible: true, listed: true, ...extra});
const steam = (apiName, extra = {}) => ({apiName, displayName: `Steam ${apiName}`, unlocked: false, kind: 'STEAM',
setId: null, iconUrl: 'https://cdn.steamstatic.com/a.jpg', iconGrayUrl: 'https://cdn.steamstatic.com/b.jpg', ...extra});
const trophy = (setId, apiName, extra = {}) => ({apiName, displayName: `Trophy ${setId} ${apiName}`, unlocked: false,
kind: 'PSN', setId, trophyType: 'BRONZE', iconUrl: 'https://psnobj.prod.dl.playstation.net/t.png',
iconGrayUrl: 'https://psnobj.prod.dl.playstation.net/t.png', ...extra});
const renderGame = (game) => render(<GameAchievements game={{appid: 220, ...game}}/>);
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('GameAchievements', () => {
it('shows one card per set in the order of achievementSets', () => {
renderGame({
achievementSets: [set('steam'), set('NPWR1_00', {platform: 'PS5'}), set('NPWR2_00', {platform: 'PS4'})],
achievements: [trophy('NPWR2_00', '1'), steam('A'), trophy('NPWR1_00', '1')],
});
const headings = screen.getAllByRole('heading', {level: 2}).map((heading) => heading.textContent);
expect(headings).toEqual(['GAME_DETAIL.ACHIEVEMENTS', 'GAME_DETAIL.TROPHIES', 'GAME_DETAIL.TROPHIES']);
const names = screen.getAllByRole('listitem').map((item) => item.textContent);
expect(names[0]).toContain('Steam A');
expect(names[1]).toContain('Trophy NPWR1_00 1');
expect(names[2]).toContain('Trophy NPWR2_00 1');
});
it('lists the base game first and the DLC groups after it, whatever the earned state', () => {
renderGame({
achievementSets: [set('NPWR1_00', {platform: 'PS5', total: 3, unlocked: 1})],
achievements: [trophy('NPWR1_00', '1', {group: '002', unlocked: true, unlockedAt: '2026-01-02T00:00:00Z'}),
trophy('NPWR1_00', '2', {group: '001'}), trophy('NPWR1_00', '3', {group: null})],
});
expect(screen.getAllByRole('listitem').map((item) => item.textContent)).toEqual([
expect.stringContaining('Trophy NPWR1_00 3'), expect.stringContaining('Trophy NPWR1_00 2'),
expect.stringContaining('Trophy NPWR1_00 1')]);
});
it('titles the trophy type and marks DLC trophies and the rarity', () => {
renderGame({
achievementSets: [set('NPWR1_00', {platform: 'PS5', total: 1, unlocked: 0})],
achievements: [trophy('NPWR1_00', '1', {trophyType: 'GOLD', group: '001', rarity: 12.34})],
});
expect(screen.getByTitle('GAME_DETAIL.TROPHY_TYPE.GOLD')).toBeTruthy();
expect(screen.getByText('GAME_DETAIL.DLC')).toBeTruthy();
expect(screen.getByText('GAME_DETAIL.RARITY')).toBeTruthy();
});
it('shows a hidden trophy that is not earned without a name', () => {
renderGame({
achievementSets: [set('NPWR1_00', {platform: 'PS5', total: 1, unlocked: 0})],
achievements: [trophy('NPWR1_00', '1', {displayName: null, description: null, hidden: true})],
});
expect(screen.getByText('GAME_DETAIL.HIDDEN_TROPHY')).toBeTruthy();
});
it('shows the hidden-stats text for a Steam set Steam hides, and still lists the PlayStation set', () => {
renderGame({
achievementSets: [set('steam', {visible: false, unlocked: 0}), set('NPWR1_00', {platform: 'PS5'})],
achievements: [steam('A'), trophy('NPWR1_00', '1')],
});
expect(screen.getByText('GAME_DETAIL.STATS_HIDDEN')).toBeTruthy();
expect(screen.queryByText('Steam A')).toBeNull();
expect(screen.getByText('Trophy NPWR1_00 1')).toBeTruthy();
});
it('shows the pending text for a trophy set whose list is not stored yet', () => {
renderGame({achievementSets: [set('NPWR1_00', {platform: 'PS4', listed: false})], achievements: []});
expect(screen.getByText('GAME_DETAIL.TROPHIES_PENDING')).toBeTruthy();
});
it('counts a perfect set as 100 percent, also with trophies missing', () => {
renderGame({
achievementSets: [set('NPWR1_00', {platform: 'PS5', total: 50, unlocked: 30, perfect: true})],
achievements: [],
});
expect(screen.getByText('100%')).toBeTruthy();
expect(screen.getByText('30 / 50')).toBeTruthy();
});
it('reports a broken Steam icon but not a broken PlayStation icon', () => {
const {container} = renderGame({
achievementSets: [set('steam', {total: 1, unlocked: 0}), set('NPWR1_00', {platform: 'PS5', total: 1, unlocked: 0})],
achievements: [steam('A'), trophy('NPWR1_00', '1')],
});
const steamItem = screen.getByText('Steam A').closest('li');
const psnItem = screen.getByText('Trophy NPWR1_00 1').closest('li');
fireEvent.error(psnItem.querySelector('img'));
expect(reportBrokenImage).not.toHaveBeenCalled();
fireEvent.error(steamItem.querySelector('img'));
expect(reportBrokenImage).toHaveBeenCalledTimes(1);
expect(container.querySelectorAll('img')).toHaveLength(0);
});
});
+6 -2
View File
@@ -25,7 +25,9 @@ export default function ManualGameCard({game, onSaved}) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false); const [confirmRemove, setConfirmRemove] = useState(false);
const playtimeMinutes = hoursToMinutes(hours); // PlayStation owns the playtime of its rows: Core ignores a value sent for one.
const psn = game.source === 'PSN';
const playtimeMinutes = psn ? game.playtimeMinutes : hoursToMinutes(hours);
const dirty = platform.trim() !== (game.platform ?? '') || playtimeMinutes !== game.playtimeMinutes; const dirty = platform.trim() !== (game.platform ?? '') || playtimeMinutes !== game.playtimeMinutes;
useUnsavedChanges(dirty); useUnsavedChanges(dirty);
@@ -65,7 +67,7 @@ export default function ManualGameCard({game, onSaved}) {
<Hand className="size-4 text-primary"/> <Hand className="size-4 text-primary"/>
{t('MANUAL_GAME.TITLE')} {t('MANUAL_GAME.TITLE')}
</h2> </h2>
<p className="text-sm text-muted-foreground">{t('MANUAL_GAME.HINT')}</p> {!psn && <p className="text-sm text-muted-foreground">{t('MANUAL_GAME.HINT')}</p>}
<div className="grid gap-5 sm:grid-cols-2"> <div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
@@ -73,11 +75,13 @@ export default function ManualGameCard({game, onSaved}) {
<Input id="manual-platform" value={platform} maxLength={MAX_PLATFORM_LENGTH} <Input id="manual-platform" value={platform} maxLength={MAX_PLATFORM_LENGTH}
onChange={(e) => setPlatform(e.target.value)}/> onChange={(e) => setPlatform(e.target.value)}/>
</div> </div>
{!psn && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="manual-hours">{t('MANUAL_GAME.HOURS')}</Label> <Label htmlFor="manual-hours">{t('MANUAL_GAME.HOURS')}</Label>
<Input id="manual-hours" type="number" min="0" step="0.5" inputMode="decimal" value={hours} <Input id="manual-hours" type="number" min="0" step="0.5" inputMode="decimal" value={hours}
onChange={(e) => setHours(e.target.value)}/> onChange={(e) => setHours(e.target.value)}/>
</div> </div>
)}
</div> </div>
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
+6 -5
View File
@@ -1,7 +1,7 @@
import {useState} from 'react'; import {useState} from 'react';
import {Link} from 'react-router-dom'; import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {Gamepad, Link2, Loader2, Unlink, Upload} from 'lucide-react'; import {Gamepad, Link2, Loader2, Unlink} from 'lucide-react';
import {toast} from 'sonner'; import {toast} from 'sonner';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
@@ -13,7 +13,7 @@ import {isExpired, linkPsn, NPSSO_PAGE_URL, unlinkPsn} from '../utils/psn.js';
/** /**
* The linked PlayStation account. The user pastes an NPSSO once; Core keeps only an encrypted token for about 10 days, * The linked PlayStation account. The user pastes an NPSSO once; Core keeps only an encrypted token for about 10 days,
* so the card shows when the link ends. * so the card shows when the link ends. Linking starts a sync; titles the sync could not match wait in an import review.
*/ */
export default function PsnAccountCard() { export default function PsnAccountCard() {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
@@ -94,11 +94,12 @@ export default function PsnAccountCard() {
{expired && <> · {t('SETTINGS.PSN.EXPIRED')}</>} {expired && <> · {t('SETTINGS.PSN.EXPIRED')}</>}
</p> </p>
</div> </div>
<p className="text-sm text-muted-foreground">{t('SETTINGS.PSN.SYNC_HINT')}</p>
{expired && form} {expired && form}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{!expired && ( {account.reviewImportId && (
<Button asChild> <Button asChild variant="outline">
<Link to="/import?source=PSN"><Upload/>{t('SETTINGS.PSN.IMPORT')}</Link> <Link to={`/import?id=${account.reviewImportId}`}>{t('SETTINGS.PSN.REVIEW')}</Link>
</Button> </Button>
)} )}
<Button type="button" variant="outline" onClick={unlink} disabled={busy}> <Button type="button" variant="outline" onClick={unlink} disabled={busy}>
+21 -3
View File
@@ -11,7 +11,8 @@ vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn(), info: vi.fn(
const NPSSO = 'npsso-secret-value'; const NPSSO = 'npsso-secret-value';
const noBody = {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }}; const noBody = {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }};
const linkedAccount = (expiresAt) => ({enabled: true, linked: true, linkedAt: '2026-10-01T10:00:00Z', expiresAt}); const linkedAccount = (expiresAt, reviewImportId = null) =>
({enabled: true, linked: true, linkedAt: '2026-10-01T10:00:00Z', expiresAt, reviewImportId});
const inDays = (days) => new Date(Date.now() + days * 86_400_000).toISOString(); const inDays = (days) => new Date(Date.now() + days * 86_400_000).toISOString();
const calls = (method) => fetch.mock.calls.filter(([, init]) => init?.method === method); const calls = (method) => fetch.mock.calls.filter(([, init]) => init?.method === method);
@@ -66,7 +67,8 @@ describe('PsnAccountCard', () => {
expect(await screen.findByText('SETTINGS.PSN.LINKED_SINCE')).toBeTruthy(); expect(await screen.findByText('SETTINGS.PSN.LINKED_SINCE')).toBeTruthy();
expect(screen.getByText('SETTINGS.PSN.EXPIRES_ON')).toBeTruthy(); expect(screen.getByText('SETTINGS.PSN.EXPIRES_ON')).toBeTruthy();
expect(screen.getByRole('link', {name: /SETTINGS.PSN.IMPORT/}).getAttribute('href')).toBe('/import?source=PSN'); expect(screen.getByText('SETTINGS.PSN.SYNC_HINT')).toBeTruthy();
expect(screen.queryByRole('link', {name: /SETTINGS.PSN.IMPORT/})).toBeNull();
expect(JSON.parse(calls('POST')[0][1].body)).toEqual({npsso: NPSSO}); expect(JSON.parse(calls('POST')[0][1].body)).toEqual({npsso: NPSSO});
expect(document.body.innerHTML).not.toContain(NPSSO); expect(document.body.innerHTML).not.toContain(NPSSO);
expect(input.isConnected && input.value).toBeFalsy(); expect(input.isConnected && input.value).toBeFalsy();
@@ -97,7 +99,23 @@ describe('PsnAccountCard', () => {
expect(await screen.findByText(/SETTINGS.PSN.EXPIRED/)).toBeTruthy(); expect(await screen.findByText(/SETTINGS.PSN.EXPIRED/)).toBeTruthy();
expect(screen.getByText(/SETTINGS.PSN.EXPIRES_ON/).className).toContain('text-destructive'); expect(screen.getByText(/SETTINGS.PSN.EXPIRES_ON/).className).toContain('text-destructive');
expect(screen.getByLabelText('SETTINGS.PSN.NPSSO_LABEL')).toBeTruthy(); expect(screen.getByLabelText('SETTINGS.PSN.NPSSO_LABEL')).toBeTruthy();
expect(screen.queryByRole('link', {name: /SETTINGS.PSN.IMPORT/})).toBeNull(); expect(screen.queryByRole('link', {name: /SETTINGS.PSN.REVIEW/})).toBeNull();
});
it('links to the review only when titles wait in one', async () => {
renderCard({'GET /api/library/psn-account': json(200, linkedAccount(inDays(5), 42))});
expect(await screen.findByRole('link', {name: 'SETTINGS.PSN.REVIEW'})).toBeTruthy();
expect(screen.getByRole('link', {name: 'SETTINGS.PSN.REVIEW'}).getAttribute('href')).toBe('/import?id=42');
});
it('has no review link and no import button without a waiting review', async () => {
renderCard({'GET /api/library/psn-account': json(200, linkedAccount(inDays(5)))});
await screen.findByText('SETTINGS.PSN.SYNC_HINT');
expect(screen.queryByRole('link')).toBeNull();
expect(screen.queryByText('SETTINGS.PSN.IMPORT')).toBeNull();
}); });
it('unlinks and shows the link form again', async () => { it('unlinks and shows the link form again', async () => {
+6 -50
View File
@@ -1,6 +1,5 @@
import {useState} from 'react'; import {useState} from 'react';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {Link} from 'react-router-dom';
import {Copy, Loader2, Search} from 'lucide-react'; import {Copy, Loader2, Search} from 'lucide-react';
import {toast} from 'sonner'; import {toast} from 'sonner';
@@ -8,13 +7,10 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
import {Textarea} from '@/components/ui/textarea'; import {Textarea} from '@/components/ui/textarea';
import usePsnAccount from '../../hooks/usePsnAccount.js';
import useStartImport from '../../hooks/useStartImport.js'; import useStartImport from '../../hooks/useStartImport.js';
import useStartPsnImport from '../../hooks/useStartPsnImport.js';
import {readImportFile} from '../../utils/importFormats.js'; import {readImportFile} from '../../utils/importFormats.js';
import {parseTextList} from '../../utils/imports.js'; import {parseTextList} from '../../utils/imports.js';
import {MAX_IMPORT_ROWS} from '../../utils/limits.js'; import {MAX_IMPORT_ROWS} from '../../utils/limits.js';
import {isExpired} from '../../utils/psn.js';
import {bookmarkletHref, consoleScript} from '../../utils/storeList.js'; import {bookmarkletHref, consoleScript} from '../../utils/storeList.js';
const STORE_SITES = 'PlayStation, Xbox, Nintendo eShop, Epic Games, GOG'; const STORE_SITES = 'PlayStation, Xbox, Nintendo eShop, Epic Games, GOG';
@@ -23,11 +19,10 @@ const setBookmarklet = (link) => link?.setAttribute('href', bookmarkletHref(loca
/** /**
* @param storeRows the games the bookmarklet read on a store page, or null * @param storeRows the games the bookmarklet read on a store page, or null
* @param initialSource the tab to open, e.g. PSN; null for the default
*/ */
export default function ImportInput({storeRows, initialSource = null, onCreated}) { export default function ImportInput({storeRows, onCreated}) {
const {t} = useTranslation(); const {t} = useTranslation();
const [source, setSource] = useState(storeRows ? 'BOOKMARKLET' : initialSource ?? 'TEXT'); const [source, setSource] = useState(storeRows ? 'BOOKMARKLET' : 'TEXT');
const [text, setText] = useState(''); const [text, setText] = useState('');
const [csv, setCsv] = useState(null); const [csv, setCsv] = useState(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -49,14 +44,12 @@ export default function ImportInput({storeRows, initialSource = null, onCreated}
<Card className="gap-4 p-5"> <Card className="gap-4 p-5">
<p className="text-sm text-muted-foreground">{t('IMPORT.HINT')}</p> <p className="text-sm text-muted-foreground">{t('IMPORT.HINT')}</p>
<div role="group" aria-label={t('IMPORT.SOURCE')} className="flex flex-wrap gap-2"> <div role="group" aria-label={t('IMPORT.SOURCE')} className="flex flex-wrap gap-2">
{['TEXT', 'CSV', 'BOOKMARKLET', 'PSN'].map((value) => ( {['TEXT', 'CSV', 'BOOKMARKLET'].map((value) => (
<Button key={value} size="sm" variant={source === value ? 'default' : 'outline'} aria-pressed={source === value} <Button key={value} size="sm" variant={source === value ? 'default' : 'outline'} aria-pressed={source === value}
onClick={() => setSource(value)}>{t(`IMPORT.SOURCE_${value}`)}</Button> onClick={() => setSource(value)}>{t(`IMPORT.SOURCE_${value}`)}</Button>
))} ))}
</div> </div>
{source === 'PSN' ? ( {source === 'BOOKMARKLET' ? (
<PsnInput onCreated={onCreated}/>
) : source === 'BOOKMARKLET' ? (
<StoreInput rows={storeRows}/> <StoreInput rows={storeRows}/>
) : source === 'TEXT' ? ( ) : source === 'TEXT' ? (
<Textarea rows={10} value={text} aria-label={t('IMPORT.TEXT_LABEL')} placeholder={t('IMPORT.TEXT_PLACEHOLDER')} <Textarea rows={10} value={text} aria-label={t('IMPORT.TEXT_LABEL')} placeholder={t('IMPORT.TEXT_PLACEHOLDER')}
@@ -73,14 +66,14 @@ export default function ImportInput({storeRows, initialSource = null, onCreated}
<RowPreview rows={csv?.rows}/> <RowPreview rows={csv?.rows}/>
</div> </div>
)} )}
{source !== 'PSN' && <div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<span className={tooMany ? 'text-sm text-destructive' : 'text-sm text-muted-foreground'}> <span className={tooMany ? 'text-sm text-destructive' : 'text-sm text-muted-foreground'}>
{t(tooMany ? 'IMPORT.TOO_MANY' : 'IMPORT.LINES', {count: rows.length, max: MAX_IMPORT_ROWS})} {t(tooMany ? 'IMPORT.TOO_MANY' : 'IMPORT.LINES', {count: rows.length, max: MAX_IMPORT_ROWS})}
</span> </span>
<Button onClick={start} disabled={busy || rows.length === 0 || tooMany}> <Button onClick={start} disabled={busy || rows.length === 0 || tooMany}>
{busy ? <Loader2 className="animate-spin"/> : <Search/>}{t('IMPORT.START')} {busy ? <Loader2 className="animate-spin"/> : <Search/>}{t('IMPORT.START')}
</Button> </Button>
</div>} </div>
</Card> </Card>
); );
} }
@@ -124,40 +117,3 @@ function StoreInput({rows}) {
</div> </div>
); );
} }
/**
* The played PlayStation titles: Core reads them from the linked account, so there is nothing to type.
*/
function PsnInput({onCreated}) {
const {t} = useTranslation();
const {account, failed} = usePsnAccount();
const startImport = useStartPsnImport();
const [busy, setBusy] = useState(false);
const start = async () => {
setBusy(true);
const id = await startImport();
setBusy(false);
if (id) onCreated(id);
};
if (failed) return <p className="text-sm text-destructive">{t('SETTINGS.PSN.ERROR')}</p>;
if (!account) return <Loader2 className="animate-spin text-muted-foreground"/>;
const linked = account.enabled && account.linked;
if (!linked || isExpired(account)) {
return (
<p className="text-sm">
{t(linked ? 'IMPORT.PSN_RELINK' : 'IMPORT.PSN_NOT_LINKED')}{' '}
<Link to="/settings" className="underline">{t('IMPORT.PSN_SETTINGS')}</Link>
</p>
);
}
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">{t('IMPORT.PSN_HINT')}</p>
<Button onClick={start} disabled={busy}>
{busy ? <Loader2 className="animate-spin"/> : <Search/>}{t('IMPORT.START')}
</Button>
</div>
);
}
+13 -5
View File
@@ -49,7 +49,7 @@ export default function ImportReview({data, favorite, onSaved, onDone}) {
const response = await saveImport(data.id, chosen.map(({choice, row}) => ({ const response = await saveImport(data.id, chosen.map(({choice, row}) => ({
lineNo: row.lineNo, igdbId: choice.igdbId, platform: choice.platform.trim(), status: choice.status, lineNo: row.lineNo, igdbId: choice.igdbId, platform: choice.platform.trim(), status: choice.status,
playtimeMinutes: hoursToMinutes(choice.hours), playtimeMinutes: hoursToMinutes(choice.hours),
}))); })), data.rows.map((row) => row.lineNo));
if (response.status === 401) return; if (response.status === 401) return;
if (!response.ok) { if (!response.ok) {
toast.error(t('IMPORT.SAVE_FAILED')); toast.error(t('IMPORT.SAVE_FAILED'));
@@ -64,15 +64,21 @@ export default function ImportReview({data, favorite, onSaved, onDone}) {
} }
}; };
// A PlayStation review can be saved with nothing checked: the unchecked titles are not offered again.
const dismissesUnchecked = data.source === 'PSN';
return ( return (
<Card className="gap-0 overflow-hidden p-0"> <Card className="gap-0 overflow-hidden p-0">
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-5 py-4"> <div className="flex flex-wrap items-center justify-between gap-3 border-b px-5 py-4">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">{t('IMPORT.REVIEW_HINT')}</p> <p className="text-sm text-muted-foreground">{t('IMPORT.REVIEW_HINT')}</p>
{dismissesUnchecked && <p className="text-sm text-muted-foreground">{t('IMPORT.PSN_UNCHECKED_DISMISSED')}</p>}
</div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => checkAll(true)} disabled={busy}>{t('IMPORT.SELECT_ALL')}</Button> <Button variant="outline" onClick={() => checkAll(true)} disabled={busy}>{t('IMPORT.SELECT_ALL')}</Button>
<Button variant="outline" onClick={() => checkAll(false)} disabled={busy}>{t('IMPORT.SELECT_NONE')}</Button> <Button variant="outline" onClick={() => checkAll(false)} disabled={busy}>{t('IMPORT.SELECT_NONE')}</Button>
<Button variant="ghost" onClick={onDone} disabled={busy}>{t('IMPORT.NEW')}</Button> <Button variant="ghost" onClick={onDone} disabled={busy}>{t('IMPORT.NEW')}</Button>
<Button onClick={save} disabled={busy || chosen.length === 0}> <Button onClick={save} disabled={busy || (chosen.length === 0 && !dismissesUnchecked)}>
{busy && <Loader2 className="animate-spin"/>}{t('IMPORT.SAVE', {count: chosen.length})} {busy && <Loader2 className="animate-spin"/>}{t('IMPORT.SAVE', {count: chosen.length})}
</Button> </Button>
</div> </div>
@@ -80,14 +86,14 @@ export default function ImportReview({data, favorite, onSaved, onDone}) {
<ul className="divide-y"> <ul className="divide-y">
{choices.map((choice, index) => ( {choices.map((choice, index) => (
<ReviewRow key={data.rows[index].lineNo} index={index} row={data.rows[index]} choice={choice} <ReviewRow key={data.rows[index].lineNo} index={index} row={data.rows[index]} choice={choice}
onChange={change} onPick={pick}/> psn={dismissesUnchecked} onChange={change} onPick={pick}/>
))} ))}
</ul> </ul>
</Card> </Card>
); );
} }
const ReviewRow = memo(function ReviewRow({index, row, choice, onChange: changeLine, onPick: pickLine}) { const ReviewRow = memo(function ReviewRow({index, row, choice, psn, onChange: changeLine, onPick: pickLine}) {
const {t} = useTranslation(); const {t} = useTranslation();
const onChange = (changes) => changeLine(index, changes); const onChange = (changes) => changeLine(index, changes);
const onPick = (candidate) => pickLine(index, candidate); const onPick = (candidate) => pickLine(index, candidate);
@@ -117,7 +123,7 @@ const ReviewRow = memo(function ReviewRow({index, row, choice, onChange: changeL
{choice.candidates.length === 0 ? ( {choice.candidates.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('IMPORT.NO_MATCH')}</p> <p className="text-sm text-muted-foreground">{t('IMPORT.NO_MATCH')}</p>
) : ( ) : (
<div className="grid gap-2 sm:grid-cols-[2fr_1fr_1fr_6rem]"> <div className={`grid gap-2 ${psn ? 'sm:grid-cols-[2fr_1fr_1fr]' : 'sm:grid-cols-[2fr_1fr_1fr_6rem]'}`}>
<select className={SELECT_CLASS} aria-label={t('IMPORT.MATCH', {line: row.lineNo})} value={choice.igdbId ?? ''} <select className={SELECT_CLASS} aria-label={t('IMPORT.MATCH', {line: row.lineNo})} value={choice.igdbId ?? ''}
onChange={(e) => onPick(choice.candidates.find((known) => String(known.igdbId) === e.target.value))}> onChange={(e) => onPick(choice.candidates.find((known) => String(known.igdbId) === e.target.value))}>
{choice.candidates.map((known) => ( {choice.candidates.map((known) => (
@@ -143,8 +149,10 @@ const ReviewRow = memo(function ReviewRow({index, row, choice, onChange: changeL
onChange={(e) => onChange({status: e.target.value})}> onChange={(e) => onChange({status: e.target.value})}>
{GAME_STATUSES.map((status) => <option key={status} value={status}>{t(`BACKLOG.STATUS.${status}`)}</option>)} {GAME_STATUSES.map((status) => <option key={status} value={status}>{t(`BACKLOG.STATUS.${status}`)}</option>)}
</select> </select>
{!psn && (
<Input type="number" min="0" step="0.5" inputMode="decimal" value={choice.hours} <Input type="number" min="0" step="0.5" inputMode="decimal" value={choice.hours}
aria-label={t('IMPORT.HOURS', {line: row.lineNo})} onChange={(e) => onChange({hours: e.target.value})}/> aria-label={t('IMPORT.HOURS', {line: row.lineNo})} onChange={(e) => onChange({hours: e.target.value})}/>
)}
</div> </div>
)} )}
</li> </li>
@@ -0,0 +1,77 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import ImportReview from './ImportReview.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(), info: vi.fn()}}));
const wilds = {igdbId: 7, name: 'Monster Hunter Wilds', year: 2025, imageUrl: null, platforms: ['PC'], inLibrary: false};
const review = (source) => ({
id: 5, source, status: 'READY', total: 1, matched: 1,
rows: [{lineNo: 1, title: 'Monster Hunter Wilds', platform: 'PS5', playtimeMinutes: 30, status: null,
searchFailed: false, exact: false, candidates: [wilds]}],
});
const onSaved = vi.fn();
function renderReview(source) {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'POST /api/library/imports/5/save': json(202, {}),
});
return renderWithProviders(<ImportReview data={review(source)} favorite={null} onSaved={onSaved} onDone={vi.fn()}/>);
}
const savePosts = () => fetch.mock.calls.filter(([url, init]) => init?.method === 'POST' && url.endsWith('/5/save'));
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('ImportReview of a PlayStation import', () => {
it('tells that unchecked titles are not offered again', async () => {
renderReview('PSN');
expect(await screen.findByText('IMPORT.PSN_UNCHECKED_DISMISSED')).toBeTruthy();
});
it('offers no hours field, as PlayStation owns the playtime', async () => {
renderReview('PSN');
expect(await screen.findByLabelText(/IMPORT.PLATFORM/)).toBeTruthy();
expect(screen.queryByLabelText(/IMPORT.HOURS/)).toBeNull();
});
it('saves with nothing checked and posts no rows', async () => {
renderReview('PSN');
fireEvent.click(await screen.findByRole('button', {name: /IMPORT.SAVE/}));
await waitFor(() => expect(savePosts()).toHaveLength(1));
expect(JSON.parse(savePosts()[0][1].body)).toEqual({rows: [], shownLineNos: [1]});
await waitFor(() => expect(onSaved).toHaveBeenCalled());
});
});
describe('ImportReview of another import', () => {
it('has no dismiss hint, and saving needs a checked line', async () => {
renderReview('TEXT');
const save = await screen.findByRole('button', {name: /IMPORT.SAVE/});
expect(screen.queryByText('IMPORT.PSN_UNCHECKED_DISMISSED')).toBeNull();
expect(save.disabled).toBe(true);
});
it('offers the hours field', async () => {
renderReview('TEXT');
expect(await screen.findByLabelText(/IMPORT.HOURS/)).toBeTruthy();
});
});
+3 -2
View File
@@ -21,8 +21,9 @@ describe('Content Security Policy in nginx.conf', () => {
const hosts = new Set([...code.matchAll(/`https:\/\/([a-z0-9.-]+)\//g)].map((match) => match[1])); const hosts = new Set([...code.matchAll(/`https:\/\/([a-z0-9.-]+)\//g)].map((match) => match[1]));
// Only links: a navigation needs no img-src. // Only links: a navigation needs no img-src.
LINK_ONLY_HOSTS.forEach((host) => hosts.delete(host)); LINK_ONLY_HOSTS.forEach((host) => hosts.delete(host));
// Hosts the backends pass on: Steam avatars and achievement icons, IGDB pictures. // Hosts the backends pass on: Steam avatars and achievement icons, IGDB pictures, PlayStation trophy icons.
['avatars.steamstatic.com', 'steamcdn-a.akamaihd.net', 'media.steampowered.com', 'images.igdb.com'] ['avatars.steamstatic.com', 'steamcdn-a.akamaihd.net', 'media.steampowered.com', 'images.igdb.com',
'psnobj.prod.dl.playstation.net']
.forEach((host) => hosts.add(host)); .forEach((host) => hosts.add(host));
for (const host of hosts) { for (const host of hosts) {
+9 -5
View File
@@ -11,12 +11,14 @@ const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cde
// A card whose request fails or has no JSON body stays empty. // A card whose request fails or has no JSON body stays empty.
async function loadInto(path, set, pick = (data) => data) { async function loadInto(path, set, pick = (data) => data) {
const {ok, data} = await request(`${import.meta.env.VITE_CORE_API_URL}${path}`); const {ok, data} = await request(`${import.meta.env.VITE_CORE_API_URL}${path}`);
if (ok && data != null) set(pick(data)); if (!ok || data == null) return null;
set(pick(data));
return data;
} }
export default function useDashboardData() { export default function useDashboardData() {
const {reload: reloadUser} = useCurrentUserContext(); const {reload: reloadUser} = useCurrentUserContext();
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false, syncStarted: false}); const [steamData, setSteamData] = useState({count: 0, hasSteamId: false, canSync: false, syncStarted: false});
const [topGames, setTopGames] = useState([]); const [topGames, setTopGames] = useState([]);
const [playing, setPlaying] = useState([]); const [playing, setPlaying] = useState([]);
const [upNext, setUpNext] = useState([]); const [upNext, setUpNext] = useState([]);
@@ -60,9 +62,11 @@ export default function useDashboardData() {
const fetchUserData = async () => { const fetchUserData = async () => {
const {ok, status, data} = await reloadUser(); const {ok, status, data} = await reloadUser();
if (ok) { if (ok) {
// Games added by hand need no Steam ID; history and achievements come only from Steam. // Games added by hand need no Steam ID. The history and the achievements (Steam achievements,
await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchUpNext(), fetchSummary(), // PlayStation trophies) also come from a PlayStation link, which games-count reports as canSync.
...(data.steamId ? [fetchActivity(), fetchAchievements()] : [])]); const [counts] = await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchUpNext(),
fetchSummary(), ...(data.steamId ? [fetchActivity(), fetchAchievements()] : [])]);
if (!data.steamId && counts?.canSync) await Promise.all([fetchActivity(), fetchAchievements()]);
} else if (status === 0) { } else if (status === 0) {
setError('INFO.ERROR.NETWORK_FAILURE'); setError('INFO.ERROR.NETWORK_FAILURE');
} else if (status !== 401) { } else if (status !== 401) {
-41
View File
@@ -1,41 +0,0 @@
import {useCallback} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {startPsnImport} from '../utils/psn.js';
/**
* Returns `start()`: it asks Core to read the played PlayStation titles and create an import. Resolves to the import's
* id, or shows why not and resolves to null.
*/
export default function useStartPsnImport() {
const {t} = useTranslation();
return useCallback(async () => {
try {
const response = await startPsnImport();
if (response.status === 401) return null;
if (response.status === 204) {
toast.info(t('IMPORT.PSN_NO_TITLES'));
} else if (response.status === 404) {
toast.error(t('IMPORT.PSN_NOT_LINKED'));
} else if (response.status === 409) {
const body = await response.json().catch(() => null);
toast.error(t(body?.code === 'PSN_RELINK' ? 'IMPORT.PSN_RELINK' : 'IMPORT.RUNNING'));
} else if (response.status === 429) {
toast.error(t('IMPORT.PSN_BUSY'));
} else if (response.status === 502) {
toast.error(t('IMPORT.PSN_UNAVAILABLE'));
} else if (response.status === 503) {
toast.error(t('MANUAL_GAME.UNAVAILABLE'));
} else if (!response.ok) {
toast.error(t('IMPORT.START_FAILED'));
} else {
return (await response.json()).id;
}
} catch (err) {
console.error('PSN import start error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
}
return null;
}, [t]);
}
+8
View File
@@ -33,6 +33,14 @@ describe('admin broadcast texts', () => {
}); });
describe('translation keys', () => { describe('translation keys', () => {
it('importHistoryHasALabelForThePsnSource', () => {
const dir = path.resolve(import.meta.dirname, '../public/i18n');
for (const file of ['en.json', 'de.json']) {
const texts = JSON.parse(readFileSync(path.join(dir, file), 'utf8')).IMPORT;
expect(texts.SOURCE_PSN, file).toBeTruthy();
}
});
it('de_And_En_HaveTheSameKeys', () => { it('de_And_En_HaveTheSameKeys', () => {
const dir = path.resolve(import.meta.dirname, '../public/i18n'); const dir = path.resolve(import.meta.dirname, '../public/i18n');
const keys = (value, prefix = '') => Object.entries(value).flatMap(([key, child]) => const keys = (value, prefix = '') => Object.entries(value).flatMap(([key, child]) =>
+10 -8
View File
@@ -61,15 +61,17 @@ const Dashboard = () => {
}; };
const hasSteamId = !!userData?.steamId; const hasSteamId = !!userData?.steamId;
const showLibrary = hasSteamId || steamData.count > 0; // A PlayStation link syncs too, also without a Steam ID.
const canSync = hasSteamId || !!steamData.canSync;
const showLibrary = canSync || steamData.count > 0;
const {appearance, equipped} = usePreferences(); const {appearance, equipped} = usePreferences();
const steamProfile = useSteamProfile(hasSteamId); const steamProfile = useSteamProfile(hasSteamId);
const bannerAppid = appearance.showBanner const bannerAppid = appearance.showBanner
? appearance.bannerAppid ?? activity?.recentGames?.[0]?.appid ?? null ? appearance.bannerAppid ?? activity?.recentGames?.[0]?.appid ?? null
: null; : null;
const sync = useLibrarySync({ const sync = useLibrarySync({
enabled: hasSteamId, enabled: canSync,
expectInitialSync: hasSteamId && steamData.count === 0, expectInitialSync: canSync && steamData.count === 0,
onComplete: () => { onComplete: () => {
reloadLibrary(); reloadLibrary();
stats.reload(); stats.reload();
@@ -126,8 +128,8 @@ const Dashboard = () => {
</Button> </Button>
</Card> </Card>
); );
case 'ACTIVITY': return hasSteamId && activity && <ActivityCard activity={activity} charts={charts} onChart={change}/>; case 'ACTIVITY': return canSync && activity && <ActivityCard activity={activity} charts={charts} onChart={change}/>;
case 'ACHIEVEMENTS': return hasSteamId && achievements && <AchievementsCard summary={achievements}/>; case 'ACHIEVEMENTS': return canSync && achievements && <AchievementsCard summary={achievements}/>;
default: return statGroupTile(id, stats.stats, true, charts, change); default: return statGroupTile(id, stats.stats, true, charts, change);
} }
}; };
@@ -137,14 +139,14 @@ const Dashboard = () => {
<PageContainer className="p-4 md:p-8 space-y-6"> <PageContainer className="p-4 md:p-8 space-y-6">
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl} <DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
equipped={equipped} equipped={equipped}
sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/> sync={sync} forceSync={forceSync} canSync={canSync} editing={editing} onCustomize={() => setEditing(true)}/>
{hasSteamId && <SyncChangesBanner lastSyncedAt={sync.lastSyncedAt}/>} {canSync && <SyncChangesBanner lastSyncedAt={sync.lastSyncedAt}/>}
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing} <TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/> defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
<WhatsNewDialog/> <WhatsNewDialog/>
{/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */} {/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
<DashboardHint user={userData} summary={summaryFailed ? null : summary ?? undefined} <DashboardHint user={userData} canSync={canSync} summary={summaryFailed ? null : summary ?? undefined}
trend={stats.failed ? null : stats.stats ? stats.stats.trend ?? null : undefined}/> trend={stats.failed ? null : stats.stats ? stats.stats.trend ?? null : undefined}/>
</PageContainer> </PageContainer>
</StatDrillProvider> </StatDrillProvider>
+45
View File
@@ -234,6 +234,51 @@ describe('Dashboard', () => {
await waitFor(() => expect(watchSync).toHaveBeenCalledTimes(1)); await waitFor(() => expect(watchSync).toHaveBeenCalledTimes(1));
}); });
describe('of a user with a PlayStation link and no Steam ID', () => {
const psnOnlyRoutes = (canSync) => ({
...dashboardRoutes(),
'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo', settings: {}}),
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: false, canSync}),
});
const asked = (path) => fetch.mock.calls.some(([url]) => url.includes(path));
it('loads the activity, the achievements and the sync result, but no Steam profile', async () => {
fetchRoutes(psnOnlyRoutes(true));
renderDashboard();
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
await waitFor(() => expect(asked('/api/library/activity')).toBe(true));
await waitFor(() => expect(asked('/api/library/sync-changes')).toBe(true));
await waitFor(() => expect(asked('/api/library/achievements/summary')).toBe(true));
expect(asked('/api/library/steam-profile')).toBe(false);
});
it('shows the achievements card, which counts trophies too', async () => {
fetchRoutes(psnOnlyRoutes(true));
renderDashboard();
expect(await screen.findByText('DASHBOARD.ACHIEVEMENTS.UNLOCKED')).toBeTruthy();
});
it('shows the sync button of the welcome card', async () => {
fetchRoutes(psnOnlyRoutes(true));
renderDashboard();
expect(await screen.findByText('LIBRARY.RESYNC')).toBeTruthy();
});
it('asks for no activity and no sync result without a PlayStation link', async () => {
fetchRoutes(psnOnlyRoutes(false));
renderDashboard();
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
expect(asked('/api/library/activity')).toBe(false);
expect(asked('/api/library/sync-changes')).toBe(false);
expect(asked('/api/library/achievements/summary')).toBe(false);
expect(screen.queryByText('LIBRARY.RESYNC')).toBeNull();
});
});
it('does not watch a sync when the server started none', async () => { it('does not watch a sync when the server started none', async () => {
fetchRoutes(dashboardRoutes()); fetchRoutes(dashboardRoutes());
renderDashboard(); renderDashboard();
+10 -10
View File
@@ -1,7 +1,7 @@
import {useEffect, useRef, useState} from 'react'; import {useEffect, useRef, useState} from 'react';
import {Link, useNavigate, useParams} from 'react-router-dom'; import {Link, useNavigate, useParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {ArrowLeft, EyeOff, ShieldOff, Trophy} from 'lucide-react'; import {ArrowLeft, EyeOff, Trophy} from 'lucide-react';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
@@ -24,9 +24,10 @@ import useGame from '../hooks/useGame.js';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import {canCorrect} from '../utils/corrections.js'; import {canCorrect} from '../utils/corrections.js';
import {celebrationFor, celebrationLevel} from '../utils/confetti.js'; import {celebrationFor, celebrationLevel} from '../utils/confetti.js';
import {formatDate} from '../utils/format.js';
export default function GameDetail() { export default function GameDetail() {
const {t} = useTranslation(); const {t, i18n} = useTranslation();
const {appid} = useParams(); const {appid} = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -87,6 +88,12 @@ export default function GameDetail() {
onHiddenChanged={(hidden) => setData((d) => ({...d, hidden}))} onHiddenChanged={(hidden) => setData((d) => ({...d, hidden}))}
onProfileStyleSaved={setSavedProfileStyle}/> onProfileStyleSaved={setSavedProfileStyle}/>
{data.lastPlayedAt && (
<p className="text-sm text-muted-foreground">
{t('GAME_DETAIL.LAST_PLAYED', {date: formatDate(data.lastPlayedAt, i18n.language, {dateStyle: 'medium'})})}
</p>
)}
{/* The finished question also answers "are you playing it?". */} {/* The finished question also answers "are you playing it?". */}
{!(data.finishedHint && ['BACKLOG', 'PLAYING'].includes(data.status)) && ( {!(data.finishedHint && ['BACKLOG', 'PLAYING'].includes(data.status)) && (
<PlayingHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/> <PlayingHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
@@ -115,14 +122,7 @@ export default function GameDetail() {
</Card> </Card>
)} )}
{data.hasAchievements && !data.statsVisible && ( {data.hasAchievements && <GameAchievements game={data}/>}
<Card className="items-center py-10 text-center text-sm text-muted-foreground">
<ShieldOff className="size-8"/>
{t('GAME_DETAIL.STATS_HIDDEN')}
</Card>
)}
{data.hasAchievements && data.statsVisible && <GameAchievements game={data}/>}
</> </>
)} )}
{celebration && <Confetti key={celebration.id} size={celebration.size} onDone={() => setCelebration(null)}/>} {celebration && <Confetti key={celebration.id} size={celebration.size} onDone={() => setCelebration(null)}/>}
+40 -2
View File
@@ -129,9 +129,11 @@ describe('GameDetail', () => {
it('reports a broken achievement icon once and shows the empty tile', async () => { it('reports a broken achievement icon once and shows the empty tile', async () => {
const achievement = (apiName) => ({apiName, displayName: apiName, unlocked: false, iconUrl: `https://x/${apiName}.jpg`, const achievement = (apiName) => ({apiName, displayName: apiName, unlocked: false, iconUrl: `https://x/${apiName}.jpg`,
iconGrayUrl: `https://x/${apiName}_gray.jpg`}); iconGrayUrl: `https://x/${apiName}_gray.jpg`, kind: 'STEAM', setId: null});
answer({...game, hasAchievements: true, statsVisible: true, achievementsTotal: 2, achievementsUnlocked: 0, answer({...game, hasAchievements: true, statsVisible: true, achievementsTotal: 2, achievementsUnlocked: 0,
completionPercent: 0, achievements: [achievement('A'), achievement('B')]}, {'POST /api/library/broken-images': json(204)}); completionPercent: 0, achievements: [achievement('A'), achievement('B')],
achievementSets: [{id: 'steam', kind: 'STEAM', total: 2, unlocked: 0, perfect: false, visible: true, listed: true}]},
{'POST /api/library/broken-images': json(204)});
const {container} = renderDetail(); const {container} = renderDetail();
await screen.findByText('A'); await screen.findByText('A');
@@ -144,6 +146,19 @@ describe('GameDetail', () => {
expect(container.querySelector('img[src^="https://x/"]')).toBeNull(); expect(container.querySelector('img[src^="https://x/"]')).toBeNull();
}); });
it('shows the trophies of a PlayStation game and not the no-achievements card', async () => {
answer({...game, source: 'PSN', hasAchievements: true, statsVisible: true,
achievements: [{apiName: '0', displayName: 'Legend', unlocked: true, kind: 'PSN', setId: 'NPWR1_00',
trophyType: 'PLATINUM', iconUrl: 'https://psnobj.prod.dl.playstation.net/0.png'}],
achievementSets: [{id: 'NPWR1_00', kind: 'PSN', platform: 'PS5', name: 'Ghost', total: 1, unlocked: 1,
perfect: true, visible: true, listed: true}]});
renderDetail();
expect(await screen.findByText('Legend')).toBeTruthy();
expect(screen.getByText('GAME_DETAIL.TROPHIES')).toBeTruthy();
expect(screen.queryByText('GAME_DETAIL.NO_ACHIEVEMENTS')).toBeNull();
});
it('links the speedrun.com page when Core found one', async () => { it('links the speedrun.com page when Core found one', async () => {
answer({...game, speedrunUrl: 'https://www.speedrun.com/hl2'}); answer({...game, speedrunUrl: 'https://www.speedrun.com/hl2'});
renderDetail(); renderDetail();
@@ -249,6 +264,29 @@ describe('GameDetail of a game added by hand', () => {
expect(screen.getByText('MANUAL_GAME.NO_ACHIEVEMENTS')).toBeTruthy(); expect(screen.getByText('MANUAL_GAME.NO_ACHIEVEMENTS')).toBeTruthy();
}); });
it('psnRowOffersNoHoursField', async () => {
answer({...manualGame, source: 'PSN', platform: 'PS5'});
renderDetail(-1025);
expect(await screen.findByLabelText('MANUAL_GAME.PLATFORM')).toBeTruthy();
expect(screen.queryByLabelText('MANUAL_GAME.HOURS')).toBeNull();
});
it('shows when a PlayStation game was last played, and nothing for a game without that date', async () => {
answer({...manualGame, source: 'PSN', platform: 'PS5', lastPlayedAt: '2026-10-05T18:30:00Z'},
{'PUT /api/library/games/-1025/manual': noBody, 'DELETE /api/library/games/-1025': noBody});
renderDetail(-1025);
expect(await screen.findByText('GAME_DETAIL.LAST_PLAYED')).toBeTruthy();
cleanup();
answer({...manualGame, source: 'PSN', platform: 'PS5', lastPlayedAt: null});
renderDetail(-1025);
expect(await screen.findByLabelText('MANUAL_GAME.PLATFORM')).toBeTruthy();
expect(screen.queryByText('GAME_DETAIL.LAST_PLAYED')).toBeNull();
});
it('saves platform and hours', async () => { it('saves platform and hours', async () => {
renderDetail(-1025); renderDetail(-1025);
fireEvent.change(await screen.findByLabelText('MANUAL_GAME.HOURS'), {target: {value: '2.5'}}); fireEvent.change(await screen.findByLabelText('MANUAL_GAME.HOURS'), {target: {value: '2.5'}});
+1 -1
View File
@@ -22,7 +22,7 @@ beforeEach(() => {
'GET /api/users/me': json(200, {settings: {}}), 'GET /api/users/me': json(200, {settings: {}}),
'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {COMPLETED_EFFECT: 'FX_GOLD_SHINE'}, history: []}), 'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {COMPLETED_EFFECT: 'FX_GOLD_SHINE'}, history: []}),
'GET /api/library/games': json(200, {content: games, page: 0, size: 20, totalElements: 2, totalPages: 1}), 'GET /api/library/games': json(200, {content: games, page: 0, size: 20, totalElements: 2, totalPages: 1}),
'GET /api/library/games-count': json(200, {count: 2, hasSteamId: true}), 'GET /api/library/games-count': json(200, {count: 2, hasSteamId: true, canSync: true}),
}); });
}); });
+8 -6
View File
@@ -14,6 +14,7 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import {cn} from '@/lib/utils';
import {Skeleton} from '@/components/ui/skeleton'; import {Skeleton} from '@/components/ui/skeleton';
import AddGamePanel from '../components/AddGamePanel.jsx'; import AddGamePanel from '../components/AddGamePanel.jsx';
import GameCover from '../components/GameCover.jsx'; import GameCover from '../components/GameCover.jsx';
@@ -65,8 +66,9 @@ export default function GameLibrary() {
const filtered = Boolean(listStatus || q.trim() || listShortOnly || hiddenOnly || listHideDuplicates); const filtered = Boolean(listStatus || q.trim() || listShortOnly || hiddenOnly || listHideDuplicates);
const [search, setSearch] = useState(q); const [search, setSearch] = useState(q);
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
// Starts true until /games-count answers, so a Steam user does not see the sync controls vanish and come back. // A Steam ID or a PlayStation link. Starts true until /games-count answers, so the sync controls do not vanish
const [hasSteamId, setHasSteamId] = useState(true); // and come back.
const [canSync, setCanSync] = useState(true);
const [data, setData] = useState({content: [], page: 0, size, totalElements: 0, totalPages: 0}); const [data, setData] = useState({content: [], page: 0, size, totalElements: 0, totalPages: 0});
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -98,7 +100,7 @@ export default function GameLibrary() {
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`) apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`)
.then((response) => (response.ok ? response.json() : null)) .then((response) => (response.ok ? response.json() : null))
.then((json) => { .then((json) => {
if (!cancelled && json) setHasSteamId(json.hasSteamId); if (!cancelled && json) setCanSync(json.canSync);
}) })
.catch(() => {}); .catch(() => {});
return () => { return () => {
@@ -108,7 +110,7 @@ export default function GameLibrary() {
const expectInitialSync = !isLoading && !error && !filtered && data.totalElements === 0; const expectInitialSync = !isLoading && !error && !filtered && data.totalElements === 0;
const sync = useLibrarySync({ const sync = useLibrarySync({
enabled: hasSteamId, enabled: canSync,
expectInitialSync, expectInitialSync,
onComplete: load, onComplete: load,
}); });
@@ -164,7 +166,7 @@ export default function GameLibrary() {
<LibraryTabs/> <LibraryTabs/>
<h1 className="text-3xl font-bold tracking-tight">{t('LIBRARY.TITLE')}</h1> <h1 className="text-3xl font-bold tracking-tight">{t('LIBRARY.TITLE')}</h1>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{hasSteamId && ( {canSync && (
<> <>
<SyncStatusBadge <SyncStatusBadge
status={sync.status} status={sync.status}
@@ -322,7 +324,7 @@ export default function GameLibrary() {
{g.achievementsTotal > 0 && g.achievementsUnlocked != null && ( {g.achievementsTotal > 0 && g.achievementsUnlocked != null && (
<Badge variant="secondary" className="hidden sm:inline-flex tabular-nums" <Badge variant="secondary" className="hidden sm:inline-flex tabular-nums"
title={t('LIBRARY.ACHIEVEMENTS', {unlocked: g.achievementsUnlocked, total: g.achievementsTotal})}> title={t('LIBRARY.ACHIEVEMENTS', {unlocked: g.achievementsUnlocked, total: g.achievementsTotal})}>
<Trophy className="size-3"/> <Trophy className={cn('size-3', g.achievementsPerfect && 'text-amber-500')}/>
{g.achievementsUnlocked}/{g.achievementsTotal} {g.achievementsUnlocked}/{g.achievementsTotal}
</Badge> </Badge>
)} )}
+20 -4
View File
@@ -17,10 +17,10 @@ vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), b
const DEBOUNCE_WAIT_MS = 700; const DEBOUNCE_WAIT_MS = 700;
const page = {content: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG'}], page: 0, size: 20, totalElements: 1, totalPages: 1}; const page = {content: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG'}], page: 0, size: 20, totalElements: 1, totalPages: 1};
const answer = (games = page.content, hasSteamId = true) => fetchRoutes({ const answer = (games = page.content, canSync = true, hasSteamId = canSync) => fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}), 'GET /api/users/me': json(200, {settings: {}}),
'GET /api/library/games': json(200, {...page, content: games, totalElements: games.length, totalPages: games.length ? 1 : 0}), 'GET /api/library/games': json(200, {...page, content: games, totalElements: games.length, totalPages: games.length ? 1 : 0}),
'GET /api/library/games-count': json(200, {count: games.length, hasSteamId}), 'GET /api/library/games-count': json(200, {count: games.length, hasSteamId, canSync}),
}); });
const wait = (ms) => act(() => new Promise((resolve) => setTimeout(resolve, ms))); const wait = (ms) => act(() => new Promise((resolve) => setTimeout(resolve, ms)));
@@ -87,12 +87,20 @@ describe('GameLibrary search', () => {
expect(screen.queryByText(/\/5$/)).toBeNull(); expect(screen.queryByText(/\/5$/)).toBeNull();
}); });
it('shows the real count of a game with a platinum trophy and marks it perfect', async () => {
answer([{...page.content[0], achievementsUnlocked: 30, achievementsTotal: 50, achievementsPerfect: true}]);
renderLibrary();
const badge = (await screen.findByText('30/50')).closest('[data-slot="badge"]');
expect(badge.querySelector('svg.text-amber-500')).not.toBeNull();
});
it('shows the newest search when an older answer comes last', async () => { it('shows the newest search when an older answer comes last', async () => {
const later = []; const later = [];
const games = (name) => json(200, {...page, content: [{...page.content[0], name}], totalElements: 1, totalPages: 1}); const games = (name) => json(200, {...page, content: [{...page.content[0], name}], totalElements: 1, totalPages: 1});
fetchRoutes({ fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}), 'GET /api/users/me': json(200, {settings: {}}),
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true}), 'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true, canSync: true}),
'GET /api/library/games': (url, init) => { 'GET /api/library/games': (url, init) => {
const q = new URL(url, 'http://x').searchParams.get('q'); const q = new URL(url, 'http://x').searchParams.get('q');
if (!q) return games('Half-Life 2'); if (!q) return games('Half-Life 2');
@@ -263,7 +271,15 @@ describe('GameLibrary with games added by hand', () => {
expect(screen.getByRole('link', {name: 'IMPORT.OPEN'}).getAttribute('href')).toBe('/import'); expect(screen.getByRole('link', {name: 'IMPORT.OPEN'}).getAttribute('href')).toBe('/import');
}); });
it('hides the Steam sync for a user without a Steam ID but still offers adding a game', async () => { it('shows the sync for a user with a PlayStation link and no Steam ID', async () => {
answer([zelda], true, false);
renderLibrary();
await screen.findByText('Zelda');
expect(await screen.findByText('LIBRARY.RESYNC')).toBeTruthy();
});
it('hides the sync for a user who has neither a Steam ID nor a PlayStation link but still offers adding a game', async () => {
answer([zelda], false); answer([zelda], false);
renderLibrary(); renderLibrary();
+2 -3
View File
@@ -14,7 +14,7 @@ import {decodeStoreList} from '../utils/storeList.js';
/** /**
* Many games at once: a list or a CSV goes to Core, which matches it with IGDB in the background; the user checks * Many games at once: a list or a CSV goes to Core, which matches it with IGDB in the background; the user checks
* the matches and saves them. ?id= opens one import; ?source=PSN opens the PlayStation input; the history below can * the matches and saves them. ?id= opens one import (also the PlayStation review the sync made); the history below can
* undo a saved import. #data= is the list the bookmarklet read on a store page. * undo a saved import. #data= is the list the bookmarklet read on a store page.
*/ */
export default function ImportPage() { export default function ImportPage() {
@@ -84,8 +84,7 @@ export default function ImportPage() {
<ImportView key={shownId} id={shownId} onDone={done} onChanged={loadImports}/> <ImportView key={shownId} id={shownId} onDone={done} onChanged={loadImports}/>
) : ( ) : (
// A store list comes after the first answer: a new key opens its tab. // A store list comes after the first answer: a new key opens its tab.
<ImportInput key={storeRows ? 'store' : 'input'} storeRows={storeRows} <ImportInput key={storeRows ? 'store' : 'input'} storeRows={storeRows} onCreated={(created) => {
initialSource={searchParams.get('source') === 'PSN' ? 'PSN' : null} onCreated={(created) => {
setStoreRows(null); setStoreRows(null);
show(created); show(created);
loadImports(); loadImports();
-102
View File
@@ -1,102 +0,0 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import {toast} from 'sonner';
import ImportPage from './ImportPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn(), info: vi.fn()}}));
const linked = {enabled: true, linked: true, linkedAt: '2026-10-01T10:00:00Z', expiresAt: new Date(Date.now() + 5 * 86_400_000).toISOString()};
function renderPage(routes, entry = '/import?source=PSN') {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {favoritePlatform: null}}),
'GET /api/library/imports': json(200, {imports: []}),
'GET /api/library/psn-account': json(200, linked),
...routes,
});
render(
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path="/import" element={<PreferencesProvider><CurrentUserProvider><ImportPage/></CurrentUserProvider></PreferencesProvider>}/>
<Route path="/settings" element={<p>settings page</p>}/>
</Routes>
</MemoryRouter>,
);
}
const startButton = () => screen.findByRole('button', {name: 'IMPORT.START'});
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('ImportPage PlayStation source', () => {
it('opens on the PlayStation tab for ?source=PSN', async () => {
renderPage({});
expect(await screen.findByText('IMPORT.PSN_HINT')).toBeTruthy();
expect(screen.getByRole('button', {name: 'IMPORT.SOURCE_PSN'}).getAttribute('aria-pressed')).toBe('true');
});
it('opens on the list without the parameter', async () => {
renderPage({}, '/import');
expect(await screen.findByLabelText('IMPORT.TEXT_LABEL')).toBeTruthy();
});
it('points to the settings when no account is linked', async () => {
renderPage({'GET /api/library/psn-account': json(200, {enabled: true, linked: false})});
expect(await screen.findByText(/IMPORT.PSN_NOT_LINKED/)).toBeTruthy();
expect(screen.queryByRole('button', {name: 'IMPORT.START'})).toBeNull();
fireEvent.click(screen.getByRole('link', {name: 'IMPORT.PSN_SETTINGS'}));
expect(await screen.findByText('settings page')).toBeTruthy();
});
it('points to the settings when the link has ended', async () => {
renderPage({'GET /api/library/psn-account': json(200, {...linked, expiresAt: '2020-01-01T00:00:00Z'})});
expect(await screen.findByText(/IMPORT.PSN_RELINK/)).toBeTruthy();
expect(screen.queryByRole('button', {name: 'IMPORT.START'})).toBeNull();
});
it('starts the import at the PlayStation endpoint without sending rows, and shows its progress', async () => {
renderPage({
'POST /api/library/psn-account/imports': json(202, {id: 9}),
'GET /api/library/imports/9': json(200, {id: 9, source: 'PSN', status: 'MATCHING', total: 4, matched: 1}),
});
fireEvent.click(await startButton());
expect(await screen.findByText('IMPORT.MATCHING')).toBeTruthy();
const [, init] = fetch.mock.calls.find(([url, call]) => call?.method === 'POST' && url.endsWith('/psn-account/imports'));
expect(init.body).toBeUndefined();
});
it.each([
[204, {}, 'info', 'IMPORT.PSN_NO_TITLES'],
[404, {}, 'error', 'IMPORT.PSN_NOT_LINKED'],
[409, {code: 'PSN_RELINK'}, 'error', 'IMPORT.PSN_RELINK'],
[409, {code: 'IMPORT_RUNNING'}, 'error', 'IMPORT.RUNNING'],
[429, {code: 'PSN_BUSY'}, 'error', 'IMPORT.PSN_BUSY'],
[502, {}, 'error', 'IMPORT.PSN_UNAVAILABLE'],
[503, {}, 'error', 'MANUAL_GAME.UNAVAILABLE'],
])('answers status %i %j with a toast', async (status, body, kind, key) => {
renderPage({'POST /api/library/psn-account/imports': json(status, body)});
fireEvent.click(await startButton());
await waitFor(() => expect(toast[kind]).toHaveBeenCalledWith(key));
expect(screen.getByText('IMPORT.PSN_HINT')).toBeTruthy();
});
});
+1 -1
View File
@@ -259,7 +259,7 @@ describe('ImportPage', () => {
expect(JSON.parse(posted('POST', '/5/save')[1].body)).toEqual({rows: [ expect(JSON.parse(posted('POST', '/5/save')[1].body)).toEqual({rows: [
{lineNo: 1, igdbId: 1, platform: 'Switch', status: 'BACKLOG', playtimeMinutes: 0}, {lineNo: 1, igdbId: 1, platform: 'Switch', status: 'BACKLOG', playtimeMinutes: 0},
{lineNo: 3, igdbId: 1, platform: 'Switch', status: 'PLAYING', playtimeMinutes: 90}, {lineNo: 3, igdbId: 1, platform: 'Switch', status: 'PLAYING', playtimeMinutes: 90},
]}); ], shownLineNos: [1, 2, 3]});
}); });
it('checks every line with a new match at once, and none', async () => { it('checks every line with a new match at once, and none', async () => {
+11 -2
View File
@@ -1,5 +1,6 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {Link} from 'react-router-dom';
import {Loader2} from 'lucide-react'; import {Loader2} from 'lucide-react';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
@@ -8,8 +9,10 @@ import PageContainer from '../components/PageContainer.jsx';
import {formatDate} from '../utils/format.js'; import {formatDate} from '../utils/format.js';
import {digestParts, fetchInbox, markInboxRead} from '../utils/inbox.js'; import {digestParts, fetchInbox, markInboxRead} from '../utils/inbox.js';
const PSN_KINDS = ['PSN_EXPIRING', 'PSN_EXPIRED'];
/** /**
* The newest notes about the people the user follows. Opening the page marks them read. * The newest notes about the people the user follows and about the PlayStation link. Opening the page marks them read.
*/ */
export default function InboxPage() { export default function InboxPage() {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
@@ -32,6 +35,7 @@ export default function InboxPage() {
}, []); }, []);
const text = (note) => { const text = (note) => {
if (PSN_KINDS.includes(note.kind)) return t(`INBOX.${note.kind}`);
const name = note.displayName ?? t('FOLLOW.PRIVATE_USER'); const name = note.displayName ?? t('FOLLOW.PRIVATE_USER');
if (note.kind === 'VISIBILITY') return t('INBOX.VISIBILITY', {name}); if (note.kind === 'VISIBILITY') return t('INBOX.VISIBILITY', {name});
const parts = digestParts(note).map(({key, count}) => t(`INBOX.PART_${key}`, {count})).join(', '); const parts = digestParts(note).map(({key, count}) => t(`INBOX.PART_${key}`, {count})).join(', ');
@@ -55,7 +59,12 @@ export default function InboxPage() {
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{formatDate(note.createdAt, i18n.language, {dateStyle: 'medium'})} {formatDate(note.createdAt, i18n.language, {dateStyle: 'medium'})}
</p> </p>
<p className={cn('break-words text-sm', !note.read && 'font-semibold')}>{text(note)}</p> <p className={cn('break-words text-sm', !note.read && 'font-semibold')}>
{text(note)}
{PSN_KINDS.includes(note.kind) && (
<> <Link to="/settings" className="underline">{t('INBOX.OPEN_SETTINGS')}</Link></>
)}
</p>
</li> </li>
))} ))}
</ul> </ul>
+18
View File
@@ -34,6 +34,24 @@ describe('InboxPage', () => {
expect(await screen.findByText('INBOX.VISIBILITY')).toBeTruthy(); expect(await screen.findByText('INBOX.VISIBILITY')).toBeTruthy();
}); });
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}),
'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.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})});
renderWithProviders(<InboxPage/>);
expect(await screen.findByText('INBOX.VISIBILITY')).toBeTruthy();
expect(screen.queryByRole('link', {name: 'INBOX.OPEN_SETTINGS'})).toBeNull();
});
it('saysSoWhenThereAreNoNotesAndMarksNothing', async () => { it('saysSoWhenThereAreNoNotesAndMarksNothing', async () => {
fetchRoutes({'GET /api/inbox': json(200, {results: [], unread: 0})}); fetchRoutes({'GET /api/inbox': json(200, {results: [], unread: 0})});
renderWithProviders(<InboxPage/>); renderWithProviders(<InboxPage/>);
+3 -2
View File
@@ -15,8 +15,9 @@ export const listImports = () => apiFetch(IMPORTS_URL());
export const getImport = (id) => apiFetch(`${IMPORTS_URL()}/${id}`); export const getImport = (id) => apiFetch(`${IMPORTS_URL()}/${id}`);
export const saveImport = (id, rows) => // shownLineNos: the lines the user saw; a PlayStation review keeps the others open (the sync may add lines meanwhile).
apiFetch(`${IMPORTS_URL()}/${id}/save`, {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({rows})}); export const saveImport = (id, rows, shownLineNos) =>
apiFetch(`${IMPORTS_URL()}/${id}/save`, {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({rows, shownLineNos})});
export const retryImport = (id) => apiFetch(`${IMPORTS_URL()}/${id}/retry`, {method: 'POST'}); export const retryImport = (id) => apiFetch(`${IMPORTS_URL()}/${id}/retry`, {method: 'POST'});
+2 -1
View File
@@ -4,7 +4,8 @@ const INBOX = () => `${import.meta.env.VITE_CORE_API_URL}/api/inbox`;
/** /**
* GET /api/inbox: `{results: [{id, kind, displayName, newGames, achievements, statusChanges, wishlistAdds, createdAt, * GET /api/inbox: `{results: [{id, kind, displayName, newGames, achievements, statusChanges, wishlistAdds, createdAt,
* read}], unread}`, the newest 50 notes. `kind` is `DIGEST` or `VISIBILITY`; `unread` counts all unread notes. * read}], unread}`, the newest 50 notes. `kind` is `DIGEST`, `VISIBILITY`, `PSN_EXPIRING` or `PSN_EXPIRED` (the PSN
* kinds have no `displayName`); `unread` counts all unread notes.
*/ */
export const fetchInbox = () => request(INBOX()); export const fetchInbox = () => request(INBOX());
-2
View File
@@ -11,8 +11,6 @@ export const linkPsn = (npsso) =>
export const unlinkPsn = () => apiFetch(PSN_URL(), {method: 'DELETE'}); export const unlinkPsn = () => apiFetch(PSN_URL(), {method: 'DELETE'});
export const startPsnImport = () => apiFetch(`${PSN_URL()}/imports`, {method: 'POST'});
// Where a signed-in user reads the NPSSO value. // Where a signed-in user reads the NPSSO value.
export const NPSSO_PAGE_URL = 'https://ca.account.sony.com/api/v1/ssocookie'; export const NPSSO_PAGE_URL = 'https://ca.account.sony.com/api/v1/ssocookie';