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