Public Access
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7b08cfb47 | ||
|
|
27c402fa34 |
No files matched your search
@@ -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
|
||||
days ("this month: +N added, -N off the pile") and a random suggestion,
|
||||
Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked
|
||||
total, perfect games, average completion and the 5 games closest to 100 %.
|
||||
total, perfect games, average completion and the 5 games closest to 100 %. The card counts PlayStation trophies too
|
||||
(platinum = perfect) and also shows for a user with only a PlayStation link.
|
||||
- **Coming games**: a game that is not released yet (and nobody played) shows a "Coming <date>" badge in the library, on
|
||||
the details page, in the stat game lists and on the public profile. The pile counts only the games Core sends in
|
||||
`pileGames`, so coming games are not on it; Core never picks them.
|
||||
@@ -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
|
||||
settings is preselected when a game is on it; otherwise a game on one platform gets that one, and a game on
|
||||
several waits for the user's pick.
|
||||
- **PlayStation trophies** (slice 3): the game detail shows one card per set, Steam first, then PS5 and PS4
|
||||
(`GameAchievements`, grouped by `achievementSets`). A trophy has its type (bronze to platinum), rarity and a DLC tag;
|
||||
a hidden trophy shows "Hidden trophy" until it is earned; a set whose list is not stored yet says it loads with the
|
||||
next sync. The sync banner and inbox text say "achievements or trophies". Library tiles show the best set of a game.
|
||||
- **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
|
||||
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
|
||||
user"). Core writes one inbox note per followed user and batch (new games, achievements, status changes, wishlist
|
||||
adds; what the profile hides is left out). `/inbox` (bell in the header, with the unread count) lists the newest 50 notes
|
||||
and marks them read; a 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"
|
||||
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`.
|
||||
@@ -200,7 +207,8 @@ flowchart LR
|
||||
Core --> Igdb["IGDB API"]
|
||||
Core -->|"OAuth token"| Twitch["Twitch"]
|
||||
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 -.->|"bookmarklet, the user's login"| Stores["PlayStation, Xbox, Nintendo, Epic, GOG"]
|
||||
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.
|
||||
- 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).
|
||||
- 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.
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
# An nginx template: AUTH_HOST and API_HOST come from the container env at start (see Dockerfile).
|
||||
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 Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
+27
-17
@@ -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.",
|
||||
"NPSSO_LABEL": "NPSSO-Code",
|
||||
"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}}",
|
||||
"EXPIRES_ON": "Verknüpfung endet am {{date}}",
|
||||
"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",
|
||||
"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.",
|
||||
@@ -298,6 +299,7 @@
|
||||
"ERROR_LOAD": "Spiel konnte nicht geladen werden.",
|
||||
"NOT_FOUND": "Spiel nicht in deiner Bibliothek.",
|
||||
"PLAYTIME": "Spielzeit: {{time}}",
|
||||
"LAST_PLAYED": "Zuletzt gespielt {{date}}",
|
||||
"OPEN_IN_STEAM": "Auf Steam ansehen",
|
||||
"HIDE": "Spiel ausblenden",
|
||||
"UNHIDE": "Spiel wieder anzeigen",
|
||||
@@ -310,8 +312,19 @@
|
||||
"UNLOCKED": "Erreicht",
|
||||
"REMAINING": "Offen",
|
||||
"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.",
|
||||
"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.",
|
||||
"SET_PLAYING": "Auf „Spiele ich“ setzen",
|
||||
"TTB": "Spieldauer laut IGDB",
|
||||
@@ -526,7 +539,7 @@
|
||||
"REMOVE_YES": "Ja, entfernen",
|
||||
"REMOVE_NO": "Abbrechen",
|
||||
"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",
|
||||
"SELECTED": "{{count}} ausgewählt",
|
||||
"ADD_SELECTED": "Prüfen und hinzufügen"
|
||||
@@ -931,10 +944,13 @@
|
||||
"LOAD_ERROR": "Das Postfach konnte nicht geladen werden.",
|
||||
"DIGEST": "{{name}}: {{parts}}",
|
||||
"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_other": "{{count}} neue Spiele",
|
||||
"PART_ACHIEVEMENTS_one": "{{count}} Erfolg",
|
||||
"PART_ACHIEVEMENTS_other": "{{count}} Erfolge",
|
||||
"PART_ACHIEVEMENTS_one": "{{count}} Erfolg oder Trophäe",
|
||||
"PART_ACHIEVEMENTS_other": "{{count}} Erfolge oder Trophäen",
|
||||
"PART_STATUS_CHANGES_one": "{{count}} Statuswechsel",
|
||||
"PART_STATUS_CHANGES_other": "{{count}} Statuswechsel",
|
||||
"PART_WISHLIST_ADDS_one": "{{count}} Wunschlisten-Eintrag",
|
||||
@@ -1049,6 +1065,7 @@
|
||||
"SOURCE": "Eingabe",
|
||||
"SOURCE_TEXT": "Liste",
|
||||
"SOURCE_CSV": "CSV-Datei",
|
||||
"SOURCE_PSN": "PlayStation",
|
||||
"SOURCE_SEARCH": "Suche",
|
||||
"TEXT_LABEL": "Spieltitel",
|
||||
"TEXT_PLACEHOLDER": "Ein Spiel pro Zeile",
|
||||
@@ -1060,13 +1077,7 @@
|
||||
"START": "Spiele suchen",
|
||||
"START_FAILED": "Der Import konnte nicht starten.",
|
||||
"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_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",
|
||||
"PSN_UNCHECKED_DISMISSED": "Nicht angehakte PlayStation-Titel werden dir nicht noch einmal angeboten.",
|
||||
"LOAD_FAILED": "Der Import konnte nicht geladen werden.",
|
||||
"MATCHING": "Spiele werden gesucht: {{matched}} / {{total}}",
|
||||
"MATCHING_HINT": "Du kannst die Seite verlassen und später wiederkommen.",
|
||||
@@ -1130,7 +1141,6 @@
|
||||
"SOURCE_HLTB": "HowLongToBeat",
|
||||
"SOURCE_PLAYNITE": "Playnite",
|
||||
"SOURCE_BOOKMARKLET": "Store-Seite",
|
||||
"SOURCE_PSN": "PlayStation",
|
||||
"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_LINK": "To Be Played Import",
|
||||
@@ -1265,8 +1275,8 @@
|
||||
"REMOVED_one": "{{count}} Spiel entfernt",
|
||||
"REMOVED_other": "{{count}} Spiele entfernt",
|
||||
"PLAYTIME": "{{time}} gespielt",
|
||||
"ACHIEVEMENTS_one": "{{count}} neuer Erfolg",
|
||||
"ACHIEVEMENTS_other": "{{count}} neue Erfolge",
|
||||
"ACHIEVEMENTS_one": "{{count}} neuer Erfolg oder Trophäe",
|
||||
"ACHIEVEMENTS_other": "{{count}} neue Erfolge oder Trophäen",
|
||||
"MOVES_one": "{{count}} Statuswechsel",
|
||||
"MOVES_other": "{{count}} Statuswechsel",
|
||||
"DETAILS": "Details",
|
||||
@@ -1277,7 +1287,7 @@
|
||||
"ADDED": "Neue Spiele",
|
||||
"REMOVED": "Entfernte Spiele",
|
||||
"PLAYTIME": "Spielzeit",
|
||||
"ACHIEVEMENTS": "Neue Erfolge",
|
||||
"ACHIEVEMENTS": "Neue Erfolge oder Trophäen",
|
||||
"STATUS": "Statuswechsel"
|
||||
}
|
||||
}
|
||||
|
||||
+27
-17
@@ -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.",
|
||||
"NPSSO_LABEL": "NPSSO code",
|
||||
"LINK": "Link PlayStation account",
|
||||
"LINKED": "PlayStation account linked.",
|
||||
"LINKED": "PlayStation account linked, the sync starts now.",
|
||||
"LINKED_SINCE": "Linked since {{date}}",
|
||||
"EXPIRES_ON": "Link ends on {{date}}",
|
||||
"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",
|
||||
"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.",
|
||||
@@ -298,6 +299,7 @@
|
||||
"ERROR_LOAD": "Could not load this game.",
|
||||
"NOT_FOUND": "Game not in your library.",
|
||||
"PLAYTIME": "Playtime: {{time}}",
|
||||
"LAST_PLAYED": "Last played {{date}}",
|
||||
"OPEN_IN_STEAM": "View on Steam",
|
||||
"HIDE": "Hide game",
|
||||
"UNHIDE": "Show game again",
|
||||
@@ -310,8 +312,19 @@
|
||||
"UNLOCKED": "Unlocked",
|
||||
"REMAINING": "Remaining",
|
||||
"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.",
|
||||
"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.",
|
||||
"SET_PLAYING": "Set to \"Playing\"",
|
||||
"TTB": "Time to beat (IGDB)",
|
||||
@@ -526,7 +539,7 @@
|
||||
"REMOVE_YES": "Yes, remove",
|
||||
"REMOVE_NO": "Cancel",
|
||||
"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}}",
|
||||
"SELECTED": "{{count}} selected",
|
||||
"ADD_SELECTED": "Check and add"
|
||||
@@ -931,10 +944,13 @@
|
||||
"LOAD_ERROR": "The inbox could not be loaded.",
|
||||
"DIGEST": "{{name}}: {{parts}}",
|
||||
"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_other": "{{count}} new games",
|
||||
"PART_ACHIEVEMENTS_one": "{{count}} achievement",
|
||||
"PART_ACHIEVEMENTS_other": "{{count}} achievements",
|
||||
"PART_ACHIEVEMENTS_one": "{{count}} achievement or trophy",
|
||||
"PART_ACHIEVEMENTS_other": "{{count}} achievements or trophies",
|
||||
"PART_STATUS_CHANGES_one": "{{count}} status change",
|
||||
"PART_STATUS_CHANGES_other": "{{count}} status changes",
|
||||
"PART_WISHLIST_ADDS_one": "{{count}} wishlist add",
|
||||
@@ -1049,6 +1065,7 @@
|
||||
"SOURCE": "Input",
|
||||
"SOURCE_TEXT": "List",
|
||||
"SOURCE_CSV": "CSV file",
|
||||
"SOURCE_PSN": "PlayStation",
|
||||
"SOURCE_SEARCH": "Search",
|
||||
"TEXT_LABEL": "Game titles",
|
||||
"TEXT_PLACEHOLDER": "One game per line",
|
||||
@@ -1060,13 +1077,7 @@
|
||||
"START": "Search games",
|
||||
"START_FAILED": "Could not start the import.",
|
||||
"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_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",
|
||||
"PSN_UNCHECKED_DISMISSED": "Unchecked PlayStation titles are not offered again.",
|
||||
"LOAD_FAILED": "Could not load the import.",
|
||||
"MATCHING": "Searching games: {{matched}} / {{total}}",
|
||||
"MATCHING_HINT": "You can leave this page and come back later.",
|
||||
@@ -1130,7 +1141,6 @@
|
||||
"SOURCE_HLTB": "HowLongToBeat",
|
||||
"SOURCE_PLAYNITE": "Playnite",
|
||||
"SOURCE_BOOKMARKLET": "Store page",
|
||||
"SOURCE_PSN": "PlayStation",
|
||||
"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_LINK": "To Be Played import",
|
||||
@@ -1265,8 +1275,8 @@
|
||||
"REMOVED_one": "{{count}} game removed",
|
||||
"REMOVED_other": "{{count}} games removed",
|
||||
"PLAYTIME": "{{time}} played",
|
||||
"ACHIEVEMENTS_one": "{{count}} new achievement",
|
||||
"ACHIEVEMENTS_other": "{{count}} new achievements",
|
||||
"ACHIEVEMENTS_one": "{{count}} new achievement or trophy",
|
||||
"ACHIEVEMENTS_other": "{{count}} new achievements or trophies",
|
||||
"MOVES_one": "{{count}} status change",
|
||||
"MOVES_other": "{{count}} status changes",
|
||||
"DETAILS": "Details",
|
||||
@@ -1277,7 +1287,7 @@
|
||||
"ADDED": "New games",
|
||||
"REMOVED": "Removed games",
|
||||
"PLAYTIME": "Playtime",
|
||||
"ACHIEVEMENTS": "New achievements",
|
||||
"ACHIEVEMENTS": "New achievements or trophies",
|
||||
"STATUS": "Status changes"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,10 +33,11 @@ const rememberClosed = (hintId) => {
|
||||
* hint hides it for 7 days, "Do not show again" for good.
|
||||
*
|
||||
* @param user the current user
|
||||
* @param canSync a Steam ID or a PlayStation link: then the NO_STEAM hint has nothing to say; undefined while loading
|
||||
* @param summary Core's library summary; undefined while loading, null when it failed
|
||||
* @param trend the TREND group of the stats; undefined while loading, null when it failed
|
||||
*/
|
||||
export default function DashboardHint({user, summary, trend}) {
|
||||
export default function DashboardHint({user, canSync, summary, trend}) {
|
||||
const {t} = useTranslation();
|
||||
const [data, setData] = useState({hints: [], automatic: []});
|
||||
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');
|
||||
// Show nothing until every fact the cards need has settled, so a later fact does not swap the card.
|
||||
if ((needsSummary && summary === undefined) || (automatic.includes('PILE_GREW') && trend === undefined)
|
||||
|| (automatic.includes('NO_STEAM') && !user) || links === undefined) {
|
||||
|| (automatic.includes('NO_STEAM') && (!user || canSync === undefined)) || links === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const libraryEmpty = summary?.total === 0;
|
||||
const growth = pileGrowth(trend);
|
||||
const condition = {NO_STEAM: !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};
|
||||
return [
|
||||
...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]}),
|
||||
})),
|
||||
].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.
|
||||
const close = useCallback(async (forever, byUser) => {
|
||||
|
||||
@@ -148,7 +148,7 @@ describe('DashboardHint, automatic hints', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
fireEvent.click(screen.getByLabelText('HINT.CLOSE'));
|
||||
@@ -156,14 +156,26 @@ describe('DashboardHint, automatic hints', () => {
|
||||
expect(screen.getByText('HINT.AUTO_NO_STEAM')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows NO_STEAM only without a Steam ID', async () => {
|
||||
await showAuto(['NO_STEAM']);
|
||||
it('shows NO_STEAM only when nothing can sync', async () => {
|
||||
await showAuto(['NO_STEAM'], {canSync: true});
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows no NO_STEAM hint to a user with a PlayStation link and no Steam ID', async () => {
|
||||
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: true});
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
|
||||
it('waits with NO_STEAM until it is known whether the user can sync', async () => {
|
||||
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: undefined});
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides an automatic hint for 7 days with X', async () => {
|
||||
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
|
||||
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: false});
|
||||
|
||||
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 () => {
|
||||
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'));
|
||||
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 () => {
|
||||
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));
|
||||
|
||||
@@ -189,12 +201,12 @@ describe('DashboardHint, automatic hints', () => {
|
||||
expect(doneCalls()).toEqual([]);
|
||||
cleanup();
|
||||
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();
|
||||
});
|
||||
|
||||
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'));
|
||||
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 () => {
|
||||
hintRoutes(json(200, {results: [DISMISS]}));
|
||||
mount({user: {...USER, steamId: null}});
|
||||
mount({user: {...USER, steamId: null}, canSync: false});
|
||||
await act(async () => undefined);
|
||||
|
||||
expect(screen.getByText('Heute Nacht Update')).toBeTruthy();
|
||||
|
||||
@@ -14,10 +14,12 @@ import SyncStatusBadge from './SyncStatusBadge.jsx';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
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 {user: userData} = useCurrentUserContext();
|
||||
const hasSteamId = !!userData?.steamId;
|
||||
const showSync = canSync ?? hasSteamId;
|
||||
const syncBusy = forceSync.busy || sync.isRunning;
|
||||
|
||||
return (
|
||||
@@ -53,7 +55,7 @@ export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forc
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-col items-start md:items-end gap-3 shrink-0">
|
||||
{hasSteamId && (
|
||||
{showSync && (
|
||||
<>
|
||||
<SyncStatusBadge
|
||||
status={sync.status}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import {useMemo, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Trophy} from 'lucide-react';
|
||||
import {ShieldOff, Trophy} from 'lucide-react';
|
||||
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
|
||||
import {formatDate} from '../utils/format.js';
|
||||
|
||||
const STEAM_SET = 'steam';
|
||||
|
||||
const TROPHY_COLORS = {
|
||||
BRONZE: 'text-amber-700',
|
||||
SILVER: 'text-slate-400',
|
||||
GOLD: 'text-yellow-500',
|
||||
PLATINUM: 'text-cyan-400',
|
||||
};
|
||||
|
||||
const formatUnlockedAt = (instant, locale) => {
|
||||
if (!instant) return '';
|
||||
try {
|
||||
@@ -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);
|
||||
if (!src || failed) return <div className="size-12 rounded-md bg-muted"/>;
|
||||
return (
|
||||
<img src={src} alt="" className={cn('size-12 rounded-md', !unlocked && 'grayscale')}
|
||||
onError={() => {
|
||||
reportBrokenImage(appid, ACHIEVEMENT_ICONS);
|
||||
if (steam) reportBrokenImage(appid, ACHIEVEMENT_ICONS);
|
||||
setFailed(true);
|
||||
}}/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GameAchievements({game}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
|
||||
const sortedAchievements = useMemo(() => {
|
||||
if (!game.achievements) return [];
|
||||
return [...game.achievements].sort((a, b) => {
|
||||
// The base game (no group) first, then the DLC groups in their order; inside a group the earned ones first.
|
||||
function sortAchievements(achievements) {
|
||||
return [...achievements].sort((a, b) => {
|
||||
if ((a.group ?? '') !== (b.group ?? '')) return (a.group ?? '').localeCompare(b.group ?? '');
|
||||
if (a.unlocked !== b.unlocked) return a.unlocked ? -1 : 1;
|
||||
if (a.unlocked && b.unlocked) {
|
||||
return (b.unlockedAt ?? '').localeCompare(a.unlockedAt ?? '');
|
||||
}
|
||||
return (a.displayName ?? a.apiName).localeCompare(b.displayName ?? b.apiName);
|
||||
});
|
||||
}, [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 = [
|
||||
[t('GAME_DETAIL.UNLOCKED'), game.achievementsUnlocked],
|
||||
[t('GAME_DETAIL.REMAINING'), game.achievementsRemaining],
|
||||
[t('GAME_DETAIL.COMPLETION'), `${game.completionPercent}%`],
|
||||
[t('GAME_DETAIL.UNLOCKED'), set.unlocked],
|
||||
[t('GAME_DETAIL.REMAINING'), Math.max(0, set.total - set.unlocked)],
|
||||
[t('GAME_DETAIL.COMPLETION'), `${percent}%`],
|
||||
];
|
||||
|
||||
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-full rounded-full bg-primary transition-all"
|
||||
style={{width: `${Math.min(100, game.completionPercent)}%`}}
|
||||
/>
|
||||
<div className="h-full rounded-full bg-primary transition-all"
|
||||
style={{width: `${Math.min(100, percent)}%`}}/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
{stats.map(([label, value]) => (
|
||||
@@ -74,35 +88,97 @@ export default function GameAchievements({game}) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{sortedAchievements.map((a) => {
|
||||
const icon = a.unlocked ? a.iconUrl : a.iconGrayUrl;
|
||||
function AchievementItem({game, achievement}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
const steam = achievement.kind !== 'PSN';
|
||||
const icon = achievement.unlocked ? achievement.iconUrl : achievement.iconGrayUrl;
|
||||
const name = achievement.displayName
|
||||
?? (achievement.hidden && !steam ? t('GAME_DETAIL.HIDDEN_TROPHY') : achievement.apiName);
|
||||
const trophyLabel = achievement.trophyType && t(`GAME_DETAIL.TROPHY_TYPE.${achievement.trophyType}`);
|
||||
return (
|
||||
<li
|
||||
key={a.apiName}
|
||||
className={cn(
|
||||
'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}/>
|
||||
<li className={cn('flex items-start gap-3 rounded-xl border bg-card p-3',
|
||||
achievement.unlocked ? 'border-primary/40' : 'opacity-60')}>
|
||||
<AchievementIcon key={icon} appid={game.appid} src={icon} unlocked={achievement.unlocked} steam={steam}/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{a.displayName ?? a.apiName}</div>
|
||||
{a.description && (
|
||||
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{a.description}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{achievement.trophyType && (
|
||||
<Trophy className={cn('size-3.5 shrink-0', TROPHY_COLORS[achievement.trophyType])}
|
||||
aria-label={trophyLabel}>
|
||||
<title>{trophyLabel}</title>
|
||||
</Trophy>
|
||||
)}
|
||||
{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">
|
||||
{formatUnlockedAt(a.unlockedAt, i18n.language)}
|
||||
{formatUnlockedAt(achievement.unlockedAt, i18n.language)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
}
|
||||
|
||||
function AchievementSet({game, set, achievements}) {
|
||||
const {t} = useTranslation();
|
||||
const sorted = useMemo(() => sortAchievements(achievements), [achievements]);
|
||||
const steam = set.id === STEAM_SET;
|
||||
|
||||
if (steam && !set.visible) {
|
||||
return (
|
||||
<Card className="items-center py-10 text-center text-sm text-muted-foreground">
|
||||
<ShieldOff className="size-8"/>
|
||||
{t('GAME_DETAIL.STATS_HIDDEN')}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Card className="p-6 gap-5">
|
||||
<SetHeader set={set}/>
|
||||
{!steam && set.name && <p className="-mt-3 truncate text-sm text-muted-foreground">{set.name}</p>}
|
||||
<SetSummary set={set}/>
|
||||
{!set.listed && <p className="text-sm text-muted-foreground">{t('GAME_DETAIL.TROPHIES_PENDING')}</p>}
|
||||
</Card>
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{sorted.map((achievement) => (
|
||||
<AchievementItem key={`${achievement.setId ?? STEAM_SET}:${achievement.apiName}`} game={game}
|
||||
achievement={achievement}/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GameAchievements({game}) {
|
||||
const bySet = useMemo(() => {
|
||||
const groups = new Map();
|
||||
for (const a of game.achievements ?? []) {
|
||||
const id = a.setId ?? STEAM_SET;
|
||||
if (!groups.has(id)) groups.set(id, []);
|
||||
groups.get(id).push(a);
|
||||
}
|
||||
return groups;
|
||||
}, [game.achievements]);
|
||||
|
||||
return (game.achievementSets ?? []).map((set) => (
|
||||
<AchievementSet key={set.id} game={game} set={set} achievements={bySet.get(set.id) ?? []}/>
|
||||
));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,9 @@ export default function ManualGameCard({game, onSaved}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
|
||||
const playtimeMinutes = hoursToMinutes(hours);
|
||||
// PlayStation owns the playtime of its rows: Core ignores a value sent for one.
|
||||
const psn = game.source === 'PSN';
|
||||
const playtimeMinutes = psn ? game.playtimeMinutes : hoursToMinutes(hours);
|
||||
const dirty = platform.trim() !== (game.platform ?? '') || playtimeMinutes !== game.playtimeMinutes;
|
||||
useUnsavedChanges(dirty);
|
||||
|
||||
@@ -65,7 +67,7 @@ export default function ManualGameCard({game, onSaved}) {
|
||||
<Hand className="size-4 text-primary"/>
|
||||
{t('MANUAL_GAME.TITLE')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('MANUAL_GAME.HINT')}</p>
|
||||
{!psn && <p className="text-sm text-muted-foreground">{t('MANUAL_GAME.HINT')}</p>}
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
@@ -73,11 +75,13 @@ export default function ManualGameCard({game, onSaved}) {
|
||||
<Input id="manual-platform" value={platform} maxLength={MAX_PLATFORM_LENGTH}
|
||||
onChange={(e) => setPlatform(e.target.value)}/>
|
||||
</div>
|
||||
{!psn && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manual-hours">{t('MANUAL_GAME.HOURS')}</Label>
|
||||
<Input id="manual-hours" type="number" min="0" step="0.5" inputMode="decimal" value={hours}
|
||||
onChange={(e) => setHours(e.target.value)}/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {useState} from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
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 {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,
|
||||
* 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() {
|
||||
const {t, i18n} = useTranslation();
|
||||
@@ -94,11 +94,12 @@ export default function PsnAccountCard() {
|
||||
{expired && <> · {t('SETTINGS.PSN.EXPIRED')}</>}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t('SETTINGS.PSN.SYNC_HINT')}</p>
|
||||
{expired && form}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!expired && (
|
||||
<Button asChild>
|
||||
<Link to="/import?source=PSN"><Upload/>{t('SETTINGS.PSN.IMPORT')}</Link>
|
||||
{account.reviewImportId && (
|
||||
<Button asChild variant="outline">
|
||||
<Link to={`/import?id=${account.reviewImportId}`}>{t('SETTINGS.PSN.REVIEW')}</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={unlink} disabled={busy}>
|
||||
|
||||
@@ -11,7 +11,8 @@ vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn(), info: vi.fn(
|
||||
|
||||
const NPSSO = 'npsso-secret-value';
|
||||
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 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(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(document.body.innerHTML).not.toContain(NPSSO);
|
||||
expect(input.isConnected && input.value).toBeFalsy();
|
||||
@@ -97,7 +99,23 @@ describe('PsnAccountCard', () => {
|
||||
expect(await screen.findByText(/SETTINGS.PSN.EXPIRED/)).toBeTruthy();
|
||||
expect(screen.getByText(/SETTINGS.PSN.EXPIRES_ON/).className).toContain('text-destructive');
|
||||
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 () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {Copy, Loader2, Search} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
@@ -8,13 +7,10 @@ import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Textarea} from '@/components/ui/textarea';
|
||||
import usePsnAccount from '../../hooks/usePsnAccount.js';
|
||||
import useStartImport from '../../hooks/useStartImport.js';
|
||||
import useStartPsnImport from '../../hooks/useStartPsnImport.js';
|
||||
import {readImportFile} from '../../utils/importFormats.js';
|
||||
import {parseTextList} from '../../utils/imports.js';
|
||||
import {MAX_IMPORT_ROWS} from '../../utils/limits.js';
|
||||
import {isExpired} from '../../utils/psn.js';
|
||||
import {bookmarkletHref, consoleScript} from '../../utils/storeList.js';
|
||||
|
||||
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 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 [source, setSource] = useState(storeRows ? 'BOOKMARKLET' : initialSource ?? 'TEXT');
|
||||
const [source, setSource] = useState(storeRows ? 'BOOKMARKLET' : 'TEXT');
|
||||
const [text, setText] = useState('');
|
||||
const [csv, setCsv] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -49,14 +44,12 @@ export default function ImportInput({storeRows, initialSource = null, onCreated}
|
||||
<Card className="gap-4 p-5">
|
||||
<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">
|
||||
{['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}
|
||||
onClick={() => setSource(value)}>{t(`IMPORT.SOURCE_${value}`)}</Button>
|
||||
))}
|
||||
</div>
|
||||
{source === 'PSN' ? (
|
||||
<PsnInput onCreated={onCreated}/>
|
||||
) : source === 'BOOKMARKLET' ? (
|
||||
{source === 'BOOKMARKLET' ? (
|
||||
<StoreInput rows={storeRows}/>
|
||||
) : source === 'TEXT' ? (
|
||||
<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}/>
|
||||
</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'}>
|
||||
{t(tooMany ? 'IMPORT.TOO_MANY' : 'IMPORT.LINES', {count: rows.length, max: MAX_IMPORT_ROWS})}
|
||||
</span>
|
||||
<Button onClick={start} disabled={busy || rows.length === 0 || tooMany}>
|
||||
{busy ? <Loader2 className="animate-spin"/> : <Search/>}{t('IMPORT.START')}
|
||||
</Button>
|
||||
</div>}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -124,40 +117,3 @@ function StoreInput({rows}) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export default function ImportReview({data, favorite, onSaved, onDone}) {
|
||||
const response = await saveImport(data.id, chosen.map(({choice, row}) => ({
|
||||
lineNo: row.lineNo, igdbId: choice.igdbId, platform: choice.platform.trim(), status: choice.status,
|
||||
playtimeMinutes: hoursToMinutes(choice.hours),
|
||||
})));
|
||||
})), data.rows.map((row) => row.lineNo));
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
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 (
|
||||
<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="space-y-1">
|
||||
<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">
|
||||
<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="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})}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -80,14 +86,14 @@ export default function ImportReview({data, favorite, onSaved, onDone}) {
|
||||
<ul className="divide-y">
|
||||
{choices.map((choice, index) => (
|
||||
<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>
|
||||
</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 onChange = (changes) => changeLine(index, changes);
|
||||
const onPick = (candidate) => pickLine(index, candidate);
|
||||
@@ -117,7 +123,7 @@ const ReviewRow = memo(function ReviewRow({index, row, choice, onChange: changeL
|
||||
{choice.candidates.length === 0 ? (
|
||||
<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 ?? ''}
|
||||
onChange={(e) => onPick(choice.candidates.find((known) => String(known.igdbId) === e.target.value))}>
|
||||
{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})}>
|
||||
{GAME_STATUSES.map((status) => <option key={status} value={status}>{t(`BACKLOG.STATUS.${status}`)}</option>)}
|
||||
</select>
|
||||
{!psn && (
|
||||
<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})}/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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
@@ -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]));
|
||||
// Only links: a navigation needs no img-src.
|
||||
LINK_ONLY_HOSTS.forEach((host) => hosts.delete(host));
|
||||
// Hosts the backends pass on: Steam avatars and achievement icons, IGDB pictures.
|
||||
['avatars.steamstatic.com', 'steamcdn-a.akamaihd.net', 'media.steampowered.com', 'images.igdb.com']
|
||||
// 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',
|
||||
'psnobj.prod.dl.playstation.net']
|
||||
.forEach((host) => hosts.add(host));
|
||||
|
||||
for (const host of hosts) {
|
||||
|
||||
@@ -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.
|
||||
async function loadInto(path, set, pick = (data) => data) {
|
||||
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() {
|
||||
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 [playing, setPlaying] = useState([]);
|
||||
const [upNext, setUpNext] = useState([]);
|
||||
@@ -60,9 +62,11 @@ export default function useDashboardData() {
|
||||
const fetchUserData = async () => {
|
||||
const {ok, status, data} = await reloadUser();
|
||||
if (ok) {
|
||||
// Games added by hand need no Steam ID; history and achievements come only from Steam.
|
||||
await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchUpNext(), fetchSummary(),
|
||||
...(data.steamId ? [fetchActivity(), fetchAchievements()] : [])]);
|
||||
// Games added by hand need no Steam ID. The history and the achievements (Steam achievements,
|
||||
// PlayStation trophies) also come from a PlayStation link, which games-count reports as canSync.
|
||||
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) {
|
||||
setError('INFO.ERROR.NETWORK_FAILURE');
|
||||
} else if (status !== 401) {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -33,6 +33,14 @@ describe('admin broadcast texts', () => {
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const dir = path.resolve(import.meta.dirname, '../public/i18n');
|
||||
const keys = (value, prefix = '') => Object.entries(value).flatMap(([key, child]) =>
|
||||
|
||||
+10
-8
@@ -61,15 +61,17 @@ const Dashboard = () => {
|
||||
};
|
||||
|
||||
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 steamProfile = useSteamProfile(hasSteamId);
|
||||
const bannerAppid = appearance.showBanner
|
||||
? appearance.bannerAppid ?? activity?.recentGames?.[0]?.appid ?? null
|
||||
: null;
|
||||
const sync = useLibrarySync({
|
||||
enabled: hasSteamId,
|
||||
expectInitialSync: hasSteamId && steamData.count === 0,
|
||||
enabled: canSync,
|
||||
expectInitialSync: canSync && steamData.count === 0,
|
||||
onComplete: () => {
|
||||
reloadLibrary();
|
||||
stats.reload();
|
||||
@@ -126,8 +128,8 @@ const Dashboard = () => {
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
case 'ACTIVITY': return hasSteamId && activity && <ActivityCard activity={activity} charts={charts} onChart={change}/>;
|
||||
case 'ACHIEVEMENTS': return hasSteamId && achievements && <AchievementsCard summary={achievements}/>;
|
||||
case 'ACTIVITY': return canSync && activity && <ActivityCard activity={activity} charts={charts} onChart={change}/>;
|
||||
case 'ACHIEVEMENTS': return canSync && achievements && <AchievementsCard summary={achievements}/>;
|
||||
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">
|
||||
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
|
||||
equipped={equipped}
|
||||
sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/>
|
||||
{hasSteamId && <SyncChangesBanner lastSyncedAt={sync.lastSyncedAt}/>}
|
||||
sync={sync} forceSync={forceSync} canSync={canSync} editing={editing} onCustomize={() => setEditing(true)}/>
|
||||
{canSync && <SyncChangesBanner lastSyncedAt={sync.lastSyncedAt}/>}
|
||||
|
||||
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
|
||||
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
|
||||
<WhatsNewDialog/>
|
||||
{/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
|
||||
<DashboardHint user={userData} 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}/>
|
||||
</PageContainer>
|
||||
</StatDrillProvider>
|
||||
|
||||
@@ -234,6 +234,51 @@ describe('Dashboard', () => {
|
||||
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 () => {
|
||||
fetchRoutes(dashboardRoutes());
|
||||
renderDashboard();
|
||||
|
||||
+10
-10
@@ -1,7 +1,7 @@
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {Link, useNavigate, useParams} from 'react-router-dom';
|
||||
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 {Card} from '@/components/ui/card';
|
||||
@@ -24,9 +24,10 @@ import useGame from '../hooks/useGame.js';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {canCorrect} from '../utils/corrections.js';
|
||||
import {celebrationFor, celebrationLevel} from '../utils/confetti.js';
|
||||
import {formatDate} from '../utils/format.js';
|
||||
|
||||
export default function GameDetail() {
|
||||
const {t} = useTranslation();
|
||||
const {t, i18n} = useTranslation();
|
||||
const {appid} = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -87,6 +88,12 @@ export default function GameDetail() {
|
||||
onHiddenChanged={(hidden) => setData((d) => ({...d, hidden}))}
|
||||
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?". */}
|
||||
{!(data.finishedHint && ['BACKLOG', 'PLAYING'].includes(data.status)) && (
|
||||
<PlayingHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
|
||||
@@ -115,14 +122,7 @@ export default function GameDetail() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{data.hasAchievements && !data.statsVisible && (
|
||||
<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}/>}
|
||||
{data.hasAchievements && <GameAchievements game={data}/>}
|
||||
</>
|
||||
)}
|
||||
{celebration && <Confetti key={celebration.id} size={celebration.size} onDone={() => setCelebration(null)}/>}
|
||||
|
||||
@@ -129,9 +129,11 @@ describe('GameDetail', () => {
|
||||
|
||||
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`,
|
||||
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,
|
||||
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();
|
||||
|
||||
await screen.findByText('A');
|
||||
@@ -144,6 +146,19 @@ describe('GameDetail', () => {
|
||||
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 () => {
|
||||
answer({...game, speedrunUrl: 'https://www.speedrun.com/hl2'});
|
||||
renderDetail();
|
||||
@@ -249,6 +264,29 @@ describe('GameDetail of a game added by hand', () => {
|
||||
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 () => {
|
||||
renderDetail(-1025);
|
||||
fireEvent.change(await screen.findByLabelText('MANUAL_GAME.HOURS'), {target: {value: '2.5'}});
|
||||
|
||||
@@ -22,7 +22,7 @@ beforeEach(() => {
|
||||
'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/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}),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import AddGamePanel from '../components/AddGamePanel.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 [search, setSearch] = useState(q);
|
||||
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.
|
||||
const [hasSteamId, setHasSteamId] = useState(true);
|
||||
// A Steam ID or a PlayStation link. Starts true until /games-count answers, so the sync controls do not vanish
|
||||
// and come back.
|
||||
const [canSync, setCanSync] = useState(true);
|
||||
|
||||
const [data, setData] = useState({content: [], page: 0, size, totalElements: 0, totalPages: 0});
|
||||
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`)
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((json) => {
|
||||
if (!cancelled && json) setHasSteamId(json.hasSteamId);
|
||||
if (!cancelled && json) setCanSync(json.canSync);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
@@ -108,7 +110,7 @@ export default function GameLibrary() {
|
||||
|
||||
const expectInitialSync = !isLoading && !error && !filtered && data.totalElements === 0;
|
||||
const sync = useLibrarySync({
|
||||
enabled: hasSteamId,
|
||||
enabled: canSync,
|
||||
expectInitialSync,
|
||||
onComplete: load,
|
||||
});
|
||||
@@ -164,7 +166,7 @@ export default function GameLibrary() {
|
||||
<LibraryTabs/>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('LIBRARY.TITLE')}</h1>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{hasSteamId && (
|
||||
{canSync && (
|
||||
<>
|
||||
<SyncStatusBadge
|
||||
status={sync.status}
|
||||
@@ -322,7 +324,7 @@ export default function GameLibrary() {
|
||||
{g.achievementsTotal > 0 && g.achievementsUnlocked != null && (
|
||||
<Badge variant="secondary" className="hidden sm:inline-flex tabular-nums"
|
||||
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}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -17,10 +17,10 @@ vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), b
|
||||
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 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/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)));
|
||||
@@ -87,12 +87,20 @@ describe('GameLibrary search', () => {
|
||||
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 () => {
|
||||
const later = [];
|
||||
const games = (name) => json(200, {...page, content: [{...page.content[0], name}], totalElements: 1, totalPages: 1});
|
||||
fetchRoutes({
|
||||
'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) => {
|
||||
const q = new URL(url, 'http://x').searchParams.get('q');
|
||||
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');
|
||||
});
|
||||
|
||||
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);
|
||||
renderLibrary();
|
||||
|
||||
|
||||
@@ -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
|
||||
* 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.
|
||||
*/
|
||||
export default function ImportPage() {
|
||||
@@ -84,8 +84,7 @@ export default function ImportPage() {
|
||||
<ImportView key={shownId} id={shownId} onDone={done} onChanged={loadImports}/>
|
||||
) : (
|
||||
// A store list comes after the first answer: a new key opens its tab.
|
||||
<ImportInput key={storeRows ? 'store' : 'input'} storeRows={storeRows}
|
||||
initialSource={searchParams.get('source') === 'PSN' ? 'PSN' : null} onCreated={(created) => {
|
||||
<ImportInput key={storeRows ? 'store' : 'input'} storeRows={storeRows} onCreated={(created) => {
|
||||
setStoreRows(null);
|
||||
show(created);
|
||||
loadImports();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -259,7 +259,7 @@ describe('ImportPage', () => {
|
||||
expect(JSON.parse(posted('POST', '/5/save')[1].body)).toEqual({rows: [
|
||||
{lineNo: 1, igdbId: 1, platform: 'Switch', status: 'BACKLOG', playtimeMinutes: 0},
|
||||
{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 () => {
|
||||
|
||||
+11
-2
@@ -1,5 +1,6 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {Loader2} from 'lucide-react';
|
||||
|
||||
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 {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() {
|
||||
const {t, i18n} = useTranslation();
|
||||
@@ -32,6 +35,7 @@ export default function InboxPage() {
|
||||
}, []);
|
||||
|
||||
const text = (note) => {
|
||||
if (PSN_KINDS.includes(note.kind)) return t(`INBOX.${note.kind}`);
|
||||
const name = note.displayName ?? t('FOLLOW.PRIVATE_USER');
|
||||
if (note.kind === 'VISIBILITY') return t('INBOX.VISIBILITY', {name});
|
||||
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">
|
||||
{formatDate(note.createdAt, i18n.language, {dateStyle: 'medium'})}
|
||||
</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>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -34,6 +34,24 @@ describe('InboxPage', () => {
|
||||
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 () => {
|
||||
fetchRoutes({'GET /api/inbox': json(200, {results: [], unread: 0})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
|
||||
@@ -15,8 +15,9 @@ export const listImports = () => apiFetch(IMPORTS_URL());
|
||||
|
||||
export const getImport = (id) => apiFetch(`${IMPORTS_URL()}/${id}`);
|
||||
|
||||
export const saveImport = (id, rows) =>
|
||||
apiFetch(`${IMPORTS_URL()}/${id}/save`, {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({rows})});
|
||||
// shownLineNos: the lines the user saw; a PlayStation review keeps the others open (the sync may add lines meanwhile).
|
||||
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'});
|
||||
|
||||
|
||||
+2
-1
@@ -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,
|
||||
* 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());
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@ export const linkPsn = (npsso) =>
|
||||
|
||||
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.
|
||||
export const NPSSO_PAGE_URL = 'https://ca.account.sony.com/api/v1/ssocookie';
|
||||
|
||||
|
||||
Reference in new issue
Block a user