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
- {hasSteamId && ( + {showSync && ( <> {t('MANUAL_GAME.TITLE')} -

{t('MANUAL_GAME.HINT')}

+ {!psn &&

{t('MANUAL_GAME.HINT')}

}
@@ -73,11 +75,13 @@ export default function ManualGameCard({game, onSaved}) { setPlatform(e.target.value)}/>
-
- - setHours(e.target.value)}/> -
+ {!psn && ( +
+ + setHours(e.target.value)}/> +
+ )}
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')}}

+

{t('SETTINGS.PSN.SYNC_HINT')}

{expired && form}
- {!expired && ( - )} ))}
- {source === 'PSN' ? ( - - ) : source === 'BOOKMARKLET' ? ( + {source === 'BOOKMARKLET' ? ( ) : source === 'TEXT' ? (