Public Access
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:
1 parent
720764bf68
commit
5be0d42bca
31 files changed
+692
-67
No files matched your search
@@ -14,6 +14,7 @@ import AdminHintsPage from './pages/AdminHintsPage.jsx';
|
||||
import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx';
|
||||
import AdminNewsPage from './pages/AdminNewsPage.jsx';
|
||||
import InboxPage from './pages/InboxPage.jsx';
|
||||
import FollowingPage from './pages/FollowingPage.jsx';
|
||||
import QuestionsPage from './pages/QuestionsPage.jsx';
|
||||
import WhatsNewPage from './pages/WhatsNewPage.jsx';
|
||||
import WelcomePage from './pages/WelcomePage.jsx';
|
||||
@@ -89,6 +90,7 @@ function App() {
|
||||
<Route path="/backlog" element={<BacklogPage/>}/>
|
||||
<Route path="/play-next" element={<PlayNextPage/>}/>
|
||||
<Route path="/stats" element={<StatsPage/>}/>
|
||||
<Route path="/following" element={<FollowingPage/>}/>
|
||||
<Route path="/import" element={<ImportPage/>}/>
|
||||
<Route path="/points" element={<PointsPage/>}/>
|
||||
<Route path="/settings" element={<SettingsPage/>}/>
|
||||
|
||||
@@ -22,11 +22,14 @@ import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
/**
|
||||
* Search IGDB and add a game the user owns outside Steam, e.g. on PlayStation or Switch; or check several and review
|
||||
* them on the import page.
|
||||
*
|
||||
* @param onAdded called with the new appid before the navigation, for a page that shows the same address
|
||||
* @param initialQuery the search text to start with, e.g. the name of a game seen elsewhere
|
||||
*/
|
||||
export default function AddGamePanel({onClose}) {
|
||||
export default function AddGamePanel({onClose, onAdded, initialQuery = ''}) {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState('');
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [picked, setPicked] = useState(null);
|
||||
// igdbId -> hit, over several searches.
|
||||
const [selected, setSelected] = useState(() => new Map());
|
||||
@@ -62,7 +65,8 @@ export default function AddGamePanel({onClose}) {
|
||||
</div>
|
||||
|
||||
{picked ? (
|
||||
<AddForm hit={picked} favorite={favorite} onBack={() => setPicked(null)} navigate={navigate}/>
|
||||
<AddForm hit={picked} favorite={favorite} onBack={() => setPicked(null)} navigate={navigate}
|
||||
onAdded={onAdded}/>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">{t('MANUAL_GAME.ADD_HINT')}</p>
|
||||
@@ -82,7 +86,7 @@ export default function AddGamePanel({onClose}) {
|
||||
);
|
||||
}
|
||||
|
||||
function AddForm({hit, favorite, onBack, navigate}) {
|
||||
function AddForm({hit, favorite, onBack, navigate, onAdded}) {
|
||||
const {t} = useTranslation();
|
||||
const [platform, setPlatform] = useState(() => preselectPlatform(null, hit.platforms, favorite));
|
||||
// The favorite can arrive after the pick; it fills only a platform the user has not picked yet.
|
||||
@@ -113,6 +117,7 @@ function AddForm({hit, favorite, onBack, navigate}) {
|
||||
const {appid} = await response.json();
|
||||
toast.success(t('MANUAL_GAME.ADDED', {name: hit.name}));
|
||||
check(appid, {status, tell: true});
|
||||
onAdded?.(appid);
|
||||
navigate(`/library/${appid}`);
|
||||
} catch (err) {
|
||||
console.error('add game error:', err);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Bell, ChartColumn, CircleQuestionMark, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun, Users} from 'lucide-react';
|
||||
import {Bell, ChartColumn, CircleQuestionMark, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun, UserCheck, Users} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
@@ -37,6 +37,7 @@ const NAV_ITEMS = [
|
||||
{to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices},
|
||||
{to: '/questions', labelKey: 'NAV.QUESTIONS', Icon: CircleQuestionMark},
|
||||
{to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn},
|
||||
{to: '/following', labelKey: 'NAV.FOLLOWING', Icon: UserCheck},
|
||||
{to: '/', labelKey: 'NAV.COMMUNITY', Icon: Users, iconOnly: true},
|
||||
];
|
||||
|
||||
|
||||
@@ -49,6 +49,12 @@ describe('AppHeader', () => {
|
||||
expect(screen.queryByTitle('NAV.COMMUNITY')).toBeNull();
|
||||
});
|
||||
|
||||
it('links to the following page when logged in', () => {
|
||||
loggedInAs({role: 'USER', activeRole: 'USER'});
|
||||
|
||||
expect(screen.getByTitle('NAV.FOLLOWING').getAttribute('href')).toBe('/following');
|
||||
});
|
||||
|
||||
it('links to the community stats when logged in', () => {
|
||||
loggedInAs({role: 'USER', activeRole: 'USER'});
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Plus} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import AddGamePanel from './AddGamePanel.jsx';
|
||||
import FitText from './FitText.jsx';
|
||||
import GameCover from './GameCover.jsx';
|
||||
import TimeToBeat from './TimeToBeat.jsx';
|
||||
import {fetchPreview} from '../utils/catalog.js';
|
||||
|
||||
/**
|
||||
* A game the user does not have: its catalog data and a way to add it. The add flow is the search of the library,
|
||||
* started with the game's name; adding ends on the user's own game page.
|
||||
*
|
||||
* @param onAdded called after the game was added, so the page can load the user's own game
|
||||
* @param fallback shown when the catalog does not know the game or the request fails
|
||||
*/
|
||||
export default function CatalogGameCard({appid, fallback, onAdded}) {
|
||||
const {t} = useTranslation();
|
||||
const [preview, setPreview] = useState(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setPreview(null);
|
||||
setFailed(false);
|
||||
setAdding(false);
|
||||
(async () => {
|
||||
const {ok, data} = await fetchPreview(appid);
|
||||
if (cancelled) return;
|
||||
if (ok && data) setPreview(data);
|
||||
else setFailed(true);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [appid]);
|
||||
|
||||
if (failed) return fallback;
|
||||
if (!preview) return <Skeleton className="h-52 rounded-xl"/>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="gap-4 p-5 md:flex-row md:items-start">
|
||||
<GameCover appid={preview.appid} imageUrl={preview.imageUrl} className="w-full md:w-72 md:shrink-0"/>
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<h1 className="@container text-2xl font-bold"><FitText text={preview.name}/></h1>
|
||||
{preview.genres?.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground">{preview.genres.join(', ')}</p>
|
||||
)}
|
||||
<TimeToBeat time={preview.timeToBeat}/>
|
||||
<p className="text-sm text-muted-foreground">{t('CATALOG.NOT_IN_LIBRARY')}</p>
|
||||
{!adding && <Button onClick={() => setAdding(true)}><Plus/>{t('CATALOG.ADD')}</Button>}
|
||||
</div>
|
||||
</Card>
|
||||
{adding && <AddGamePanel initialQuery={preview.name} onAdded={onAdded} onClose={() => setAdding(false)}/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {ArrowRight, UserCheck} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import FollowingList from './people/FollowingList.jsx';
|
||||
import useFollowing from '../hooks/useFollowing.js';
|
||||
|
||||
/**
|
||||
* Dashboard tile: the first people of the following list. Loads its own data, so a dashboard without the tile pays nothing.
|
||||
*
|
||||
* @param limit how many people the tile size shows
|
||||
*/
|
||||
export default function FollowingCard({limit}) {
|
||||
const {t} = useTranslation();
|
||||
const {following, failed, unfollow} = useFollowing();
|
||||
|
||||
return (
|
||||
<Card className="gap-0 py-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 border-b px-5 py-4">
|
||||
<h2 className="flex items-center gap-2 font-semibold">
|
||||
<UserCheck className="size-4 text-primary"/>
|
||||
{t('FOLLOW.TITLE')}
|
||||
</h2>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/following">{t('FOLLOW.SHOW_ALL')}<ArrowRight/></Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="px-5 py-2">
|
||||
{failed && <p className="py-3 text-sm text-destructive">{t('FOLLOW.LOAD_ERROR')}</p>}
|
||||
{following && <FollowingList entries={following} limit={limit} onUnfollow={unfollow} compact/>}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, screen} from '@testing-library/react';
|
||||
import FollowingCard from './FollowingCard.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()}}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('FollowingCard', () => {
|
||||
it('showsAtMostTheLimitOfPeopleAndLinksToTheFollowingPage', async () => {
|
||||
const following = ['A', 'B', 'C', 'D'].map((displayName, i) => ({id: i, displayName, lastActivityAt: null}));
|
||||
fetchRoutes({'GET /api/follows': json(200, {followers: [], following})});
|
||||
renderWithProviders(<FollowingCard limit={3}/>);
|
||||
|
||||
await screen.findByRole('link', {name: 'A'});
|
||||
|
||||
expect(screen.queryByRole('link', {name: 'D'})).toBeNull();
|
||||
expect(screen.getAllByRole('button', {name: 'FOLLOW.UNFOLLOW'})).toHaveLength(3);
|
||||
expect(screen.getByRole('link', {name: /FOLLOW.SHOW_ALL/}).getAttribute('href')).toBe('/following');
|
||||
});
|
||||
|
||||
it('showsTheEmptyStateWhenNobodyIsFollowed', async () => {
|
||||
fetchRoutes({'GET /api/follows': json(200, {followers: [], following: []})});
|
||||
renderWithProviders(<FollowingCard limit={3}/>);
|
||||
|
||||
expect(await screen.findByText('FOLLOW.NO_FOLLOWING')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,34 +1,15 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Users} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {fetchFollows, removeFollow} from '../utils/follows.js';
|
||||
import useFollowing from '../hooks/useFollowing.js';
|
||||
import {profileHref} from '../utils/people.js';
|
||||
|
||||
export default function FollowsCard() {
|
||||
const {t} = useTranslation();
|
||||
const [follows, setFollows] = useState(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const {ok, data} = await fetchFollows();
|
||||
setFailed(!ok);
|
||||
if (ok) setFollows(data);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const end = async (id) => {
|
||||
const {ok, status} = await removeFollow(id);
|
||||
if (!ok && status !== 401) toast.error(t('FOLLOW.ERROR'));
|
||||
await load();
|
||||
};
|
||||
const {follows, failed, unfollow: end} = useFollowing();
|
||||
|
||||
const link = (displayName) => (
|
||||
<Link to={profileHref(displayName)} className="min-w-0 truncate hover:text-primary hover:underline">{displayName}</Link>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Trophy} from 'lucide-react';
|
||||
|
||||
import {Card} from '@/components/ui/card';
|
||||
import FitText from './FitText.jsx';
|
||||
import GameCover from './GameCover.jsx';
|
||||
import GameStatusBadge from './GameStatusBadge.jsx';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import {fetchProfileGame} from '../utils/people.js';
|
||||
|
||||
/**
|
||||
* One game of a public profile, above its tiles. Shows what the profile shares of it; a game the user hides or does
|
||||
* not have gets one message.
|
||||
*/
|
||||
export default function ProfileGameCard({ticket, appid}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
const [game, setGame] = useState(null);
|
||||
const [missing, setMissing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setGame(null);
|
||||
setMissing(false);
|
||||
(async () => {
|
||||
const {ok, data} = await fetchProfileGame(ticket, appid);
|
||||
if (cancelled) return;
|
||||
if (ok) setGame(data);
|
||||
else setMissing(true);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [ticket, appid]);
|
||||
|
||||
if (missing) {
|
||||
return <Card className="p-5 text-sm text-muted-foreground">{t('PROFILE.GAME_NOT_SHOWN')}</Card>;
|
||||
}
|
||||
if (!game) return null;
|
||||
|
||||
return (
|
||||
<Card className="gap-4 p-5 sm:flex-row sm:items-center">
|
||||
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-full sm:w-56 sm:shrink-0"/>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="@container text-lg font-semibold"><FitText text={game.name ?? `App ${game.appid}`}/></div>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
||||
{game.status && <GameStatusBadge status={game.status}/>}
|
||||
{game.playtimeMinutes != null && <span className="tabular-nums">{formatPlaytime(game.playtimeMinutes)}</span>}
|
||||
{game.achievementsTotal != null && (
|
||||
<span className="flex items-center gap-1 tabular-nums">
|
||||
<Trophy className="size-4 text-primary"/>
|
||||
{t('PROFILE.GAME_ACHIEVEMENTS', {
|
||||
unlocked: game.achievementsUnlocked,
|
||||
total: game.achievementsTotal,
|
||||
percent: game.completionPercent.toLocaleString(i18n.language),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {formatDate} from '../../utils/format.js';
|
||||
import {profileHref} from '../../utils/people.js';
|
||||
|
||||
// Newest activity first, people without any last; Core's name order stays within a tie.
|
||||
const byActivity = (a, b) => (b.lastActivityAt ? Date.parse(b.lastActivityAt) : -Infinity)
|
||||
- (a.lastActivityAt ? Date.parse(a.lastActivityAt) : -Infinity) || 0;
|
||||
|
||||
/**
|
||||
* The people the user follows, with the time of their newest feed message and an unfollow button.
|
||||
*
|
||||
* @param entries `following` of GET /api/follows
|
||||
* @param limit shows only the first `limit` after the sort
|
||||
* @param onUnfollow called with the follow row id
|
||||
* @param compact tighter rows for a tile
|
||||
*/
|
||||
export default function FollowingList({entries, limit, onUnfollow, compact}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
if (entries.length === 0) return <p className="text-sm text-muted-foreground">{t('FOLLOW.NO_FOLLOWING')}</p>;
|
||||
const shown = [...entries].sort(byActivity).slice(0, limit);
|
||||
return (
|
||||
<ul className={cn('divide-y', compact && 'text-sm')}>
|
||||
{shown.map((entry) => (
|
||||
<li key={entry.id} className={cn('flex items-center gap-3', compact ? 'py-2' : 'py-3')}>
|
||||
<span aria-hidden="true"
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 font-semibold uppercase text-primary">
|
||||
{entry.displayName?.[0]}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link to={profileHref(entry.displayName)}
|
||||
className="block truncate font-medium hover:text-primary hover:underline">{entry.displayName}</Link>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{entry.lastActivityAt
|
||||
? t('FOLLOW.LAST_ACTIVITY', {date: formatDate(entry.lastActivityAt, i18n.language, {dateStyle: 'medium'})})
|
||||
: t('FOLLOW.NO_ACTIVITY')}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => onUnfollow(entry.id)}>{t('FOLLOW.UNFOLLOW')}</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {fetchFollows, removeFollow} from '../utils/follows.js';
|
||||
|
||||
/**
|
||||
* The follows answer (`following` and `followers`), and ending a follow with a reload. `follows` and `following` are null until loaded.
|
||||
*/
|
||||
export default function useFollowing() {
|
||||
const {t} = useTranslation();
|
||||
const [follows, setFollows] = useState(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const {ok, data} = await fetchFollows();
|
||||
setFailed(!ok);
|
||||
if (ok) setFollows(data);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const unfollow = async (id) => {
|
||||
const {ok, status} = await removeFollow(id);
|
||||
if (!ok && status !== 401) toast.error(t('FOLLOW.ERROR'));
|
||||
await load();
|
||||
};
|
||||
|
||||
return {follows, following: follows?.following ?? null, failed, unfollow};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {Skeleton} from '@/components/ui/skeleton';
|
||||
import AchievementsCard from '../components/AchievementsCard.jsx';
|
||||
import ActivityCard from '../components/ActivityCard.jsx';
|
||||
import CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.jsx';
|
||||
import FollowingCard from '../components/FollowingCard.jsx';
|
||||
import DashboardHint from '../components/DashboardHint.jsx';
|
||||
import DashboardWelcomeCard from '../components/DashboardWelcomeCard.jsx';
|
||||
import MostPlayedCard from '../components/MostPlayedCard.jsx';
|
||||
@@ -30,7 +31,7 @@ import TileGrid from '../components/TileGrid.jsx';
|
||||
import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
|
||||
import statGroupTile from '../components/stats/statGroupTile.jsx';
|
||||
import {
|
||||
DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, PLAYING_GAMES, saveTiles, statGroup, TOP_GAMES, UP_NEXT_GAMES,
|
||||
DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, FOLLOWING_SHOWN, normalizeLayout, PLAYING_GAMES, saveTiles, statGroup, TOP_GAMES, UP_NEXT_GAMES,
|
||||
} from '../utils/tiles.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
@@ -134,6 +135,7 @@ const Dashboard = () => {
|
||||
);
|
||||
case 'ACTIVITY': return canSync && activity && <ActivityCard activity={activity} charts={charts} onChart={change}/>;
|
||||
case 'ACHIEVEMENTS': return canSync && achievements && <AchievementsCard summary={achievements}/>;
|
||||
case 'FOLLOWING': return <FollowingCard limit={FOLLOWING_SHOWN[size]}/>;
|
||||
default: return statGroupTile(id, stats.stats, true, charts, change);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -72,6 +72,22 @@ describe('Dashboard', () => {
|
||||
return renderDashboard().container;
|
||||
};
|
||||
|
||||
it('showsTheFollowingTileFromTheSavedLayoutAndHidesItByDefault', async () => {
|
||||
const routes = dashboardRoutes({dashboardTiles: [{id: 'FOLLOWING', size: 'M'}]});
|
||||
fetchRoutes({...routes, 'GET /api/follows': json(200, {followers: [],
|
||||
following: [{id: 1, displayName: 'Anna', lastActivityAt: null}]})});
|
||||
renderDashboard();
|
||||
|
||||
expect(await screen.findByRole('link', {name: 'Anna'})).toBeTruthy();
|
||||
cleanup();
|
||||
|
||||
fetchRoutes(dashboardRoutes());
|
||||
renderDashboard();
|
||||
await screen.findByText('DASHBOARD.CURRENTLY_PLAYING');
|
||||
|
||||
expect(fetch.mock.calls.some(([url]) => String(url).includes('/api/follows'))).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the Steam avatar and the most played game of the last two weeks as banner', async () => {
|
||||
const container = mockDashboard();
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Loader2, UserCheck} from 'lucide-react';
|
||||
|
||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import FollowingList from '../components/people/FollowingList.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import useFollowing from '../hooks/useFollowing.js';
|
||||
|
||||
/**
|
||||
* The people the user follows, newest activity first. The followers stay in the settings.
|
||||
*/
|
||||
export default function FollowingPage() {
|
||||
const {t} = useTranslation();
|
||||
const {following, failed, unfollow} = useFollowing();
|
||||
|
||||
return (
|
||||
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><UserCheck className="size-4 text-primary"/>{t('FOLLOW.TITLE')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{failed && <p className="text-sm text-destructive">{t('FOLLOW.LOAD_ERROR')}</p>}
|
||||
{!failed && !following && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
|
||||
{following && <FollowingList entries={following} onUnfollow={unfollow}/>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
|
||||
import FollowingPage from './FollowingPage.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()}}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const person = (id, displayName, lastActivityAt) => ({id, displayName, lastActivityAt});
|
||||
|
||||
describe('FollowingPage', () => {
|
||||
it('listsThePeopleByLastActivityNewestFirstAndThoseWithoutActivityLast', async () => {
|
||||
fetchRoutes({'GET /api/follows': json(200, {followers: [], following: [
|
||||
person(1, 'Anna', null), person(2, 'Bert', '2026-10-01T10:00:00Z'), person(3, 'Cleo', '2026-10-09T10:00:00Z')]})});
|
||||
renderWithProviders(<FollowingPage/>);
|
||||
|
||||
await screen.findByRole('link', {name: 'Cleo'});
|
||||
|
||||
expect(screen.getAllByRole('link').map((link) => link.textContent)).toEqual(['Cleo', 'Bert', 'Anna']);
|
||||
expect(screen.getByRole('link', {name: 'Cleo'}).getAttribute('href')).toBe('/u/Cleo');
|
||||
expect(screen.getAllByText('FOLLOW.LAST_ACTIVITY')).toHaveLength(2);
|
||||
expect(screen.getByText('FOLLOW.NO_ACTIVITY')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('unfollowsByTheFollowRowAndLoadsTheListAgain', async () => {
|
||||
let list = [person(5, 'Anna', null), person(6, 'Bert', null)];
|
||||
const mock = fetchRoutes({
|
||||
'GET /api/follows': () => json(200, {followers: [], following: list}),
|
||||
'DELETE /api/follows/5': () => {
|
||||
list = [list[1]];
|
||||
return json(204);
|
||||
},
|
||||
});
|
||||
renderWithProviders(<FollowingPage/>);
|
||||
|
||||
fireEvent.click((await screen.findAllByRole('button', {name: 'FOLLOW.UNFOLLOW'}))[0]);
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('link', {name: 'Anna'})).toBeNull());
|
||||
expect(screen.getByRole('link', {name: 'Bert'})).toBeTruthy();
|
||||
expect(mock.mock.calls.filter(([, init]) => init?.method === 'DELETE')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('saysHowToFollowWhenThereIsNobody', async () => {
|
||||
fetchRoutes({'GET /api/follows': json(200, {followers: [], following: []})});
|
||||
renderWithProviders(<FollowingPage/>);
|
||||
|
||||
expect(await screen.findByText('FOLLOW.NO_FOLLOWING')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('showsAnErrorWhenTheListFails', async () => {
|
||||
fetchRoutes({'GET /api/follows': json(500, {})});
|
||||
renderWithProviders(<FollowingPage/>);
|
||||
|
||||
expect(await screen.findByText('FOLLOW.LOAD_ERROR')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import BacklogCard from '../components/BacklogCard.jsx';
|
||||
import CatalogGameCard from '../components/CatalogGameCard.jsx';
|
||||
import Confetti from '../components/Confetti.jsx';
|
||||
import GameAchievements from '../components/GameAchievements.jsx';
|
||||
import GameHeroCard from '../components/GameHeroCard.jsx';
|
||||
@@ -57,6 +58,12 @@ export default function GameDetail() {
|
||||
}, [data, appid, navigate]);
|
||||
|
||||
const manual = !!data && data.source !== 'STEAM';
|
||||
const errorBlock = error && (
|
||||
<div className="py-12 text-center text-destructive space-y-4">
|
||||
<p>{t(error)}</p>
|
||||
<Button asChild variant="outline"><Link to="/library">{t('GAME_DETAIL.BACK_TO_LIBRARY')}</Link></Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
@@ -71,12 +78,9 @@ export default function GameDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !isLoading && (
|
||||
<div className="py-12 text-center text-destructive space-y-4">
|
||||
<p>{t(error)}</p>
|
||||
<Button asChild variant="outline"><Link to="/library">{t('GAME_DETAIL.BACK_TO_LIBRARY')}</Link></Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Not in the library: the catalog view, which falls back to the same message. */}
|
||||
{error && !isLoading && (error === 'GAME_DETAIL.NOT_FOUND'
|
||||
? <CatalogGameCard appid={appid} fallback={errorBlock} onAdded={reload}/> : errorBlock)}
|
||||
|
||||
{!error && data && (
|
||||
<>
|
||||
|
||||
@@ -514,6 +514,64 @@ describe('GameDetail session', () => {
|
||||
expect(screen.queryByText('GAME_DETAIL.ERROR_LOAD')).toBeNull();
|
||||
});
|
||||
|
||||
describe('a game that is not in the library', () => {
|
||||
const preview = {appid: 501, name: 'Hades', imageUrl: null, genres: ['Roguelike'],
|
||||
timeToBeat: {hastilyMinutes: 600, normallyMinutes: 1200, completelyMinutes: null}};
|
||||
const notOwned = (previewAnswer) => fetchRoutes({
|
||||
'GET /api/users/me': json(200, {settings: {}}),
|
||||
'GET /api/library/games/501': json(404, {}),
|
||||
'GET /api/library/games/501/preview': previewAnswer,
|
||||
'GET /api/library/game-search': json(200, {results: []}),
|
||||
});
|
||||
|
||||
it('showsTheCatalogDataAndOffersToAddTheGame', async () => {
|
||||
notOwned(json(200, preview));
|
||||
renderDetail(501);
|
||||
|
||||
expect(await screen.findByText('Hades')).toBeTruthy();
|
||||
expect(screen.getByText('Roguelike')).toBeTruthy();
|
||||
expect(screen.getByText('CATALOG.NOT_IN_LIBRARY')).toBeTruthy();
|
||||
expect(screen.queryByText('GAME_DETAIL.NOT_FOUND')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'CATALOG.ADD'}));
|
||||
|
||||
expect(screen.getByRole('searchbox').value).toBe('Hades');
|
||||
});
|
||||
|
||||
it('showsMyGamePageAfterAddingTheGameUnderTheSameKey', async () => {
|
||||
let owned = false;
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, {settings: {favoritePlatform: 'PS5'}}),
|
||||
'GET /api/library/games/501': () => (owned ? json(200, {...game, appid: 501, name: 'Hades'}) : json(404, {})),
|
||||
'GET /api/library/games/501/preview': json(200, preview),
|
||||
'GET /api/library/game-search': json(200, {available: true, results: [
|
||||
{appid: 501, igdbId: 7, name: 'Hades', year: 2020, imageUrl: null, platforms: ['PS5'], inLibrary: false}]}),
|
||||
'POST /api/library/games': () => {
|
||||
owned = true;
|
||||
return json(201, {appid: 501});
|
||||
},
|
||||
'GET /api/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0, includesYou: true, page: 0, totalPages: 0}),
|
||||
// The points check gives up on a failed answer; the sonner mock of this file has no plain toast().
|
||||
'GET /api/rewards/games/501': json(500, {}),
|
||||
});
|
||||
renderDetail(501);
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'CATALOG.ADD'}));
|
||||
fireEvent.click(await screen.findByRole('button', {name: /Hades/}));
|
||||
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.ADD'}));
|
||||
|
||||
await waitFor(() => expect(fetch.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(true));
|
||||
await waitFor(() => expect(screen.queryByText('CATALOG.NOT_IN_LIBRARY')).toBeNull());
|
||||
});
|
||||
|
||||
it('showsTheNotFoundMessageWhenTheCatalogKnowsNoSuchGame', async () => {
|
||||
notOwned(json(404, {}));
|
||||
renderDetail(501);
|
||||
|
||||
expect(await screen.findByText('GAME_DETAIL.NOT_FOUND')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', {name: 'CATALOG.ADD'})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('replacesTheUrlOfAMergedKeyWithTheKeptGame', async () => {
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, {settings: {}}),
|
||||
|
||||
+41
-19
@@ -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>
|
||||
|
||||
@@ -68,6 +68,60 @@ describe('InboxPage', () => {
|
||||
expect(putCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
describe('links', () => {
|
||||
it('linksTheNameToTheProfileOnlyWhileTheProfileIsPublic', async () => {
|
||||
fetchRoutes({'GET /api/inbox': json(200, {results: [
|
||||
note(1, {newGames: 1, profileName: 'Subject', displayName: 'Subject'}),
|
||||
note(2, {newGames: 2, displayName: 'Gone', profileName: null})], unread: 0})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
|
||||
expect((await screen.findByRole('link', {name: 'Subject'})).getAttribute('href')).toBe('/u/Subject');
|
||||
expect(screen.queryByRole('link', {name: 'Gone'})).toBeNull();
|
||||
expect(screen.getByText('INBOX.DIGEST')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keepsTheNameLinkOutsideTheButtonThatOpensTheDetails', async () => {
|
||||
fetchRoutes({'GET /api/inbox': json(200, {results: [
|
||||
note(1, {newGames: 1, details: true, profileName: 'Subject', displayName: 'Subject'})], unread: 0})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
|
||||
const link = await screen.findByRole('link', {name: 'Subject'});
|
||||
|
||||
expect(link.closest('button')).toBeNull();
|
||||
expect(screen.getByRole('button', {name: 'INBOX.DIGEST_PARTS'})).toBeTruthy();
|
||||
});
|
||||
|
||||
const open = async (rows, extra) => {
|
||||
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: true, ...extra})], unread: 0}),
|
||||
'GET /api/inbox/1/details': json(200, {results: rows})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
fireEvent.click(await screen.findByRole('button'));
|
||||
};
|
||||
const game = (extra) => ({kind: 'NEW_GAME', gameName: 'Alpha', itemName: null, fromStatus: null, toStatus: null, ...extra});
|
||||
|
||||
it('linksTheGameToMyGamePageAndToTheProfileViewOfThatGame', async () => {
|
||||
await open([game({appid: 501})], {profileName: 'Subject'});
|
||||
|
||||
expect((await screen.findByRole('link', {name: 'Alpha'})).getAttribute('href')).toBe('/library/501');
|
||||
expect(screen.getByRole('link', {name: 'INBOX.ON_PROFILE'}).getAttribute('href')).toBe('/u/Subject?game=501');
|
||||
});
|
||||
|
||||
it('givesNoProfileLinkWithoutAPublicProfile', async () => {
|
||||
await open([game({appid: 501})], {profileName: null});
|
||||
|
||||
expect(await screen.findByRole('link', {name: 'Alpha'})).toBeTruthy();
|
||||
expect(screen.queryByRole('link', {name: 'INBOX.ON_PROFILE'})).toBeNull();
|
||||
});
|
||||
|
||||
it('leavesARowFromBeforeTheAppidAsPlainText', async () => {
|
||||
await open([game({})], {profileName: 'Subject'});
|
||||
|
||||
expect(await screen.findByText('Alpha')).toBeTruthy();
|
||||
expect(screen.queryByRole('link', {name: 'Alpha'})).toBeNull();
|
||||
expect(screen.queryByRole('link', {name: 'INBOX.ON_PROFILE'})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('details', () => {
|
||||
const row = (kind, gameName, extra) => ({kind, gameName, itemName: null, fromStatus: null, toStatus: null, ...extra});
|
||||
const detailCalls = () => fetch.mock.calls.filter(([url]) => String(url).includes('/details'));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Link, useParams} from 'react-router-dom';
|
||||
import {Link, useParams, useSearchParams} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {CircleCheck, Clock, Gamepad2, LayoutGrid, Library, Percent, Star, Trophy, UserRoundX} from 'lucide-react';
|
||||
|
||||
@@ -12,6 +12,7 @@ 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 ProfileGameCard from '../components/ProfileGameCard.jsx';
|
||||
import {formatDay} from '../utils/format.js';
|
||||
import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, equippedSkin, lookClass} from '../utils/rewards.js';
|
||||
import PointsBadge from '../components/PointsBadge.jsx';
|
||||
@@ -41,6 +42,9 @@ const FINISHED_GAMES = {S: 6, M: 12};
|
||||
export default function PublicProfilePage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const {name} = useParams();
|
||||
const [params] = useSearchParams();
|
||||
// Only a game key goes into the request address.
|
||||
const game = /^-?\d+$/.test(params.get('game') ?? '') ? params.get('game') : null;
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [library, setLibrary] = useState(null);
|
||||
const [equipped, setEquipped] = useState({});
|
||||
@@ -190,6 +194,7 @@ export default function PublicProfilePage() {
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
{game && <ProfileGameCard ticket={profile.ticket} appid={game}/>}
|
||||
<StatDrillProvider source="profile" ticket={profile.ticket} linked={owner}>
|
||||
<TileGrid tiles={PROFILE_TILES} titles={PROFILE_TITLES} layout={layout} renderTile={renderTile}
|
||||
editing={owner && editing} defaultLayout={DEFAULT_PROFILE} onSave={saveLayout}
|
||||
|
||||
@@ -103,6 +103,39 @@ describe('PublicProfilePage', () => {
|
||||
expect(screen.getByText('Lobo').closest('[data-accent]').dataset.accent).toBe('red');
|
||||
});
|
||||
|
||||
describe('a game of the profile', () => {
|
||||
const gameAnswer = {appid: 501, name: 'Hades', imageUrl: null, status: 'PLAYING', playtimeMinutes: 90,
|
||||
achievementsUnlocked: 3, achievementsTotal: 10, completionPercent: 30};
|
||||
const gameCalls = () => fetch.mock.calls.filter(([url]) => String(url).includes('/library/games/'));
|
||||
|
||||
it('showsStatusPlaytimeAndAchievementsAboveTheTilesWithTheTicket', async () => {
|
||||
mockBackends({extra: {'GET /api/public/profiles/library/games/501': json(200, gameAnswer)}});
|
||||
renderPage('Lobo?game=501');
|
||||
|
||||
expect(await screen.findByText('Hades')).toBeTruthy();
|
||||
const card = within(screen.getByText('Hades').closest('[data-slot="card"]'));
|
||||
expect(card.getByText('PROFILE.GAME_ACHIEVEMENTS')).toBeTruthy();
|
||||
expect(card.getByText('BACKLOG.STATUS.PLAYING')).toBeTruthy();
|
||||
expect(gameCalls()[0][1].headers['X-Profile-Ticket']).toBe('the-ticket');
|
||||
});
|
||||
|
||||
it('saysSoWhenTheProfileDoesNotShowTheGame', async () => {
|
||||
mockBackends({extra: {'GET /api/public/profiles/library/games/501': json(404, {})}});
|
||||
renderPage('Lobo?game=501');
|
||||
|
||||
expect(await screen.findByText('PROFILE.GAME_NOT_SHOWN')).toBeTruthy();
|
||||
expect(screen.getByText('Portal 2')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('asksForNoGameWithoutTheParamOrWithAnythingButAKey', async () => {
|
||||
mockBackends();
|
||||
renderPage('Lobo?game=../x');
|
||||
|
||||
expect(await screen.findByText('Portal 2')).toBeTruthy();
|
||||
expect(gameCalls()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('asks Auth for the ticket, then Core for the library with it', async () => {
|
||||
mockBackends({name: 'Lo bo'});
|
||||
renderPage('Lo bo');
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import {request} from './api.js';
|
||||
|
||||
/**
|
||||
* GET /api/library/games/{appid}/preview: `{appid, name, imageUrl, genres, timeToBeat}`, the catalog data of a game
|
||||
* the user may not own. 404 `GAME_NOT_FOUND` for an unknown key.
|
||||
*/
|
||||
export const fetchPreview = (appid) => request(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/preview`);
|
||||
@@ -3,8 +3,9 @@ import {request} from './api.js';
|
||||
const FOLLOWS = () => `${import.meta.env.VITE_CORE_API_URL}/api/follows`;
|
||||
|
||||
/**
|
||||
* GET /api/follows: `{following: [{id, displayName}], followers: [{id, displayName}]}`. `id` is the follow row, not a
|
||||
* user id. A follower with a private profile has `displayName: null`.
|
||||
* GET /api/follows: `{following: [{id, displayName, lastActivityAt}], followers: [{id, displayName, lastActivityAt}]}`.
|
||||
* `id` is the follow row, not a user id. A follower with a private profile has `displayName: null`. `lastActivityAt`
|
||||
* is the time of the newest feed message about that person; null for followers and without a message.
|
||||
*/
|
||||
export const fetchFollows = () => request(FOLLOWS());
|
||||
|
||||
|
||||
+8
-5
@@ -3,15 +3,17 @@ import {request} from './api.js';
|
||||
const INBOX = () => `${import.meta.env.VITE_CORE_API_URL}/api/inbox`;
|
||||
|
||||
/**
|
||||
* GET /api/inbox: `{results: [{id, kind, displayName, newGames, achievements, statusChanges, wishlistAdds, createdAt,
|
||||
* GET /api/inbox: `{results: [{id, kind, displayName, profileName, newGames, achievements, statusChanges, wishlistAdds, createdAt,
|
||||
* read, details}], unread}`, the newest 50 notes. `kind` is `DIGEST`, `VISIBILITY`, `PSN_EXPIRING` or `PSN_EXPIRED`
|
||||
* (the PSN kinds have no `displayName`); `unread` counts all unread notes. `details` is true when the note can be
|
||||
* opened (`fetchInboxDetails`): it has detail rows and the followed user is still followed and public.
|
||||
* `profileName` is the followed user's current name while the profile is public, else null: only then the note links
|
||||
* to the profile.
|
||||
*/
|
||||
export const fetchInbox = () => request(INBOX());
|
||||
|
||||
/**
|
||||
* GET /api/inbox/{id}/details: `{results: [{kind, gameName, itemName, fromStatus, toStatus}]}`; 404 for a note that is
|
||||
* GET /api/inbox/{id}/details: `{results: [{appid, kind, gameName, itemName, fromStatus, toStatus}]}`; 404 for a note that is
|
||||
* not the caller's.
|
||||
*/
|
||||
export const fetchInboxDetails = (id) => request(`${INBOX()}/${id}/details`);
|
||||
@@ -32,14 +34,15 @@ export const digestParts = (note) =>
|
||||
const DETAIL_KINDS = ['NEW_GAME', 'ACHIEVEMENT', 'STATUS', 'WISHLIST'];
|
||||
|
||||
/**
|
||||
* Groups details for the page: `[{kind, games: [{gameName, items: [detail]}]}]` in the fixed kind order, only kinds
|
||||
* with rows. Games keep the order of their first row.
|
||||
* Groups details for the page: `[{kind, games: [{gameName, appid, items: [detail]}]}]` in the fixed kind order, only
|
||||
* kinds with rows. Games keep the order of their first row; `appid` is the one of that row (absent in a note from
|
||||
* before the field).
|
||||
*/
|
||||
export const groupDetails = (details) =>
|
||||
DETAIL_KINDS.map((kind) => {
|
||||
const games = new Map();
|
||||
details.filter((detail) => detail.kind === kind).forEach((detail) => {
|
||||
if (!games.has(detail.gameName)) games.set(detail.gameName, {gameName: detail.gameName, items: []});
|
||||
if (!games.has(detail.gameName)) games.set(detail.gameName, {gameName: detail.gameName, appid: detail.appid, items: []});
|
||||
games.get(detail.gameName).items.push(detail);
|
||||
});
|
||||
return {kind, games: [...games.values()]};
|
||||
|
||||
@@ -28,6 +28,12 @@ describe('groupDetails', () => {
|
||||
expect(groups[1].games[0].items.map((item) => item.itemName)).toEqual(['one', 'two']);
|
||||
});
|
||||
|
||||
it('keepsTheAppidOfTheFirstRowOfAGame', () => {
|
||||
const groups = groupDetails([row('NEW_GAME', 'A', {appid: 501}), row('NEW_GAME', 'B')]);
|
||||
|
||||
expect(groups[0].games.map((game) => game.appid)).toEqual([501, undefined]);
|
||||
});
|
||||
|
||||
it('isEmptyForNoDetails', () => {
|
||||
expect(groupDetails([])).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -9,3 +9,22 @@ export const fetchUsers = ({key, page = 0}) =>
|
||||
request(`${import.meta.env.VITE_API_URL}/api/stats/users?${query([['key', key], ['page', page]])}`);
|
||||
|
||||
export const profileHref = (displayName) => `/u/${encodeURIComponent(displayName)}`;
|
||||
|
||||
/** The profile of a user, showing one game of theirs above the tiles. */
|
||||
export const profileGameHref = (displayName, appid) => `${profileHref(displayName)}?game=${appid}`;
|
||||
|
||||
/**
|
||||
* One game of a public profile, as far as the profile shows it: `{appid, name, imageUrl, status, playtimeMinutes,
|
||||
* achievementsUnlocked, achievementsTotal, completionPercent}`, a hidden part is null. No login: the profile ticket
|
||||
* decides. 404 when the user has no such game or hides it; status 0 when the request fails.
|
||||
*/
|
||||
export const fetchProfileGame = async (ticket, appid) => {
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/public/profiles/library/games/${appid}`,
|
||||
{headers: {'X-Profile-Ticket': ticket}});
|
||||
return {ok: response.ok, status: response.status, data: response.ok ? await response.json() : null};
|
||||
} catch (err) {
|
||||
console.error('Profile game load error:', err);
|
||||
return {ok: false, status: 0, data: null};
|
||||
}
|
||||
};
|
||||
@@ -27,6 +27,7 @@ export const DASHBOARD_TILES = {
|
||||
PILE_OF_SHAME: {M: 6, L: 12},
|
||||
ACTIVITY: {S: 6, M: 12},
|
||||
ACHIEVEMENTS: {S: 6, M: 12},
|
||||
FOLLOWING: {S: 3, M: 6, L: 12},
|
||||
STATS_OVERVIEW: {M: 12},
|
||||
...STAT_TILES,
|
||||
};
|
||||
@@ -46,6 +47,7 @@ export const DASHBOARD_TITLES = {
|
||||
GAME_COUNT: 'DASHBOARD.STEAM_GAMES', MOST_PLAYED: 'DASHBOARD.MOST_PLAYED', PLAYING: 'DASHBOARD.CURRENTLY_PLAYING',
|
||||
UP_NEXT: 'DASHBOARD.UP_NEXT',
|
||||
PILE_OF_SHAME: 'DASHBOARD.PILE_OF_SHAME', ACTIVITY: 'DASHBOARD.ACTIVITY', ACHIEVEMENTS: 'DASHBOARD.ACHIEVEMENTS.TITLE',
|
||||
FOLLOWING: 'FOLLOW.TITLE',
|
||||
STATS_OVERVIEW: 'STATS.OVERVIEW.TITLE', ...STAT_TITLES,
|
||||
};
|
||||
|
||||
@@ -64,6 +66,7 @@ export const SIZES = ['S', 'M', 'L'];
|
||||
export const TOP_GAMES = {S: 3, M: 5, L: 10};
|
||||
export const PLAYING_GAMES = {S: 2, M: 4, L: 8};
|
||||
export const UP_NEXT_GAMES = {S: 3, M: 5, L: 5};
|
||||
export const FOLLOWING_SHOWN = {S: 3, M: 5, L: 10};
|
||||
|
||||
// Written out, so Tailwind finds every class.
|
||||
const SPANS = {
|
||||
|
||||
@@ -14,6 +14,12 @@ describe('normalizeLayout', () => {
|
||||
], DASHBOARD_TILES, DEFAULT_DASHBOARD)).toEqual([{id: 'STATS_TREND', size: 'L'}, {id: 'PILE_OF_SHAME', size: 'M'}]);
|
||||
});
|
||||
|
||||
it('keepsTheFollowingTileButDoesNotShowItByDefault', () => {
|
||||
expect(normalizeLayout([{id: 'FOLLOWING', size: 'L'}], DASHBOARD_TILES, DEFAULT_DASHBOARD))
|
||||
.toEqual([{id: 'FOLLOWING', size: 'L'}]);
|
||||
expect(DEFAULT_DASHBOARD.map((tile) => tile.id)).not.toContain('FOLLOWING');
|
||||
});
|
||||
|
||||
it('keeps an empty list: every tile hidden', () => {
|
||||
expect(normalizeLayout([], DASHBOARD_TILES, DEFAULT_DASHBOARD)).toEqual([]);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user