Compare commits

..
2 Commits
Author SHA1 Message Date
lobo 599b083591 feat(psn): add PlayStation link card and PSN import
Build, Test & Push Frontend / docker (push) Successful in 22s
Build, Test & Push Frontend / quality-check (push) Successful in 34s
Settings card to link and unlink with the NPSSO, shows the expiry
date. Import page gets a PSN source; blocked when the link has ended.
English and German texts.
2026-10-07 13:30:48 +02:00
lobo 6ae2abee07 feat(play-next): add FRIENDS row and reason text
Shows games from followed users. Reason displays friend name and finish
or playtime status. Supports reroll without mood. Added English and
German translations.
2026-10-07 09:42:10 +02:00
21 changed files with 610 additions and 19 deletions

No files matched your search

+2 -2
View File
@@ -28,9 +28,9 @@ To Be Played is split into three repositories:
- **Coming games**: a game that is not released yet (and nobody played) shows a "Coming <date>" badge in the library, on - **Coming games**: a game that is not released yet (and nobody played) shows a "Coming <date>" badge in the library, on
the details page, in the stat game lists and on the public profile. The pile counts only the games Core sends in the details page, in the stat game lists and on the public profile. The pile counts only the games Core sends in
`pileGames`, so coming games are not on it; Core never picks them. `pileGames`, so coming games are not on it; Core never picks them.
- **What to play next** (`/play-next`): six rows of up to 3 backlog games each: up next (your own backlog order), - **What to play next** (`/play-next`): seven rows of up to 3 backlog games each: up next (your own backlog order),
next in series (the next part of a series you played), because you played (a similar game for each of your 3 most next in series (the next part of a series you played), because you played (a similar game for each of your 3 most
played), same studio (a developer you played), longest on your list, and mood picks. A time played), same studio (a developer you played), from people you follow (a backlog game someone you follow finished or played 5 h or more), longest on your list, and mood picks. A time
budget (up to 5 / 15 / 40 h or any) filters all rows; a mood (story, co-op, chill, action, scary from IGDB; speedrun budget (up to 5 / 15 / 40 h or any) filters all rows; a mood (story, co-op, chill, action, scary from IGDB; speedrun
from speedrun.com) only the mood row. Each row has its own reroll without repeats, and "Start playing" in one click. from speedrun.com) only the mood row. Each row has its own reroll without repeats, and "Start playing" in one click.
On a phone each row scrolls sideways. On a phone each row scrolls sideways.
+33 -1
View File
@@ -143,6 +143,27 @@
"ERROR": "Die Steam-Verknüpfung konnte nicht starten.", "ERROR": "Die Steam-Verknüpfung konnte nicht starten.",
"UNLINKED": "Steam-Verknüpfung gelöst." "UNLINKED": "Steam-Verknüpfung gelöst."
}, },
"PSN": {
"TITLE": "PlayStation",
"OFF": "Die PlayStation-Verknüpfung ist auf diesem Server nicht eingerichtet.",
"STEPS_1": "Melde dich im Browser auf playstation.com an.",
"STEPS_2": "Öffne im selben Browser diese Adresse:",
"STEPS_3": "Kopiere den Wert von „npsso“ und füge ihn unten ein.",
"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_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",
"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.",
"UNAVAILABLE": "PlayStation hat nicht geantwortet. Bitte versuche es später noch einmal.",
"BUSY": "Eine andere PlayStation-Anfrage von dir läuft noch. Warte einen Moment und versuche es erneut.",
"ERROR": "Mit der PlayStation-Verknüpfung ist etwas schiefgelaufen."
},
"VERSION": "Version", "VERSION": "Version",
"VERSION_UNKNOWN": "unbekannt", "VERSION_UNKNOWN": "unbekannt",
"CHANGE_PASSWORD": { "CHANGE_PASSWORD": {
@@ -431,11 +452,13 @@
}, },
"PLAY_NEXT": { "PLAY_NEXT": {
"TITLE": "Was spiele ich als Nächstes?", "TITLE": "Was spiele ich als Nächstes?",
"SUBTITLE": "Spiele aus deinem Backlog in sechs Reihen: als Nächstes in deiner Reihenfolge, der nächste Teil einer Reihe, die du gespielt hast, wie Spiele, die du viel gespielt hast, von einem Studio, das du gespielt hast, am längsten wartend und nach Stimmung. Wähle eine Dauer für alle Reihen und eine Stimmung für die letzte.", "SUBTITLE": "Spiele aus deinem Backlog in sieben Reihen: als Nächstes in deiner Reihenfolge, der nächste Teil einer Reihe, die du gespielt hast, wie Spiele, die du viel gespielt hast, von einem Studio, das du gespielt hast, was Leute spielten, denen du folgst, am längsten wartend und nach Stimmung. Wähle eine Dauer für alle Reihen und eine Stimmung für die letzte.",
"REASON": { "REASON": {
"SIMILAR": "Weil du {{name}} gespielt hast ({{playtime}})", "SIMILAR": "Weil du {{name}} gespielt hast ({{playtime}})",
"SERIES": "Reihe: {{name}}", "SERIES": "Reihe: {{name}}",
"STUDIO": "Von {{name}}", "STUDIO": "Von {{name}}",
"FRIENDS_FINISHED": "{{name}} hat es durchgespielt",
"FRIENDS_PLAYED": "{{name}} hat {{playtime}} gespielt",
"LONGEST": "Auf deiner Liste seit {{date}}" "LONGEST": "Auf deiner Liste seit {{date}}"
}, },
"CATEGORY": { "CATEGORY": {
@@ -443,6 +466,7 @@
"SERIES": "Nächster Teil der Reihe", "SERIES": "Nächster Teil der Reihe",
"SIMILAR": "Weil du gespielt hast", "SIMILAR": "Weil du gespielt hast",
"STUDIO": "Vom selben Studio", "STUDIO": "Vom selben Studio",
"FRIENDS": "Von Leuten, denen du folgst",
"LONGEST": "Am längsten auf deiner Liste", "LONGEST": "Am längsten auf deiner Liste",
"RANDOM": "Zufallstreffer", "RANDOM": "Zufallstreffer",
"MOOD": "Stimmung: {{mood}}" "MOOD": "Stimmung: {{mood}}"
@@ -1036,6 +1060,13 @@
"START": "Spiele suchen", "START": "Spiele suchen",
"START_FAILED": "Der Import konnte nicht starten.", "START_FAILED": "Der Import konnte nicht starten.",
"RUNNING": "Ein anderer Import wird noch gesucht. Warte, bis er fertig ist.", "RUNNING": "Ein anderer Import wird noch gesucht. Warte, bis er fertig ist.",
"PSN_HINT": "Importiert deine gespielten PS4- und PS5-Titel mit Spielzeit. Titel, die du gekauft, aber nie gestartet hast, stehen nicht in der Liste.",
"PSN_NOT_LINKED": "Verknüpfe zuerst dein PlayStation-Konto in den Einstellungen.",
"PSN_RELINK": "Die PlayStation-Verknüpfung ist beendet. Füge in den Einstellungen einen neuen NPSSO-Code ein.",
"PSN_NO_TITLES": "PlayStation listet für dieses Konto keine gespielten Titel.",
"PSN_UNAVAILABLE": "PlayStation hat nicht geantwortet. Bitte versuche es später noch einmal.",
"PSN_BUSY": "Eine andere PlayStation-Anfrage von dir läuft noch. Warte einen Moment und versuche es erneut.",
"PSN_SETTINGS": "Einstellungen öffnen",
"LOAD_FAILED": "Der Import konnte nicht geladen werden.", "LOAD_FAILED": "Der Import konnte nicht geladen werden.",
"MATCHING": "Spiele werden gesucht: {{matched}} / {{total}}", "MATCHING": "Spiele werden gesucht: {{matched}} / {{total}}",
"MATCHING_HINT": "Du kannst die Seite verlassen und später wiederkommen.", "MATCHING_HINT": "Du kannst die Seite verlassen und später wiederkommen.",
@@ -1099,6 +1130,7 @@
"SOURCE_HLTB": "HowLongToBeat", "SOURCE_HLTB": "HowLongToBeat",
"SOURCE_PLAYNITE": "Playnite", "SOURCE_PLAYNITE": "Playnite",
"SOURCE_BOOKMARKLET": "Store-Seite", "SOURCE_BOOKMARKLET": "Store-Seite",
"SOURCE_PSN": "PlayStation",
"FORMAT_READ": "Gelesen als: {{format}}", "FORMAT_READ": "Gelesen als: {{format}}",
"STORE_HINT": "Zieh den Button unten in deine Lesezeichenleiste. Öffne deine Spielebibliothek auf einer dieser Seiten, melde dich an und klick das Lesezeichen: {{sites}}. Es liest dort die Liste und bringt sie hierher. Die Liste geht nur durch deinen Browser.", "STORE_HINT": "Zieh den Button unten in deine Lesezeichenleiste. Öffne deine Spielebibliothek auf einer dieser Seiten, melde dich an und klick das Lesezeichen: {{sites}}. Es liest dort die Liste und bringt sie hierher. Die Liste geht nur durch deinen Browser.",
"STORE_LINK": "To Be Played Import", "STORE_LINK": "To Be Played Import",
+33 -1
View File
@@ -143,6 +143,27 @@
"ERROR": "Could not start the Steam link.", "ERROR": "Could not start the Steam link.",
"UNLINKED": "Steam account unlinked." "UNLINKED": "Steam account unlinked."
}, },
"PSN": {
"TITLE": "PlayStation",
"OFF": "The PlayStation link is not set up on this server.",
"STEPS_1": "Sign in at playstation.com in your browser.",
"STEPS_2": "In the same browser, open this address:",
"STEPS_3": "Copy the value of \"npsso\" and paste it below.",
"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_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",
"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.",
"UNAVAILABLE": "PlayStation did not answer. Please try again later.",
"BUSY": "Another PlayStation request of yours is still running. Wait a moment and try again.",
"ERROR": "Something went wrong with the PlayStation link."
},
"VERSION": "Version", "VERSION": "Version",
"VERSION_UNKNOWN": "unknown", "VERSION_UNKNOWN": "unknown",
"CHANGE_PASSWORD": { "CHANGE_PASSWORD": {
@@ -431,11 +452,13 @@
}, },
"PLAY_NEXT": { "PLAY_NEXT": {
"TITLE": "What to play next", "TITLE": "What to play next",
"SUBTITLE": "Games from your backlog in six rows: next in your order, the next part of a series you played, like games you played a lot, from a studio you played, waiting longest, and by mood. Pick a length for all rows and a mood for the last one.", "SUBTITLE": "Games from your backlog in seven rows: next in your order, the next part of a series you played, like games you played a lot, from a studio you played, what people you follow played, waiting longest, and by mood. Pick a length for all rows and a mood for the last one.",
"REASON": { "REASON": {
"SIMILAR": "Because you played {{name}} ({{playtime}})", "SIMILAR": "Because you played {{name}} ({{playtime}})",
"SERIES": "Series: {{name}}", "SERIES": "Series: {{name}}",
"STUDIO": "From {{name}}", "STUDIO": "From {{name}}",
"FRIENDS_FINISHED": "{{name}} finished it",
"FRIENDS_PLAYED": "{{name}} played {{playtime}}",
"LONGEST": "On your list since {{date}}" "LONGEST": "On your list since {{date}}"
}, },
"CATEGORY": { "CATEGORY": {
@@ -443,6 +466,7 @@
"SERIES": "Next in series", "SERIES": "Next in series",
"SIMILAR": "Because you played", "SIMILAR": "Because you played",
"STUDIO": "Same studio", "STUDIO": "Same studio",
"FRIENDS": "From people you follow",
"LONGEST": "Longest on your list", "LONGEST": "Longest on your list",
"RANDOM": "Random picks", "RANDOM": "Random picks",
"MOOD": "Mood: {{mood}}" "MOOD": "Mood: {{mood}}"
@@ -1036,6 +1060,13 @@
"START": "Search games", "START": "Search games",
"START_FAILED": "Could not start the import.", "START_FAILED": "Could not start the import.",
"RUNNING": "Another import is still being matched. Wait until it is done.", "RUNNING": "Another import is still being matched. Wait until it is done.",
"PSN_HINT": "Imports your played PS4 and PS5 titles with playtime. Titles you bought but never started are not listed.",
"PSN_NOT_LINKED": "Link your PlayStation account in the settings first.",
"PSN_RELINK": "The PlayStation link has ended. Paste a new NPSSO code in the settings.",
"PSN_NO_TITLES": "PlayStation lists no played titles for this account.",
"PSN_UNAVAILABLE": "PlayStation did not answer. Please try again later.",
"PSN_BUSY": "Another PlayStation request of yours is still running. Wait a moment and try again.",
"PSN_SETTINGS": "Open settings",
"LOAD_FAILED": "Could not load the import.", "LOAD_FAILED": "Could not load the import.",
"MATCHING": "Searching games: {{matched}} / {{total}}", "MATCHING": "Searching games: {{matched}} / {{total}}",
"MATCHING_HINT": "You can leave this page and come back later.", "MATCHING_HINT": "You can leave this page and come back later.",
@@ -1099,6 +1130,7 @@
"SOURCE_HLTB": "HowLongToBeat", "SOURCE_HLTB": "HowLongToBeat",
"SOURCE_PLAYNITE": "Playnite", "SOURCE_PLAYNITE": "Playnite",
"SOURCE_BOOKMARKLET": "Store page", "SOURCE_BOOKMARKLET": "Store page",
"SOURCE_PSN": "PlayStation",
"FORMAT_READ": "Read as: {{format}}", "FORMAT_READ": "Read as: {{format}}",
"STORE_HINT": "Drag the button below to your bookmarks bar. Open your game library on one of these sites, log in, and click the bookmark: {{sites}}. It reads the list there and brings it back here. The list goes only through your browser.", "STORE_HINT": "Drag the button below to your bookmarks bar. Open your game library on one of these sites, log in, and click the bookmark: {{sites}}. It reads the list there and brings it back here. The list goes only through your browser.",
"STORE_LINK": "To Be Played import", "STORE_LINK": "To Be Played import",
+136
View File
@@ -0,0 +1,136 @@
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 {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import usePsnAccount from '../hooks/usePsnAccount.js';
import {formatDate} from '../utils/format.js';
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.
*/
export default function PsnAccountCard() {
const {t, i18n} = useTranslation();
const {account, setAccount, failed} = usePsnAccount();
const [npsso, setNpsso] = useState('');
const [busy, setBusy] = useState(false);
const link = async (event) => {
event.preventDefault();
setBusy(true);
try {
const response = await linkPsn(npsso.trim());
if (response.status === 401) return;
if (response.ok) {
setAccount(await response.json());
toast.success(t('SETTINGS.PSN.LINKED'));
} else if (response.status === 400) {
toast.error(t('SETTINGS.PSN.INVALID'));
} else if (response.status === 429) {
toast.error(t('SETTINGS.PSN.BUSY'));
} else if (response.status === 502) {
toast.error(t('SETTINGS.PSN.UNAVAILABLE'));
} else if (response.status === 503) {
setAccount((old) => ({...old, enabled: false}));
} else {
toast.error(t('SETTINGS.PSN.ERROR'));
}
} catch (err) {
console.error('PSN link error:', err);
toast.error(t('SETTINGS.PSN.ERROR'));
} finally {
// The NPSSO is a session secret: it does not stay in the page after any answer.
setNpsso('');
setBusy(false);
}
};
const unlink = async () => {
setBusy(true);
try {
const response = await unlinkPsn();
if (response.status === 401) return;
if (!response.ok) {
toast.error(t('SETTINGS.PSN.ERROR'));
return;
}
setAccount((old) => ({...old, linked: false, linkedAt: null, expiresAt: null}));
toast.success(t('SETTINGS.PSN.UNLINKED'));
} catch (err) {
console.error('PSN unlink error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
const expired = isExpired(account);
const form = <NpssoForm npsso={npsso} onChange={setNpsso} onSubmit={link} busy={busy}/>;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Gamepad className="size-4 text-primary"/>{t('SETTINGS.PSN.TITLE')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{failed ? (
<p className="text-sm text-destructive">{t('SETTINGS.PSN.ERROR')}</p>
) : !account ? (
<Loader2 className="animate-spin text-muted-foreground"/>
) : !account.enabled ? (
<p className="text-sm text-muted-foreground">{t('SETTINGS.PSN.OFF')}</p>
) : !account.linked ? form : (
<>
<div className="space-y-1 text-sm">
<p>{t('SETTINGS.PSN.LINKED_SINCE', {date: formatDate(account.linkedAt, i18n.language, {dateStyle: 'medium'})})}</p>
<p className={expired ? 'font-medium text-destructive' : undefined}>
{t('SETTINGS.PSN.EXPIRES_ON', {date: formatDate(account.expiresAt, i18n.language, {dateStyle: 'medium'})})}
{expired && <> · {t('SETTINGS.PSN.EXPIRED')}</>}
</p>
</div>
{expired && form}
<div className="flex flex-wrap gap-2">
{!expired && (
<Button asChild>
<Link to="/import?source=PSN"><Upload/>{t('SETTINGS.PSN.IMPORT')}</Link>
</Button>
)}
<Button type="button" variant="outline" onClick={unlink} disabled={busy}>
<Unlink/>{t('SETTINGS.PSN.UNLINK')}
</Button>
</div>
</>
)}
</CardContent>
</Card>
);
}
function NpssoForm({npsso, onChange, onSubmit, busy}) {
const {t} = useTranslation();
return (
<form onSubmit={onSubmit} className="space-y-3">
<ol className="list-decimal space-y-1 pl-5 text-sm">
<li>{t('SETTINGS.PSN.STEPS_1')}</li>
<li>
{t('SETTINGS.PSN.STEPS_2')}{' '}
<a href={NPSSO_PAGE_URL} target="_blank" rel="noopener noreferrer" className="break-all underline">{NPSSO_PAGE_URL}</a>
</li>
<li>{t('SETTINGS.PSN.STEPS_3')}</li>
</ol>
<p className="text-sm text-muted-foreground">{t('SETTINGS.PSN.WHAT_IT_GIVES')}</p>
<Input type="password" autoComplete="off" spellCheck={false} maxLength={128} value={npsso}
aria-label={t('SETTINGS.PSN.NPSSO_LABEL')} onChange={(e) => onChange(e.target.value)}/>
<Button type="submit" disabled={busy || !npsso.trim()}>
{busy ? <Loader2 className="animate-spin"/> : <Link2/>}{t('SETTINGS.PSN.LINK')}
</Button>
</form>
);
}
+115
View File
@@ -0,0 +1,115 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import {toast} from 'sonner';
import PsnAccountCard from './PsnAccountCard.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 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 inDays = (days) => new Date(Date.now() + days * 86_400_000).toISOString();
const calls = (method) => fetch.mock.calls.filter(([, init]) => init?.method === method);
function renderCard(routes) {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/library/psn-account': json(200, {enabled: true, linked: false}),
...routes,
});
return renderWithProviders(<PsnAccountCard/>, {path: '/settings', entries: ['/settings']});
}
const paste = async (value = NPSSO) => {
const input = await screen.findByLabelText('SETTINGS.PSN.NPSSO_LABEL');
fireEvent.change(input, {target: {value}});
fireEvent.click(screen.getByRole('button', {name: /SETTINGS.PSN.LINK/}));
return input;
};
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('PsnAccountCard', () => {
it('says the feature is off when Core has no key', async () => {
renderCard({'GET /api/library/psn-account': json(200, {enabled: false, linked: false})});
expect(await screen.findByText('SETTINGS.PSN.OFF')).toBeTruthy();
expect(screen.queryByLabelText('SETTINGS.PSN.NPSSO_LABEL')).toBeNull();
});
it('explains the NPSSO and takes it in a password field', async () => {
renderCard({});
const input = await screen.findByLabelText('SETTINGS.PSN.NPSSO_LABEL');
expect(input.getAttribute('type')).toBe('password');
expect(input.getAttribute('autocomplete')).toBe('off');
expect(screen.getByText('SETTINGS.PSN.WHAT_IT_GIVES')).toBeTruthy();
expect(screen.getByRole('link', {name: /ssocookie/}).getAttribute('rel')).toContain('noopener');
});
it('links, shows both dates and never shows the NPSSO again', async () => {
renderCard({'POST /api/library/psn-account': json(200, linkedAccount(inDays(10)))});
const input = await paste();
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(JSON.parse(calls('POST')[0][1].body)).toEqual({npsso: NPSSO});
expect(document.body.innerHTML).not.toContain(NPSSO);
expect(input.isConnected && input.value).toBeFalsy();
expect(toast.success).toHaveBeenCalledWith('SETTINGS.PSN.LINKED');
});
it('shows a toast for a refused NPSSO and clears the input', async () => {
renderCard({'POST /api/library/psn-account': json(400, {code: 'NPSSO_INVALID'})});
const input = await paste();
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.PSN.INVALID'));
expect(input.value).toBe('');
expect(document.body.innerHTML).not.toContain(NPSSO);
});
it.each([[429, 'SETTINGS.PSN.BUSY'], [502, 'SETTINGS.PSN.UNAVAILABLE'], [500, 'SETTINGS.PSN.ERROR']])('shows a toast for status %i', async (status, key) => {
renderCard({'POST /api/library/psn-account': json(status, {})});
await paste();
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(key));
});
it('marks an ended link in red and asks for a new NPSSO', async () => {
renderCard({'GET /api/library/psn-account': json(200, linkedAccount(inDays(-1)))});
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();
});
it('unlinks and shows the link form again', async () => {
renderCard({
'GET /api/library/psn-account': json(200, linkedAccount(inDays(5))),
'DELETE /api/library/psn-account': noBody,
});
fireEvent.click(await screen.findByRole('button', {name: /SETTINGS.PSN.UNLINK/}));
expect(await screen.findByLabelText('SETTINGS.PSN.NPSSO_LABEL')).toBeTruthy();
expect(toast.success).toHaveBeenCalledWith('SETTINGS.PSN.UNLINKED');
expect(calls('DELETE')).toHaveLength(1);
});
});
+50 -6
View File
@@ -1,5 +1,6 @@
import {useState} from 'react'; import {useState} from 'react';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {Link} from 'react-router-dom';
import {Copy, Loader2, Search} from 'lucide-react'; import {Copy, Loader2, Search} from 'lucide-react';
import {toast} from 'sonner'; import {toast} from 'sonner';
@@ -7,10 +8,13 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
import {Textarea} from '@/components/ui/textarea'; import {Textarea} from '@/components/ui/textarea';
import usePsnAccount from '../../hooks/usePsnAccount.js';
import useStartImport from '../../hooks/useStartImport.js'; import useStartImport from '../../hooks/useStartImport.js';
import useStartPsnImport from '../../hooks/useStartPsnImport.js';
import {readImportFile} from '../../utils/importFormats.js'; import {readImportFile} from '../../utils/importFormats.js';
import {parseTextList} from '../../utils/imports.js'; import {parseTextList} from '../../utils/imports.js';
import {MAX_IMPORT_ROWS} from '../../utils/limits.js'; import {MAX_IMPORT_ROWS} from '../../utils/limits.js';
import {isExpired} from '../../utils/psn.js';
import {bookmarkletHref, consoleScript} from '../../utils/storeList.js'; import {bookmarkletHref, consoleScript} from '../../utils/storeList.js';
const STORE_SITES = 'PlayStation, Xbox, Nintendo eShop, Epic Games, GOG'; const STORE_SITES = 'PlayStation, Xbox, Nintendo eShop, Epic Games, GOG';
@@ -19,10 +23,11 @@ const setBookmarklet = (link) => link?.setAttribute('href', bookmarkletHref(loca
/** /**
* @param storeRows the games the bookmarklet read on a store page, or null * @param storeRows the games the bookmarklet read on a store page, or null
* @param initialSource the tab to open, e.g. PSN; null for the default
*/ */
export default function ImportInput({storeRows, onCreated}) { export default function ImportInput({storeRows, initialSource = null, onCreated}) {
const {t} = useTranslation(); const {t} = useTranslation();
const [source, setSource] = useState(storeRows ? 'BOOKMARKLET' : 'TEXT'); const [source, setSource] = useState(storeRows ? 'BOOKMARKLET' : initialSource ?? 'TEXT');
const [text, setText] = useState(''); const [text, setText] = useState('');
const [csv, setCsv] = useState(null); const [csv, setCsv] = useState(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -44,12 +49,14 @@ export default function ImportInput({storeRows, onCreated}) {
<Card className="gap-4 p-5"> <Card className="gap-4 p-5">
<p className="text-sm text-muted-foreground">{t('IMPORT.HINT')}</p> <p className="text-sm text-muted-foreground">{t('IMPORT.HINT')}</p>
<div role="group" aria-label={t('IMPORT.SOURCE')} className="flex flex-wrap gap-2"> <div role="group" aria-label={t('IMPORT.SOURCE')} className="flex flex-wrap gap-2">
{['TEXT', 'CSV', 'BOOKMARKLET'].map((value) => ( {['TEXT', 'CSV', 'BOOKMARKLET', 'PSN'].map((value) => (
<Button key={value} size="sm" variant={source === value ? 'default' : 'outline'} aria-pressed={source === value} <Button key={value} size="sm" variant={source === value ? 'default' : 'outline'} aria-pressed={source === value}
onClick={() => setSource(value)}>{t(`IMPORT.SOURCE_${value}`)}</Button> onClick={() => setSource(value)}>{t(`IMPORT.SOURCE_${value}`)}</Button>
))} ))}
</div> </div>
{source === 'BOOKMARKLET' ? ( {source === 'PSN' ? (
<PsnInput onCreated={onCreated}/>
) : source === 'BOOKMARKLET' ? (
<StoreInput rows={storeRows}/> <StoreInput rows={storeRows}/>
) : source === 'TEXT' ? ( ) : source === 'TEXT' ? (
<Textarea rows={10} value={text} aria-label={t('IMPORT.TEXT_LABEL')} placeholder={t('IMPORT.TEXT_PLACEHOLDER')} <Textarea rows={10} value={text} aria-label={t('IMPORT.TEXT_LABEL')} placeholder={t('IMPORT.TEXT_PLACEHOLDER')}
@@ -66,14 +73,14 @@ export default function ImportInput({storeRows, onCreated}) {
<RowPreview rows={csv?.rows}/> <RowPreview rows={csv?.rows}/>
</div> </div>
)} )}
<div className="flex flex-wrap items-center justify-between gap-3"> {source !== 'PSN' && <div className="flex flex-wrap items-center justify-between gap-3">
<span className={tooMany ? 'text-sm text-destructive' : 'text-sm text-muted-foreground'}> <span className={tooMany ? 'text-sm text-destructive' : 'text-sm text-muted-foreground'}>
{t(tooMany ? 'IMPORT.TOO_MANY' : 'IMPORT.LINES', {count: rows.length, max: MAX_IMPORT_ROWS})} {t(tooMany ? 'IMPORT.TOO_MANY' : 'IMPORT.LINES', {count: rows.length, max: MAX_IMPORT_ROWS})}
</span> </span>
<Button onClick={start} disabled={busy || rows.length === 0 || tooMany}> <Button onClick={start} disabled={busy || rows.length === 0 || tooMany}>
{busy ? <Loader2 className="animate-spin"/> : <Search/>}{t('IMPORT.START')} {busy ? <Loader2 className="animate-spin"/> : <Search/>}{t('IMPORT.START')}
</Button> </Button>
</div> </div>}
</Card> </Card>
); );
} }
@@ -117,3 +124,40 @@ function StoreInput({rows}) {
</div> </div>
); );
} }
/**
* The played PlayStation titles: Core reads them from the linked account, so there is nothing to type.
*/
function PsnInput({onCreated}) {
const {t} = useTranslation();
const {account, failed} = usePsnAccount();
const startImport = useStartPsnImport();
const [busy, setBusy] = useState(false);
const start = async () => {
setBusy(true);
const id = await startImport();
setBusy(false);
if (id) onCreated(id);
};
if (failed) return <p className="text-sm text-destructive">{t('SETTINGS.PSN.ERROR')}</p>;
if (!account) return <Loader2 className="animate-spin text-muted-foreground"/>;
const linked = account.enabled && account.linked;
if (!linked || isExpired(account)) {
return (
<p className="text-sm">
{t(linked ? 'IMPORT.PSN_RELINK' : 'IMPORT.PSN_NOT_LINKED')}{' '}
<Link to="/settings" className="underline">{t('IMPORT.PSN_SETTINGS')}</Link>
</p>
);
}
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">{t('IMPORT.PSN_HINT')}</p>
<Button onClick={start} disabled={busy}>
{busy ? <Loader2 className="animate-spin"/> : <Search/>}{t('IMPORT.START')}
</Button>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import {useCallback, useEffect, useState} from 'react';
import {getPsnAccount} from '../utils/psn.js';
/**
* The linked PlayStation account of the user: `account` is null while loading, `failed` when Core did not answer.
*/
export default function usePsnAccount() {
const [account, setAccount] = useState(null);
const [failed, setFailed] = useState(false);
const load = useCallback(async () => {
try {
const response = await getPsnAccount();
if (response.status === 401) return;
if (response.ok) setAccount(await response.json());
else setFailed(true);
} catch (err) {
console.error('PSN account error:', err);
setFailed(true);
}
}, []);
useEffect(() => {
load();
}, [load]);
return {account, setAccount, failed};
}
+41
View File
@@ -0,0 +1,41 @@
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]);
}
+1 -1
View File
@@ -53,7 +53,7 @@ export default function GameDetail() {
if (data && String(data.appid) !== appid) navigate(`/library/${data.appid}`, {replace: true}); if (data && String(data.appid) !== appid) navigate(`/library/${data.appid}`, {replace: true});
}, [data, appid, navigate]); }, [data, appid, navigate]);
const manual = data?.source === 'MANUAL'; const manual = !!data && data.source !== 'STEAM';
return ( return (
<PageContainer className="p-4 md:p-8 space-y-6"> <PageContainer className="p-4 md:p-8 space-y-6">
+9
View File
@@ -240,6 +240,15 @@ describe('GameDetail of a game added by hand', () => {
expect(screen.getByText('MANUAL_GAME.NO_ACHIEVEMENTS')).toBeTruthy(); expect(screen.getByText('MANUAL_GAME.NO_ACHIEVEMENTS')).toBeTruthy();
}); });
it('treats a game from a PlayStation import like a game added by hand', async () => {
answer({...manualGame, source: 'PSN', platform: 'PS5'},
{'PUT /api/library/games/-1025/manual': noBody, 'DELETE /api/library/games/-1025': noBody});
renderDetail(-1025);
expect(await screen.findByLabelText('MANUAL_GAME.PLATFORM')).toBeTruthy();
expect(screen.getByText('MANUAL_GAME.NO_ACHIEVEMENTS')).toBeTruthy();
});
it('saves platform and hours', async () => { it('saves platform and hours', async () => {
renderDetail(-1025); renderDetail(-1025);
fireEvent.change(await screen.findByLabelText('MANUAL_GAME.HOURS'), {target: {value: '2.5'}}); fireEvent.change(await screen.findByLabelText('MANUAL_GAME.HOURS'), {target: {value: '2.5'}});
+4 -3
View File
@@ -14,8 +14,8 @@ import {decodeStoreList} from '../utils/storeList.js';
/** /**
* Many games at once: a list or a CSV goes to Core, which matches it with IGDB in the background; the user checks * Many games at once: a list or a CSV goes to Core, which matches it with IGDB in the background; the user checks
* the matches and saves them. ?id= opens one import; the history below can undo a saved import. #data= is the list * the matches and saves them. ?id= opens one import; ?source=PSN opens the PlayStation input; the history below can
* the bookmarklet read on a store page. * undo a saved import. #data= is the list the bookmarklet read on a store page.
*/ */
export default function ImportPage() { export default function ImportPage() {
const {t} = useTranslation(); const {t} = useTranslation();
@@ -84,7 +84,8 @@ export default function ImportPage() {
<ImportView key={shownId} id={shownId} onDone={done} onChanged={loadImports}/> <ImportView key={shownId} id={shownId} onDone={done} onChanged={loadImports}/>
) : ( ) : (
// A store list comes after the first answer: a new key opens its tab. // A store list comes after the first answer: a new key opens its tab.
<ImportInput key={storeRows ? 'store' : 'input'} storeRows={storeRows} onCreated={(created) => { <ImportInput key={storeRows ? 'store' : 'input'} storeRows={storeRows}
initialSource={searchParams.get('source') === 'PSN' ? 'PSN' : null} onCreated={(created) => {
setStoreRows(null); setStoreRows(null);
show(created); show(created);
loadImports(); loadImports();
+102
View File
@@ -0,0 +1,102 @@
// @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();
});
});
+6 -1
View File
@@ -27,7 +27,7 @@ const BUDGETS = [
// games without that data. // games without that data.
const MOODS = ['ANY', 'STORY', 'COOP', 'CHILL', 'ACTION', 'SCARY', 'SPEEDRUN']; const MOODS = ['ANY', 'STORY', 'COOP', 'CHILL', 'ACTION', 'SCARY', 'SPEEDRUN'];
// The rows in the order Core sends them; RANDOM is the mood row. // The rows in the order Core sends them; RANDOM is the mood row.
const ROW_ORDER = ['NEXT_UP', 'SERIES', 'SIMILAR', 'STUDIO', 'LONGEST', 'RANDOM']; const ROW_ORDER = ['NEXT_UP', 'SERIES', 'SIMILAR', 'STUDIO', 'FRIENDS', 'LONGEST', 'RANDOM'];
// The backend reads only this many excludes. With a bigger backlog the oldest seen games can come back. // The backend reads only this many excludes. With a bigger backlog the oldest seen games can come back.
const MAX_EXCLUDES = 100; const MAX_EXCLUDES = 100;
const ROW_CLASS = 'flex gap-4 overflow-x-auto snap-x snap-mandatory pb-2 sm:grid sm:grid-cols-3 sm:overflow-visible'; const ROW_CLASS = 'flex gap-4 overflow-x-auto snap-x snap-mandatory pb-2 sm:grid sm:grid-cols-3 sm:overflow-visible';
@@ -259,6 +259,11 @@ const reasonText = (reason, t, language) => {
if (reason?.source === 'STUDIO') { if (reason?.source === 'STUDIO') {
return t('PLAY_NEXT.REASON.STUDIO', {name: reason.studioName}); return t('PLAY_NEXT.REASON.STUDIO', {name: reason.studioName});
} }
if (reason?.source === 'FRIENDS') {
return reason.friendFinished
? t('PLAY_NEXT.REASON.FRIENDS_FINISHED', {name: reason.friendName})
: t('PLAY_NEXT.REASON.FRIENDS_PLAYED', {name: reason.friendName, playtime: formatPlaytime(reason.friendPlaytimeMinutes)});
}
if (reason?.source === 'LONGEST') { if (reason?.source === 'LONGEST') {
return t('PLAY_NEXT.REASON.LONGEST', {date: formatDate(reason.firstSeenAt, language, {dateStyle: 'medium'})}); return t('PLAY_NEXT.REASON.LONGEST', {date: formatDate(reason.firstSeenAt, language, {dateStyle: 'medium'})});
} }
+21 -2
View File
@@ -21,6 +21,9 @@ const ALL = () => [
row('SERIES', game(6, {reason: {source: 'SERIES', seriesName: 'Mass Effect'}})), row('SERIES', game(6, {reason: {source: 'SERIES', seriesName: 'Mass Effect'}})),
row('SIMILAR', game(3, {reason: {source: 'SIMILAR', anchorAppid: 9, anchorName: 'Celeste', anchorPlaytimeMinutes: 600}})), row('SIMILAR', game(3, {reason: {source: 'SIMILAR', anchorAppid: 9, anchorName: 'Celeste', anchorPlaytimeMinutes: 600}})),
row('STUDIO', game(8, {reason: {source: 'STUDIO', studioName: 'BioWare'}})), row('STUDIO', game(8, {reason: {source: 'STUDIO', studioName: 'BioWare'}})),
row('FRIENDS',
game(11, {reason: {source: 'FRIENDS', friendName: 'Max', friendFinished: true, friendPlaytimeMinutes: null}}),
game(12, {reason: {source: 'FRIENDS', friendName: 'Anna', friendFinished: false, friendPlaytimeMinutes: 2400}})),
row('LONGEST', game(4, {reason: {source: 'LONGEST', firstSeenAt: '2024-03-12T10:00:00Z'}})), row('LONGEST', game(4, {reason: {source: 'LONGEST', firstSeenAt: '2024-03-12T10:00:00Z'}})),
row('RANDOM', game(5, {reason: {source: 'RANDOM'}})), row('RANDOM', game(5, {reason: {source: 'RANDOM'}})),
]; ];
@@ -78,7 +81,7 @@ describe('PlayNextPage', () => {
await screen.findByText('Game 1'); await screen.findByText('Game 1');
expect(screen.getAllByRole('heading', {level: 2}).map((h) => h.textContent)).toEqual([ expect(screen.getAllByRole('heading', {level: 2}).map((h) => h.textContent)).toEqual([
'PLAY_NEXT.CATEGORY.NEXT_UP', 'PLAY_NEXT.CATEGORY.SERIES', 'PLAY_NEXT.CATEGORY.SIMILAR', 'PLAY_NEXT.CATEGORY.NEXT_UP', 'PLAY_NEXT.CATEGORY.SERIES', 'PLAY_NEXT.CATEGORY.SIMILAR',
'PLAY_NEXT.CATEGORY.STUDIO', 'PLAY_NEXT.CATEGORY.LONGEST', 'PLAY_NEXT.CATEGORY.RANDOM']); 'PLAY_NEXT.CATEGORY.STUDIO', 'PLAY_NEXT.CATEGORY.FRIENDS', 'PLAY_NEXT.CATEGORY.LONGEST', 'PLAY_NEXT.CATEGORY.RANDOM']);
const params = pickerUrls()[0]; const params = pickerUrls()[0];
expect(params.has('count')).toBe(false); expect(params.has('count')).toBe(false);
expect(params.has('category')).toBe(false); expect(params.has('category')).toBe(false);
@@ -191,6 +194,20 @@ describe('PlayNextPage', () => {
expect(pickerUrls()[2].has('mood')).toBe(false); expect(pickerUrls()[2].has('mood')).toBe(false);
}); });
it('rerolls the friends row with category=FRIENDS and without a mood', async () => {
mockRows(ALL(), {FRIENDS: [row('FRIENDS', game(13))]});
renderPage();
await screen.findByText('Game 1');
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.STORY'}));
await screen.findByText('Game 5');
fireEvent.click(rerollOf('PLAY_NEXT.CATEGORY.FRIENDS'));
await screen.findByText('Game 13');
expect(pickerUrls()[2].get('category')).toBe('FRIENDS');
expect(pickerUrls()[2].has('mood')).toBe(false);
});
it('starts a row over when it has no unseen game left', async () => { it('starts a row over when it has no unseen game left', async () => {
mockBackend((params) => { mockBackend((params) => {
if (!params.get('category')) return ALL(); if (!params.get('category')) return ALL();
@@ -244,7 +261,7 @@ describe('PlayNextPage', () => {
expect(await screen.findByText('PLAY_NEXT.EMPTY_MOOD')).toBeTruthy(); expect(await screen.findByText('PLAY_NEXT.EMPTY_MOOD')).toBeTruthy();
}); });
it('shows a reason line for the series, similar, studio and longest rows only', async () => { it('shows a reason line for the series, similar, studio, friends and longest rows only', async () => {
mockRows(); mockRows();
renderPage(); renderPage();
@@ -252,6 +269,8 @@ describe('PlayNextPage', () => {
expect(await screen.findByText('PLAY_NEXT.REASON.SIMILAR')).toBeTruthy(); expect(await screen.findByText('PLAY_NEXT.REASON.SIMILAR')).toBeTruthy();
expect(screen.getByText('PLAY_NEXT.REASON.SERIES')).toBeTruthy(); expect(screen.getByText('PLAY_NEXT.REASON.SERIES')).toBeTruthy();
expect(screen.getByText('PLAY_NEXT.REASON.STUDIO')).toBeTruthy(); expect(screen.getByText('PLAY_NEXT.REASON.STUDIO')).toBeTruthy();
expect(screen.getByText('PLAY_NEXT.REASON.FRIENDS_FINISHED')).toBeTruthy();
expect(screen.getByText('PLAY_NEXT.REASON.FRIENDS_PLAYED')).toBeTruthy();
expect(screen.getByText('PLAY_NEXT.REASON.LONGEST')).toBeTruthy(); expect(screen.getByText('PLAY_NEXT.REASON.LONGEST')).toBeTruthy();
expect(screen.queryByText(/PLAY_NEXT\.REASON\.(RANDOM|NEXT_UP)/)).toBeNull(); expect(screen.queryByText(/PLAY_NEXT\.REASON\.(RANDOM|NEXT_UP)/)).toBeNull();
}); });
+3
View File
@@ -12,6 +12,7 @@ import DeleteAccountCard from '../components/DeleteAccountCard.jsx';
import FollowsCard from '../components/FollowsCard.jsx'; import FollowsCard from '../components/FollowsCard.jsx';
import PreferencesCard from '../components/PreferencesCard.jsx'; import PreferencesCard from '../components/PreferencesCard.jsx';
import ProfileCard from '../components/ProfileCard.jsx'; import ProfileCard from '../components/ProfileCard.jsx';
import PsnAccountCard from '../components/PsnAccountCard.jsx';
import SteamAccountCard from '../components/SteamAccountCard.jsx'; import SteamAccountCard from '../components/SteamAccountCard.jsx';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import useSettingsLoad from '../hooks/useSettingsLoad.js'; import useSettingsLoad from '../hooks/useSettingsLoad.js';
@@ -73,6 +74,8 @@ export default function SettingsPage() {
<SteamAccountCard steamId={profile?.steamId} verified={!!profile?.steamIdVerified} <SteamAccountCard steamId={profile?.steamId} verified={!!profile?.steamIdVerified}
onUnlinked={() => updateProfile((p) => ({...p, steamId: null, steamIdVerified: false}))}/> onUnlinked={() => updateProfile((p) => ({...p, steamId: null, steamIdVerified: false}))}/>
<PsnAccountCard/>
<ProfileCard displayName={displayName}/> <ProfileCard displayName={displayName}/>
<FollowsCard/> <FollowsCard/>
+1
View File
@@ -33,6 +33,7 @@ function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings'} = {}) {
'POST /api/auth/refresh': noBody, 'POST /api/auth/refresh': noBody,
'GET /api/version': json(404, {}), 'GET /api/version': json(404, {}),
'GET /api/follows': json(200, {following: [], followers: []}), 'GET /api/follows': json(200, {following: [], followers: []}),
'GET /api/library/psn-account': json(200, {enabled: false, linked: false}),
}); });
renderWithProviders(<SettingsPage/>, {path: '/settings', entries: [entry]}); renderWithProviders(<SettingsPage/>, {path: '/settings', entries: [entry]});
} }
@@ -37,6 +37,7 @@ describe('SettingsPage Steam toast', () => {
'POST /api/auth/refresh': {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }}, 'POST /api/auth/refresh': {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }},
'GET /api/version': json(404, {}), 'GET /api/version': json(404, {}),
'GET /api/follows': json(200, {following: [], followers: []}), 'GET /api/follows': json(200, {following: [], followers: []}),
'GET /api/library/psn-account': json(200, {enabled: false, linked: false}),
}); });
}); });
+2
View File
@@ -34,6 +34,7 @@ const authRoutes = (me) => ({
'PUT /api/users/settings/owner-lists': noBody, 'PUT /api/users/settings/owner-lists': noBody,
'GET /api/version': json(404, {}), 'GET /api/version': json(404, {}),
'GET /api/follows': json(200, {following: [], followers: []}), 'GET /api/follows': json(200, {following: [], followers: []}),
'GET /api/library/psn-account': json(200, {enabled: false, linked: false}),
}); });
function renderPage(me) { function renderPage(me) {
@@ -294,6 +295,7 @@ describe('SettingsPage session', () => {
'POST /api/auth/refresh': json(401, {}), 'POST /api/auth/refresh': json(401, {}),
'GET /api/version': json(404, {}), 'GET /api/version': json(404, {}),
'GET /api/follows': json(200, {following: [], followers: []}), 'GET /api/follows': json(200, {following: [], followers: []}),
'GET /api/library/psn-account': json(200, {enabled: false, linked: false}),
}); });
const {router} = renderWithProviders(<SettingsPage/>, {path: '/settings', entries: ['/settings'], const {router} = renderWithProviders(<SettingsPage/>, {path: '/settings', entries: ['/settings'],
guarded: true, routes: {'/login': <p>login</p>}}); guarded: true, routes: {'/login': <p>login</p>}});
+2 -2
View File
@@ -51,8 +51,8 @@ export const preselectPlatform = (own, platforms, favorite) => {
return find(favorite) ?? (platforms.length === 1 ? platforms[0] : ''); return find(favorite) ?? (platforms.length === 1 ? platforms[0] : '');
}; };
// A Steam game has no stored platform. // A Steam game has no stored platform; a game added by hand or imported from PlayStation has.
export const platformLabel = (game) => (game.source === 'MANUAL' ? game.platform : 'Steam'); export const platformLabel = (game) => (game.source === 'STEAM' ? 'Steam' : game.platform);
/** /**
* PUT /api/users/settings/favorite-platform; null clears it. The caller handles the response status. * PUT /api/users/settings/favorite-platform; null clears it. The caller handles the response status.
+1
View File
@@ -26,5 +26,6 @@ describe('platformLabel', () => {
it('shows Steam for a Steam game', () => { it('shows Steam for a Steam game', () => {
expect(platformLabel({source: 'STEAM', platform: null})).toBe('Steam'); expect(platformLabel({source: 'STEAM', platform: null})).toBe('Steam');
expect(platformLabel({source: 'MANUAL', platform: 'PS5'})).toBe('PS5'); expect(platformLabel({source: 'MANUAL', platform: 'PS5'})).toBe('PS5');
expect(platformLabel({source: 'PSN', platform: 'PS5'})).toBe('PS5');
}); });
}); });
+19
View File
@@ -0,0 +1,19 @@
import {apiFetch} from './api.js';
const PSN_URL = () => `${import.meta.env.VITE_CORE_API_URL}/api/library/psn-account`;
// The calls return the raw response; the caller handles the status.
export const getPsnAccount = () => apiFetch(PSN_URL());
// The NPSSO goes only into this request body: Core uses it once and never stores it.
export const linkPsn = (npsso) =>
apiFetch(PSN_URL(), {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({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';
export const isExpired = (account) => !!account?.expiresAt && new Date(account.expiresAt).getTime() <= Date.now();