feat(follow): add feed links, following page and tile (#52, #51)
Build, Test & Push Frontend / quality-check (push) Successful in 37s
Build, Test & Push Frontend / docker (push) Successful in 1m23s

Feed links go to the game and to the profile view of that game.
Add /following page, nav item and an opt-in Following tile.
Games not in your library show a catalog view with an add button.
This commit is contained in:
lobo committed 2026-10-14 16:23:11 +02:00
1 parent 720764bf68
commit 5be0d42bca
31 files changed
+692 -67

No files matched your search

+41 -19
View File
@@ -7,6 +7,7 @@ import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import PageContainer from '../components/PageContainer.jsx';
import {formatDate} from '../utils/format.js';
import {profileGameHref, profileHref} from '../utils/people.js';
import {digestParts, fetchInbox, fetchInboxDetails, groupDetails, markInboxRead} from '../utils/inbox.js';
const PSN_KINDS = ['PSN_EXPIRING', 'PSN_EXPIRED'];
@@ -43,8 +44,25 @@ export default function InboxPage() {
if (PSN_KINDS.includes(note.kind)) return t(`INBOX.${note.kind}`);
const name = note.displayName ?? t('FOLLOW.PRIVATE_USER');
if (note.kind === 'VISIBILITY') return t('INBOX.VISIBILITY', {name});
const parts = digestParts(note).map(({key, count}) => t(`INBOX.PART_${key}`, {count})).join(', ');
return t('INBOX.DIGEST', {name, parts});
return t('INBOX.DIGEST', {name, parts: partsText(note)});
};
const partsText = (note) => digestParts(note).map(({key, count}) => t(`INBOX.PART_${key}`, {count})).join(', ');
// A link in a button is invalid, so the profile link sits next to the toggle button.
const noteLine = (note) => {
const linked = note.kind === 'DIGEST' && note.profileName;
const label = linked ? t('INBOX.DIGEST_PARTS', {parts: partsText(note)}) : text(note);
const line = note.kind === 'DIGEST' && note.details ? (
<button type="button" aria-expanded={open === note.id}
className="inline-flex cursor-pointer items-center gap-1 break-words text-left hover:underline"
onClick={() => toggle(note.id)}>
{label}
<ChevronDown className={cn('size-4 shrink-0 transition-transform', open === note.id && 'rotate-180')}/>
</button>
) : label;
if (!linked) return line;
return <><Link to={profileHref(note.profileName)} className="underline">{note.displayName}</Link>: {line}</>;
};
const toggle = async (id) => {
@@ -59,20 +77,31 @@ export default function InboxPage() {
setDetails((current) => ({...current, [id]: ok || status === 404 ? {status: 'ready', results: data?.results ?? []} : {status: 'error', results: []}}));
};
const gameLines = (group, {gameName, items}, noteId) => {
const gameLabel = (note, {gameName, appid}) => (
<>
{appid == null ? gameName : <Link to={`/library/${appid}`} className="hover:underline">{gameName}</Link>}
{appid != null && note.profileName && (
<Link to={profileGameHref(note.profileName, appid)}
className="ml-2 text-xs text-muted-foreground underline">{t('INBOX.ON_PROFILE')}</Link>
)}
</>
);
const gameLines = (group, gameGroup, note) => {
const {gameName, items} = gameGroup;
if (group.kind === 'STATUS') {
return items.map((item, i) => (
<li key={`${gameName}-${i}`}>
{gameName}: {t(`BACKLOG.STATUS.${item.fromStatus}`)} → {t(`BACKLOG.STATUS.${item.toStatus}`)}
{gameLabel(note, gameGroup)}: {t(`BACKLOG.STATUS.${item.fromStatus}`)} → {t(`BACKLOG.STATUS.${item.toStatus}`)}
</li>
));
}
if (group.kind !== 'ACHIEVEMENT') return <li key={gameName}>{gameName}</li>;
const key = `${noteId}:${gameName}`;
if (group.kind !== 'ACHIEVEMENT') return <li key={gameName}>{gameLabel(note, gameGroup)}</li>;
const key = `${note.id}:${gameName}`;
const shown = showAll[key] ? items : items.slice(0, ACHIEVEMENTS_SHOWN);
return (
<li key={gameName}>
{gameName}
{gameLabel(note, gameGroup)}
<ul className="ml-4 list-disc">
{shown.map((item, i) => <li key={i}>{item.itemName}</li>)}
</ul>
@@ -86,8 +115,8 @@ export default function InboxPage() {
);
};
const detailBlock = (noteId) => {
const entry = details[noteId];
const detailBlock = (note) => {
const entry = details[note.id];
if (!entry || entry.status === 'loading') return <Loader2 className="size-4 animate-spin text-muted-foreground"/>;
if (entry.status === 'error') return <p className="text-xs text-destructive">{t('INBOX.DETAILS_ERROR')}</p>;
const groups = groupDetails(entry.results);
@@ -95,7 +124,7 @@ export default function InboxPage() {
return groups.map((group) => (
<div key={group.kind} className="space-y-1">
<h3 className="text-xs font-semibold uppercase text-muted-foreground">{t(`INBOX.DETAIL_${group.kind}`)}</h3>
<ul className="space-y-0.5 text-sm">{group.games.map((gameGroup) => gameLines(group, gameGroup, noteId))}</ul>
<ul className="space-y-0.5 text-sm">{group.games.map((gameGroup) => gameLines(group, gameGroup, note))}</ul>
</div>
));
};
@@ -118,19 +147,12 @@ export default function InboxPage() {
{formatDate(note.createdAt, i18n.language, {dateStyle: 'medium'})}
</p>
<p className={cn('break-words text-sm', !note.read && 'font-semibold')}>
{note.kind === 'DIGEST' && note.details ? (
<button type="button" aria-expanded={open === note.id}
className="inline-flex cursor-pointer items-center gap-1 break-words text-left hover:underline"
onClick={() => toggle(note.id)}>
{text(note)}
<ChevronDown className={cn('size-4 shrink-0 transition-transform', open === note.id && 'rotate-180')}/>
</button>
) : text(note)}
{noteLine(note)}
{PSN_KINDS.includes(note.kind) && (
<> <Link to="/settings" className="underline">{t('INBOX.OPEN_SETTINGS')}</Link></>
)}
</p>
{open === note.id && <div className="space-y-2 pt-1">{detailBlock(note.id)}</div>}
{open === note.id && <div className="space-y-2 pt-1">{detailBlock(note)}</div>}
</li>
))}
</ul>