Public Access
Get started links to registration and says an admin approves accounts. Back navigation restores the library scroll; search no longer fills history. Steam and PSN unlink ask first. Any close marks news as seen. Confetti plays only when the level rises on the page. Count-ups respect reduced motion. Html lang follows the UI language. Add a 404 page, translated role and dialog close label.
139 lines
6.3 KiB
React
139 lines
6.3 KiB
React
import {useState} from 'react';
|
|
import {Link} from 'react-router-dom';
|
|
import {useTranslation} from 'react-i18next';
|
|
import {Gamepad, Link2, Loader2} 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 UnlinkDialog from './UnlinkDialog.jsx';
|
|
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. Linking starts a sync; titles the sync could not match wait in an import review.
|
|
*/
|
|
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>
|
|
<p className="text-sm text-muted-foreground">{t('SETTINGS.PSN.SYNC_HINT')}</p>
|
|
{expired && form}
|
|
<div className="flex flex-wrap gap-2">
|
|
{account.reviewImportId && (
|
|
<Button asChild variant="outline">
|
|
<Link to={`/import?id=${account.reviewImportId}`}>{t('SETTINGS.PSN.REVIEW')}</Link>
|
|
</Button>
|
|
)}
|
|
<UnlinkDialog label={t('SETTINGS.PSN.UNLINK')} title={t('SETTINGS.PSN.UNLINK_TITLE')}
|
|
description={t('SETTINGS.PSN.UNLINK_EXPLAIN')}
|
|
confirmLabel={t('SETTINGS.PSN.UNLINK_CONFIRM')} onConfirm={unlink} disabled={busy}/>
|
|
</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>
|
|
);
|
|
}
|