Compare commits

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

No files matched your search

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