diff --git a/README.md b/README.md
index 4b83e4b..c156c50 100644
--- a/README.md
+++ b/README.md
@@ -146,7 +146,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`.
diff --git a/public/i18n/de.json b/public/i18n/de.json
index 080d69d..568efaa 100644
--- a/public/i18n/de.json
+++ b/public/i18n/de.json
@@ -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",
@@ -931,6 +933,9 @@
"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",
@@ -1049,6 +1054,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 +1066,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 +1130,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",
diff --git a/public/i18n/en.json b/public/i18n/en.json
index 74608cb..e2e139a 100644
--- a/public/i18n/en.json
+++ b/public/i18n/en.json
@@ -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",
@@ -931,6 +933,9 @@
"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",
@@ -1049,6 +1054,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 +1066,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 +1130,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",
diff --git a/src/components/DashboardHint.jsx b/src/components/DashboardHint.jsx
index b4f1aef..ec382c0 100644
--- a/src/components/DashboardHint.jsx
+++ b/src/components/DashboardHint.jsx
@@ -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) => {
diff --git a/src/components/DashboardHint.test.jsx b/src/components/DashboardHint.test.jsx
index f5948c0..58cbc59 100644
--- a/src/components/DashboardHint.test.jsx
+++ b/src/components/DashboardHint.test.jsx
@@ -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();
diff --git a/src/components/DashboardWelcomeCard.jsx b/src/components/DashboardWelcomeCard.jsx
index d69c908..ac6eec5 100644
--- a/src/components/DashboardWelcomeCard.jsx
+++ b/src/components/DashboardWelcomeCard.jsx
@@ -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
diff --git a/src/components/PsnAccountCard.jsx b/src/components/PsnAccountCard.jsx
index bb4baf5..0c5ad59 100644
--- a/src/components/PsnAccountCard.jsx
+++ b/src/components/PsnAccountCard.jsx
@@ -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')}>}