feat(ui): add follow button, follows card, and inbox page
Build, Test & Push Frontend / quality-check (push) Successful in 34s
Build, Test & Push Frontend / docker (push) Successful in 23s

This commit is contained in:
lobo committed 2026-10-06 16:22:22 +02:00
1 parent 3aed243e35
commit 43c4862606
23 files changed
+603 -2

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Loader2} from 'lucide-react';
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 {digestParts, fetchInbox, markInboxRead} from '../utils/inbox.js';
/**
* The newest notes about the people the user follows. Opening the page marks them read.
*/
export default function InboxPage() {
const {t, i18n} = useTranslation();
const [notes, setNotes] = useState([]);
const [status, setStatus] = useState('loading');
useEffect(() => {
(async () => {
const {ok, status: code, data} = await fetchInbox();
if (code === 401) return;
if (!ok) {
setStatus('error');
return;
}
setNotes(data.results);
setStatus('ready');
// A failed mark is silent: the notes show as unread again on the next visit.
if (data.unread > 0) markInboxRead();
})();
}, []);
const text = (note) => {
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 (
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
<Card>
<CardHeader>
<CardTitle>{t('INBOX.TITLE')}</CardTitle>
</CardHeader>
<CardContent>
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
{status === 'error' && <p className="text-sm text-destructive">{t('INBOX.LOAD_ERROR')}</p>}
{status === 'ready' && notes.length === 0 && <p className="text-sm text-muted-foreground">{t('INBOX.EMPTY')}</p>}
{status === 'ready' && notes.length > 0 && (
<ul className="divide-y">
{notes.map((note) => (
<li key={note.id} className="space-y-1 py-3">
<p className="text-xs text-muted-foreground">
{formatDate(note.createdAt, i18n.language, {dateStyle: 'medium'})}
</p>
<p className={cn('break-words text-sm', !note.read && 'font-semibold')}>{text(note)}</p>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</PageContainer>
);
}
+52
View File
@@ -0,0 +1,52 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, screen, waitFor} from '@testing-library/react';
import InboxPage from './InboxPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const note = (id, extra) => ({id, kind: 'DIGEST', displayName: 'Lobo', newGames: 0, achievements: 0, statusChanges: 0,
wishlistAdds: 0, createdAt: '2026-10-06T10:00:00Z', read: false, ...extra});
const putCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT');
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('InboxPage', () => {
it('showsADigestWithOnlyTheNonZeroPartsAndMarksTheNotesRead', async () => {
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 12, wishlistAdds: 2})], unread: 1}),
'PUT /api/inbox/read': json(204)});
renderWithProviders(<InboxPage/>);
expect(await screen.findByText('INBOX.DIGEST')).toBeTruthy();
await waitFor(() => expect(putCalls()).toHaveLength(1));
});
it('showsAVisibilityNote', async () => {
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {kind: 'VISIBILITY'})], unread: 1}),
'PUT /api/inbox/read': json(204)});
renderWithProviders(<InboxPage/>);
expect(await screen.findByText('INBOX.VISIBILITY')).toBeTruthy();
});
it('saysSoWhenThereAreNoNotesAndMarksNothing', async () => {
fetchRoutes({'GET /api/inbox': json(200, {results: [], unread: 0})});
renderWithProviders(<InboxPage/>);
expect(await screen.findByText('INBOX.EMPTY')).toBeTruthy();
expect(putCalls()).toEqual([]);
});
it('showsAnErrorAndMarksNothingWhenTheLoadFails', async () => {
fetchRoutes({'GET /api/inbox': json(500, {})});
renderWithProviders(<InboxPage/>);
expect(await screen.findByText('INBOX.LOAD_ERROR')).toBeTruthy();
expect(putCalls()).toEqual([]);
});
});
+2
View File
@@ -9,6 +9,7 @@ import {Skeleton} from '@/components/ui/skeleton';
import {cn} from '@/lib/utils';
import GameCover from '../components/GameCover.jsx';
import FitText from '../components/FitText.jsx';
import FollowButton from '../components/people/FollowButton.jsx';
import ComingBadge from '../components/ComingBadge.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx';
import {formatDay} from '../utils/format.js';
@@ -182,6 +183,7 @@ export default function PublicProfilePage() {
<p className="mt-2 text-sm text-muted-foreground">{t('PROFILE.MEMBER_SINCE', {date: memberSince})}</p>
)}
</div>
{viewer && !owner && <FollowButton displayName={profile.displayName}/>}
{owner && !editing && (
<Button variant="outline" size="sm" className="relative mt-4" onClick={() => setEditing(true)}>
<LayoutGrid/>{t('TILES.CUSTOMIZE')}
+29
View File
@@ -25,6 +25,7 @@ function mockBackends({name = 'Lobo', profileStatus = 200, libraryStatus = 200,
...(me && {
'GET /api/users/me': json(200, me),
'PUT /api/users/settings/profile-tiles': json(204, {}),
'GET /api/follows': json(200, {following: [], followers: []}),
}),
});
}
@@ -238,6 +239,34 @@ describe('PublicProfilePage', () => {
}
});
it('offers "Follow" to a logged-in visitor, not to the owner and not to a guest', async () => {
localStorage.setItem('tbp_session', '1');
try {
mockBackends({me: {displayName: 'Someone', role: 'USER'}});
const visitor = renderPage();
expect(await screen.findByRole('button', {name: 'FOLLOW.FOLLOW'})).toBeTruthy();
visitor.unmount();
mockBackends({me: {displayName: 'Lobo', role: 'USER'}});
renderPage();
await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'});
expect(screen.queryByRole('button', {name: 'FOLLOW.FOLLOW'})).toBeNull();
expect(fetch).not.toHaveBeenCalledWith(expect.stringContaining('/api/follows'), expect.anything());
} finally {
localStorage.clear();
}
});
it('offers no "Follow" to a guest', async () => {
mockBackends();
renderPage();
await screen.findByText('Portal 2');
expect(screen.queryByRole('button', {name: 'FOLLOW.FOLLOW'})).toBeNull();
expect(fetch).not.toHaveBeenCalledWith(expect.stringContaining('/api/follows'), expect.anything());
});
it('shows no "Customize" to a visitor', async () => {
localStorage.setItem('tbp_session', '1');
try {
+3
View File
@@ -9,6 +9,7 @@ import AppearanceCard from '../components/AppearanceCard.jsx';
import BuildVersionCard from '../components/BuildVersionCard.jsx';
import ChangePasswordCard from '../components/ChangePasswordCard.jsx';
import DeleteAccountCard from '../components/DeleteAccountCard.jsx';
import FollowsCard from '../components/FollowsCard.jsx';
import PreferencesCard from '../components/PreferencesCard.jsx';
import ProfileCard from '../components/ProfileCard.jsx';
import SteamAccountCard from '../components/SteamAccountCard.jsx';
@@ -74,6 +75,8 @@ export default function SettingsPage() {
<ProfileCard displayName={displayName}/>
<FollowsCard/>
<BuildVersionCard/>
<ChangePasswordCard/>
+1
View File
@@ -32,6 +32,7 @@ function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings'} = {}) {
'PUT /api/users/settings': noBody,
'POST /api/auth/refresh': noBody,
'GET /api/version': json(404, {}),
'GET /api/follows': json(200, {following: [], followers: []}),
});
renderWithProviders(<SettingsPage/>, {path: '/settings', entries: [entry]});
}
@@ -36,6 +36,7 @@ describe('SettingsPage Steam toast', () => {
'GET /api/users/me': json(200, profile),
'POST /api/auth/refresh': {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }},
'GET /api/version': json(404, {}),
'GET /api/follows': json(200, {following: [], followers: []}),
});
});
+2
View File
@@ -33,6 +33,7 @@ const authRoutes = (me) => ({
'PUT /api/users/settings/favorite-platform': noBody,
'PUT /api/users/settings/owner-lists': noBody,
'GET /api/version': json(404, {}),
'GET /api/follows': json(200, {following: [], followers: []}),
});
function renderPage(me) {
@@ -292,6 +293,7 @@ describe('SettingsPage session', () => {
'GET /api/users/me': json(401, {}),
'POST /api/auth/refresh': json(401, {}),
'GET /api/version': json(404, {}),
'GET /api/follows': json(200, {following: [], followers: []}),
});
const {router} = renderWithProviders(<SettingsPage/>, {path: '/settings', entries: ['/settings'],
guarded: true, routes: {'/login': <p>login</p>}});