Public Access
feat(library): add ordered backlog page and up-next dashboard tile
This commit is contained in:
1 parent
c158909083
commit
b213b067dc
21 files changed
+677
-24
No files matched your search
@@ -28,9 +28,13 @@ 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`): three backlog picks for a time budget (up to 5 / 15 / 40 h or any) and a mood
|
- **What to play next** (`/play-next`): four backlog picks for a time budget (up to 5 / 15 / 40 h or any) and a mood
|
||||||
(story, co-op, chill, action, scary from IGDB; speedrun from speedrun.com). Each pick says why: like a game you
|
(story, co-op, chill, action, scary from IGDB; speedrun from speedrun.com). Each pick says why: like a game you
|
||||||
played a lot, waiting longest, or random. Reroll without repeats, and "Start playing" in one click.
|
played a lot, waiting longest, random, or next in your own backlog order. Reroll without repeats, and "Start playing" in one click.
|
||||||
|
- **Backlog order** (`/backlog`, third library tab): all backlog games in your own order. Move a game by drag (pointer
|
||||||
|
events, no library, so it works with touch), by the up and down buttons, or "move to top / bottom" in the menu. Each
|
||||||
|
move saves the whole list. The "Up next" dashboard tile shows the first games; the picker has a "next in your
|
||||||
|
backlog" reason.
|
||||||
- **Your stats** (`/stats`): totals and statuses; the pile in hours, in weeks at your pace of the last 90 days and in
|
- **Your stats** (`/stats`): totals and statuses; the pile in hours, in weeks at your pace of the last 90 days and in
|
||||||
years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods;
|
years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods;
|
||||||
trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform.
|
trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform.
|
||||||
@@ -195,6 +199,7 @@ flowchart LR
|
|||||||
Auth -->|"check_authentication"| SteamLogin
|
Auth -->|"check_authentication"| SteamLogin
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- `useDragSort` (`src/hooks/useDragSort.js`) sorts a list with pointer events. Only the drag handle has `touch-action: none`, so the page still scrolls from a row.
|
||||||
- nginx serves the built app. It has no backend of its own: the browser calls Auth and Core directly
|
- nginx serves the built app. It has no backend of its own: the browser calls Auth and Core directly
|
||||||
(`VITE_API_URL`, `VITE_CORE_API_URL`). The Content Security Policy allows only these two hosts and the image hosts.
|
(`VITE_API_URL`, `VITE_CORE_API_URL`). The Content Security Policy allows only these two hosts and the image hosts.
|
||||||
- Auth and Core each have their own database. Core verifies Auth's tokens with Auth's public keys (JWKS).
|
- Auth and Core each have their own database. Core verifies Auth's tokens with Auth's public keys (JWKS).
|
||||||
|
|||||||
+18
-2
@@ -202,6 +202,8 @@
|
|||||||
"WELCOME_BACK": "Willkommen zurück",
|
"WELCOME_BACK": "Willkommen zurück",
|
||||||
"CURRENTLY_PLAYING": "Spiele ich gerade",
|
"CURRENTLY_PLAYING": "Spiele ich gerade",
|
||||||
"NOTHING_PLAYING": "Gerade nichts. Setze ein Spiel auf „Spiele ich“.",
|
"NOTHING_PLAYING": "Gerade nichts. Setze ein Spiel auf „Spiele ich“.",
|
||||||
|
"UP_NEXT": "Als Nächstes",
|
||||||
|
"UP_NEXT_EMPTY": "Dein Backlog ist leer. Schön!",
|
||||||
"PILE_OF_SHAME": "Pile of Shame",
|
"PILE_OF_SHAME": "Pile of Shame",
|
||||||
"PILE_SHARE": "{{percent}} % deiner Bibliothek",
|
"PILE_SHARE": "{{percent}} % deiner Bibliothek",
|
||||||
"NEVER_PLAYED_one": "{{count}} Spiel nie gestartet",
|
"NEVER_PLAYED_one": "{{count}} Spiel nie gestartet",
|
||||||
@@ -386,6 +388,19 @@
|
|||||||
"LOW": "Niedrig"
|
"LOW": "Niedrig"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"BACKLOG_ORDER": {
|
||||||
|
"TITLE": "Backlog",
|
||||||
|
"SUBTITLE": "Dein Backlog in deiner eigenen Reihenfolge. Ziehe ein Spiel oder nutze die Pfeile und das Menü. Die ersten Spiele erscheinen im Spielvorschlag und auf dem Dashboard.",
|
||||||
|
"EMPTY": "Dein Backlog ist leer. Schön!",
|
||||||
|
"LOAD_FAILED": "Der Backlog konnte nicht geladen werden.",
|
||||||
|
"SAVE_FAILED": "Die Reihenfolge konnte nicht gespeichert werden. Die Liste wird neu geladen.",
|
||||||
|
"MOVE_UP": "Nach oben",
|
||||||
|
"MOVE_DOWN": "Nach unten",
|
||||||
|
"MOVE_TOP": "Ganz nach oben",
|
||||||
|
"MOVE_BOTTOM": "Ganz nach unten",
|
||||||
|
"MOVE_DRAG": "Zum Verschieben ziehen",
|
||||||
|
"MORE": "Mehr"
|
||||||
|
},
|
||||||
"WISHLIST": {
|
"WISHLIST": {
|
||||||
"TITLE": "Wunschliste",
|
"TITLE": "Wunschliste",
|
||||||
"ONLY_ON_SALE": "Nur im Angebot",
|
"ONLY_ON_SALE": "Nur im Angebot",
|
||||||
@@ -411,11 +426,12 @@
|
|||||||
},
|
},
|
||||||
"PLAY_NEXT": {
|
"PLAY_NEXT": {
|
||||||
"TITLE": "Was spiele ich als Nächstes?",
|
"TITLE": "Was spiele ich als Nächstes?",
|
||||||
"SUBTITLE": "Drei Spiele aus deinem Backlog: eins wie ein Spiel, das du viel gespielt hast, das am längsten wartende und eins zufällig. Wähle eine Länge und eine Stimmung.",
|
"SUBTITLE": "Vier Spiele aus deinem Backlog: eins wie ein Spiel, das du viel gespielt hast, das am längsten wartende, eins zufällig und eins von ganz oben in deiner Reihenfolge. Wähle eine Länge und eine Stimmung.",
|
||||||
"REASON": {
|
"REASON": {
|
||||||
"SIMILAR": "Weil du {{name}} gespielt hast ({{playtime}})",
|
"SIMILAR": "Weil du {{name}} gespielt hast ({{playtime}})",
|
||||||
"LONGEST": "Auf deiner Liste seit {{date}}",
|
"LONGEST": "Auf deiner Liste seit {{date}}",
|
||||||
"RANDOM": "Zufallstreffer"
|
"RANDOM": "Zufallstreffer",
|
||||||
|
"NEXT_UP": "Als Nächstes in deinem Backlog"
|
||||||
},
|
},
|
||||||
"BUDGET_LABEL": "Spieldauer",
|
"BUDGET_LABEL": "Spieldauer",
|
||||||
"BUDGET": {
|
"BUDGET": {
|
||||||
|
|||||||
+18
-2
@@ -202,6 +202,8 @@
|
|||||||
"WELCOME_BACK": "Welcome back",
|
"WELCOME_BACK": "Welcome back",
|
||||||
"CURRENTLY_PLAYING": "Currently playing",
|
"CURRENTLY_PLAYING": "Currently playing",
|
||||||
"NOTHING_PLAYING": "Nothing right now. Set a game to \"Playing\".",
|
"NOTHING_PLAYING": "Nothing right now. Set a game to \"Playing\".",
|
||||||
|
"UP_NEXT": "Up next",
|
||||||
|
"UP_NEXT_EMPTY": "Your backlog is empty. Nice!",
|
||||||
"PILE_OF_SHAME": "Pile of Shame",
|
"PILE_OF_SHAME": "Pile of Shame",
|
||||||
"PILE_SHARE": "{{percent}}% of your library",
|
"PILE_SHARE": "{{percent}}% of your library",
|
||||||
"NEVER_PLAYED_one": "{{count}} game never started",
|
"NEVER_PLAYED_one": "{{count}} game never started",
|
||||||
@@ -386,6 +388,19 @@
|
|||||||
"LOW": "Low"
|
"LOW": "Low"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"BACKLOG_ORDER": {
|
||||||
|
"TITLE": "Backlog",
|
||||||
|
"SUBTITLE": "Your backlog in your own order. Drag a game, or use the arrows and the menu. The first games come up in the picker and on the dashboard.",
|
||||||
|
"EMPTY": "Your backlog is empty. Nice!",
|
||||||
|
"LOAD_FAILED": "Could not load the backlog.",
|
||||||
|
"SAVE_FAILED": "Could not save the order. The list is loaded again.",
|
||||||
|
"MOVE_UP": "Move up",
|
||||||
|
"MOVE_DOWN": "Move down",
|
||||||
|
"MOVE_TOP": "Move to top",
|
||||||
|
"MOVE_BOTTOM": "Move to bottom",
|
||||||
|
"MOVE_DRAG": "Drag to move",
|
||||||
|
"MORE": "More"
|
||||||
|
},
|
||||||
"WISHLIST": {
|
"WISHLIST": {
|
||||||
"TITLE": "Wishlist",
|
"TITLE": "Wishlist",
|
||||||
"ONLY_ON_SALE": "On sale only",
|
"ONLY_ON_SALE": "On sale only",
|
||||||
@@ -411,11 +426,12 @@
|
|||||||
},
|
},
|
||||||
"PLAY_NEXT": {
|
"PLAY_NEXT": {
|
||||||
"TITLE": "What to play next",
|
"TITLE": "What to play next",
|
||||||
"SUBTITLE": "Three games from your backlog: one like a game you played a lot, the one waiting longest, and one at random. Pick a length and a mood.",
|
"SUBTITLE": "Four games from your backlog: one like a game you played a lot, the one waiting longest, one at random, and one from the top of your own order. Pick a length and a mood.",
|
||||||
"REASON": {
|
"REASON": {
|
||||||
"SIMILAR": "Because you played {{name}} ({{playtime}})",
|
"SIMILAR": "Because you played {{name}} ({{playtime}})",
|
||||||
"LONGEST": "On your list since {{date}}",
|
"LONGEST": "On your list since {{date}}",
|
||||||
"RANDOM": "Random pick"
|
"RANDOM": "Random pick",
|
||||||
|
"NEXT_UP": "Next in your backlog"
|
||||||
},
|
},
|
||||||
"BUDGET_LABEL": "Time to beat",
|
"BUDGET_LABEL": "Time to beat",
|
||||||
"BUDGET": {
|
"BUDGET": {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import LinkQueuePage from './pages/LinkQueuePage.jsx';
|
|||||||
import GameLibrary from './pages/GameLibrary.jsx';
|
import GameLibrary from './pages/GameLibrary.jsx';
|
||||||
import GameDetail from './pages/GameDetail.jsx';
|
import GameDetail from './pages/GameDetail.jsx';
|
||||||
import WishlistPage from './pages/WishlistPage.jsx';
|
import WishlistPage from './pages/WishlistPage.jsx';
|
||||||
|
import BacklogPage from './pages/BacklogPage.jsx';
|
||||||
import PlayNextPage from './pages/PlayNextPage.jsx';
|
import PlayNextPage from './pages/PlayNextPage.jsx';
|
||||||
import StatsPage from './pages/StatsPage.jsx';
|
import StatsPage from './pages/StatsPage.jsx';
|
||||||
import ImportPage from './pages/ImportPage.jsx';
|
import ImportPage from './pages/ImportPage.jsx';
|
||||||
@@ -83,6 +84,7 @@ function App() {
|
|||||||
<Route path="/library" element={<GameLibrary/>}/>
|
<Route path="/library" element={<GameLibrary/>}/>
|
||||||
<Route path="/library/:appid" element={<GameDetail/>}/>
|
<Route path="/library/:appid" element={<GameDetail/>}/>
|
||||||
<Route path="/wishlist" element={<WishlistPage/>}/>
|
<Route path="/wishlist" element={<WishlistPage/>}/>
|
||||||
|
<Route path="/backlog" element={<BacklogPage/>}/>
|
||||||
<Route path="/play-next" element={<PlayNextPage/>}/>
|
<Route path="/play-next" element={<PlayNextPage/>}/>
|
||||||
<Route path="/stats" element={<StatsPage/>}/>
|
<Route path="/stats" element={<StatsPage/>}/>
|
||||||
<Route path="/import" element={<ImportPage/>}/>
|
<Route path="/import" element={<ImportPage/>}/>
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ import {expireSession, markSession} from '../utils/auth.js';
|
|||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
|
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
|
||||||
// The wishlist is a tab of the library.
|
// The wishlist and the backlog order are tabs of the library.
|
||||||
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist']},
|
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist', '/backlog']},
|
||||||
{to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices},
|
{to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices},
|
||||||
{to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn},
|
{to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import {NavLink} from 'react-router-dom';
|
import {NavLink} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {Gift, Library} from 'lucide-react';
|
import {Gift, Library, ListOrdered} from 'lucide-react';
|
||||||
|
|
||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{to: '/library', labelKey: 'LIBRARY.TITLE', icon: Library},
|
{to: '/library', labelKey: 'LIBRARY.TITLE', icon: Library},
|
||||||
|
{to: '/backlog', labelKey: 'BACKLOG_ORDER.TITLE', icon: ListOrdered},
|
||||||
{to: '/wishlist', labelKey: 'WISHLIST.TITLE', icon: Gift},
|
{to: '/wishlist', labelKey: 'WISHLIST.TITLE', icon: Gift},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -18,13 +19,19 @@ export default function LibraryTabs() {
|
|||||||
key={to}
|
key={to}
|
||||||
to={to}
|
to={to}
|
||||||
end
|
end
|
||||||
|
title={t(labelKey)}
|
||||||
className={({isActive}) => cn(
|
className={({isActive}) => cn(
|
||||||
'flex min-w-0 items-center gap-1.5 rounded-md px-3 py-1.5 font-medium transition-colors',
|
'flex min-w-0 items-center gap-1.5 rounded-md px-3 py-1.5 font-medium transition-colors',
|
||||||
isActive ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
isActive ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="size-4 shrink-0"/>
|
{({isActive}) => (
|
||||||
<span className="truncate">{t(labelKey)}</span>
|
<>
|
||||||
|
<Icon className="size-4 shrink-0"/>
|
||||||
|
{/* Below sm only the active tab shows its label; the others keep it as their name. */}
|
||||||
|
<span className={cn('truncate', !isActive && 'sr-only sm:not-sr-only')}>{t(labelKey)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import {Link} from 'react-router-dom';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {ArrowRight, ListOrdered} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card} from '@/components/ui/card';
|
||||||
|
import GameCover from './GameCover.jsx';
|
||||||
|
import FitText from './FitText.jsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first games of the user's own backlog order.
|
||||||
|
*/
|
||||||
|
export default function UpNextCard({games}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
return (
|
||||||
|
<Card className="gap-0 py-0">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-x-3 px-5 py-4 border-b">
|
||||||
|
<h2 className="flex items-center gap-2 font-semibold">
|
||||||
|
<ListOrdered className="size-4 text-primary"/>
|
||||||
|
{t('DASHBOARD.UP_NEXT')}
|
||||||
|
</h2>
|
||||||
|
<Button asChild variant="ghost" size="sm">
|
||||||
|
<Link to="/backlog">{t('DASHBOARD.SHOW_ALL')}<ArrowRight/></Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{games.length === 0 ? (
|
||||||
|
<p className="px-5 py-10 text-center text-sm text-muted-foreground">{t('DASHBOARD.UP_NEXT_EMPTY')}</p>
|
||||||
|
) : (
|
||||||
|
<ol className="divide-y">
|
||||||
|
{games.map((g, index) => (
|
||||||
|
<li key={g.appid}>
|
||||||
|
<Link to={`/library/${g.appid}`} className="group flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-muted/50">
|
||||||
|
<span className="w-4 shrink-0 text-right text-sm tabular-nums text-muted-foreground">{index + 1}</span>
|
||||||
|
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 shrink-0"/>
|
||||||
|
<div className="@container min-w-0 flex-1 text-sm font-medium group-hover:text-primary transition-colors">
|
||||||
|
<FitText text={g.name ?? `App ${g.appid}`}/>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, render, screen} from '@testing-library/react';
|
||||||
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
|
import UpNextCard from './UpNextCard.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
const renderCard = (games) => render(<MemoryRouter><UpNextCard games={games}/></MemoryRouter>);
|
||||||
|
|
||||||
|
describe('UpNextCard', () => {
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it('shows the games with their rank and links to the backlog page', () => {
|
||||||
|
renderCard([{appid: 7, name: 'Hades'}, {appid: 9, name: 'Celeste'}]);
|
||||||
|
|
||||||
|
const rows = screen.getAllByRole('listitem');
|
||||||
|
expect(rows.map((row) => row.textContent)).toEqual(['1Hades', '2Celeste']);
|
||||||
|
expect(screen.getByRole('link', {name: /DASHBOARD.SHOW_ALL/}).getAttribute('href')).toBe('/backlog');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so when the backlog is empty', () => {
|
||||||
|
renderCard([]);
|
||||||
|
|
||||||
|
expect(screen.getByText('DASHBOARD.UP_NEXT_EMPTY')).toBeTruthy();
|
||||||
|
expect(screen.queryAllByRole('listitem')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import {useCallback, useEffect, useState} from 'react';
|
import {useCallback, useEffect, useState} from 'react';
|
||||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||||
import {request} from '../utils/api.js';
|
import {request} from '../utils/api.js';
|
||||||
import {PLAYING_GAMES, TOP_GAMES} from '../utils/tiles.js';
|
import {PLAYING_GAMES, TOP_GAMES, UP_NEXT_GAMES} from '../utils/tiles.js';
|
||||||
|
|
||||||
// The games endpoint accepts only the page sizes 20, 40 and 80.
|
// The games endpoint accepts only the page sizes 20, 40 and 80.
|
||||||
const TOP_GAMES_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc';
|
const TOP_GAMES_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc';
|
||||||
|
const UP_NEXT_URL = `/api/library/backlog?limit=${UP_NEXT_GAMES.L}&released=true`;
|
||||||
const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
|
const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
|
||||||
|
|
||||||
// A card whose request fails or has no JSON body stays empty.
|
// A card whose request fails or has no JSON body stays empty.
|
||||||
@@ -18,6 +19,7 @@ export default function useDashboardData() {
|
|||||||
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false, syncStarted: false});
|
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false, syncStarted: false});
|
||||||
const [topGames, setTopGames] = useState([]);
|
const [topGames, setTopGames] = useState([]);
|
||||||
const [playing, setPlaying] = useState([]);
|
const [playing, setPlaying] = useState([]);
|
||||||
|
const [upNext, setUpNext] = useState([]);
|
||||||
const [summary, setSummary] = useState(null);
|
const [summary, setSummary] = useState(null);
|
||||||
const [activity, setActivity] = useState(null);
|
const [activity, setActivity] = useState(null);
|
||||||
const [achievements, setAchievements] = useState(null);
|
const [achievements, setAchievements] = useState(null);
|
||||||
@@ -28,6 +30,7 @@ export default function useDashboardData() {
|
|||||||
const fetchSteamCount = useCallback(() => loadInto('/api/library/games-count', setSteamData), []);
|
const fetchSteamCount = useCallback(() => loadInto('/api/library/games-count', setSteamData), []);
|
||||||
const fetchTopGames = useCallback(() => loadInto(TOP_GAMES_URL, setTopGames, (page) => (page.content ?? []).slice(0, TOP_GAMES.L)), []);
|
const fetchTopGames = useCallback(() => loadInto(TOP_GAMES_URL, setTopGames, (page) => (page.content ?? []).slice(0, TOP_GAMES.L)), []);
|
||||||
const fetchPlaying = useCallback(() => loadInto(PLAYING_URL, setPlaying, (page) => (page.content ?? []).slice(0, PLAYING_GAMES.L)), []);
|
const fetchPlaying = useCallback(() => loadInto(PLAYING_URL, setPlaying, (page) => (page.content ?? []).slice(0, PLAYING_GAMES.L)), []);
|
||||||
|
const fetchUpNext = useCallback(() => loadInto(UP_NEXT_URL, setUpNext, (data) => data.games ?? []), []);
|
||||||
const fetchActivity = useCallback(() => loadInto('/api/library/activity', setActivity), []);
|
const fetchActivity = useCallback(() => loadInto('/api/library/activity', setActivity), []);
|
||||||
const fetchAchievements = useCallback(() => loadInto('/api/library/achievements/summary', setAchievements), []);
|
const fetchAchievements = useCallback(() => loadInto('/api/library/achievements/summary', setAchievements), []);
|
||||||
|
|
||||||
@@ -47,6 +50,7 @@ export default function useDashboardData() {
|
|||||||
fetchSteamCount();
|
fetchSteamCount();
|
||||||
fetchTopGames();
|
fetchTopGames();
|
||||||
fetchPlaying();
|
fetchPlaying();
|
||||||
|
fetchUpNext();
|
||||||
fetchSummary();
|
fetchSummary();
|
||||||
fetchActivity();
|
fetchActivity();
|
||||||
fetchAchievements();
|
fetchAchievements();
|
||||||
@@ -57,7 +61,7 @@ export default function useDashboardData() {
|
|||||||
const {ok, status, data} = await reloadUser();
|
const {ok, status, data} = await reloadUser();
|
||||||
if (ok) {
|
if (ok) {
|
||||||
// Games added by hand need no Steam ID; history and achievements come only from Steam.
|
// Games added by hand need no Steam ID; history and achievements come only from Steam.
|
||||||
await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary(),
|
await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchUpNext(), fetchSummary(),
|
||||||
...(data.steamId ? [fetchActivity(), fetchAchievements()] : [])]);
|
...(data.steamId ? [fetchActivity(), fetchAchievements()] : [])]);
|
||||||
} else if (status === 0) {
|
} else if (status === 0) {
|
||||||
setError('INFO.ERROR.NETWORK_FAILURE');
|
setError('INFO.ERROR.NETWORK_FAILURE');
|
||||||
@@ -68,10 +72,10 @@ export default function useDashboardData() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchUserData();
|
fetchUserData();
|
||||||
}, [reloadUser, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
|
}, [reloadUser, fetchSteamCount, fetchTopGames, fetchPlaying, fetchUpNext, fetchSummary, fetchActivity, fetchAchievements]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
steamData, topGames, playing, summary, summaryFailed, activity, achievements, isLoading, error,
|
steamData, topGames, playing, upNext, summary, summaryFailed, activity, achievements, isLoading, error,
|
||||||
fetchSummary, reloadLibrary,
|
fetchSummary, reloadLibrary,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import {useRef, useState} from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag to reorder a list with pointer events, so it works with a mouse and with touch. Put `handleProps(index)` on
|
||||||
|
* a drag handle and `rowRef(index)` on each row. The handle needs `touch-none`: only it stops the page scroll.
|
||||||
|
* The drop target is the row whose middle is closest to the pointer. There is no auto-scroll.
|
||||||
|
*
|
||||||
|
* @param onMove called with (from, to) when a drag ends on another row
|
||||||
|
* @returns dragFrom and dragOver: the indexes of the dragged row and of the row it is over, null when idle
|
||||||
|
*/
|
||||||
|
export default function useDragSort(onMove) {
|
||||||
|
const [drag, setDrag] = useState({from: null, over: null});
|
||||||
|
const current = useRef(drag);
|
||||||
|
const rows = useRef([]);
|
||||||
|
|
||||||
|
const update = (next) => {
|
||||||
|
current.current = next;
|
||||||
|
setDrag(next);
|
||||||
|
};
|
||||||
|
const reset = () => update({from: null, over: null});
|
||||||
|
|
||||||
|
const closestRow = (clientY) => {
|
||||||
|
let best = null;
|
||||||
|
let bestDistance = Infinity;
|
||||||
|
rows.current.forEach((row, index) => {
|
||||||
|
if (!row) return;
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
const distance = Math.abs(rect.top + rect.height / 2 - clientY);
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
best = index;
|
||||||
|
bestDistance = distance;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProps = (index) => ({
|
||||||
|
onPointerDown: (e) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||||
|
update({from: index, over: index});
|
||||||
|
},
|
||||||
|
onPointerMove: (e) => {
|
||||||
|
if (current.current.from === null) return;
|
||||||
|
const over = closestRow(e.clientY);
|
||||||
|
if (over !== null && over !== current.current.over) update({...current.current, over});
|
||||||
|
},
|
||||||
|
onPointerUp: () => {
|
||||||
|
const {from, over} = current.current;
|
||||||
|
reset();
|
||||||
|
if (from !== null && over !== null && from !== over) onMove(from, over);
|
||||||
|
},
|
||||||
|
onPointerCancel: reset,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rowRef = (index) => (node) => {
|
||||||
|
rows.current[index] = node;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {dragFrom: drag.from, dragOver: drag.over, rowRef, handleProps};
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||||
|
import {Link} from 'react-router-dom';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {ChevronDown, ChevronUp, EllipsisVertical, GripVertical, ListOrdered, Timer} from 'lucide-react';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
|
import {Badge} from '@/components/ui/badge';
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card} from '@/components/ui/card';
|
||||||
|
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@/components/ui/dropdown-menu';
|
||||||
|
import {Skeleton} from '@/components/ui/skeleton';
|
||||||
|
import {cn} from '@/lib/utils';
|
||||||
|
import ComingBadge from '../components/ComingBadge.jsx';
|
||||||
|
import FitText from '../components/FitText.jsx';
|
||||||
|
import GameCover from '../components/GameCover.jsx';
|
||||||
|
import LibraryTabs from '../components/LibraryTabs.jsx';
|
||||||
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
|
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||||
|
import useDragSort from '../hooks/useDragSort.js';
|
||||||
|
import {loadBacklog, moveItem, saveBacklogOrder} from '../utils/backlogOrder.js';
|
||||||
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
|
|
||||||
|
export default function BacklogPage() {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const nextSignal = useAbortPrevious();
|
||||||
|
const [games, setGames] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
// While a PUT runs, only the newest list waits; it is sent when the PUT answers, so the server gets them in order.
|
||||||
|
const saving = useRef(false);
|
||||||
|
const queued = useRef(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const signal = nextSignal();
|
||||||
|
const {ok, status, data} = await loadBacklog({signal});
|
||||||
|
if (signal.aborted || status === 401) return;
|
||||||
|
if (ok) {
|
||||||
|
setError(null);
|
||||||
|
setGames(data.games ?? []);
|
||||||
|
} else {
|
||||||
|
setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'BACKLOG_ORDER.LOAD_FAILED');
|
||||||
|
}
|
||||||
|
}, [nextSignal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const save = async (appids) => {
|
||||||
|
if (saving.current) {
|
||||||
|
queued.current = appids;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.current = true;
|
||||||
|
try {
|
||||||
|
let next = appids;
|
||||||
|
while (next) {
|
||||||
|
queued.current = null;
|
||||||
|
const response = await saveBacklogOrder(next);
|
||||||
|
if (response.status === 401) return;
|
||||||
|
if (!response.ok) throw new Error(`Saving the backlog order failed with ${response.status}`);
|
||||||
|
next = queued.current;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Backlog order save error:', err);
|
||||||
|
queued.current = null;
|
||||||
|
toast.error(t('BACKLOG_ORDER.SAVE_FAILED'));
|
||||||
|
load();
|
||||||
|
} finally {
|
||||||
|
saving.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const move = (from, to) => {
|
||||||
|
const next = moveItem(games, from, to);
|
||||||
|
if (next === games) return;
|
||||||
|
setGames(next);
|
||||||
|
save(next.map((game) => game.appid));
|
||||||
|
};
|
||||||
|
|
||||||
|
const {dragFrom, dragOver, rowRef, handleProps} = useDragSort(move);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<LibraryTabs/>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">{t('BACKLOG_ORDER.TITLE')}</h1>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">{t('BACKLOG_ORDER.SUBTITLE')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{games === null && !error && (
|
||||||
|
<Card className="gap-0 py-0 divide-y">
|
||||||
|
{Array.from({length: 5}, (_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 px-3 py-3">
|
||||||
|
<Skeleton className="h-9 w-16 rounded-md"/>
|
||||||
|
<Skeleton className="h-4 flex-1"/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="py-12 text-center text-destructive">{t(error)}</div>}
|
||||||
|
|
||||||
|
{games?.length === 0 && (
|
||||||
|
<Card className="items-center py-16 text-center text-muted-foreground">
|
||||||
|
<ListOrdered className="size-10"/>
|
||||||
|
<p className="max-w-sm text-sm">{t('BACKLOG_ORDER.EMPTY')}</p>
|
||||||
|
<Button asChild variant="outline" size="sm"><Link to="/library">{t('LIBRARY.TITLE')}</Link></Button>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{games?.length > 0 && (
|
||||||
|
<Card className="gap-0 py-0">
|
||||||
|
<ol className="divide-y">
|
||||||
|
{games.map((game, index) => (
|
||||||
|
<li
|
||||||
|
key={game.appid}
|
||||||
|
ref={rowRef(index)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-2 py-2 sm:gap-3 sm:px-4',
|
||||||
|
dragFrom === index && 'bg-muted opacity-70',
|
||||||
|
dragFrom !== null && dragOver === index && dragFrom !== index && 'bg-primary/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="w-5 shrink-0 text-right text-xs tabular-nums text-muted-foreground sm:w-7 sm:text-sm">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-12 shrink-0 sm:w-24"/>
|
||||||
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
|
<Link to={`/library/${game.appid}`} className="@container block min-w-0 text-sm font-medium transition-colors hover:text-primary sm:text-base">
|
||||||
|
<FitText text={game.name ?? `App ${game.appid}`}/>
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<ComingBadge game={game} compact/>
|
||||||
|
{game.timeToBeatMinutes > 0 && (
|
||||||
|
<span className="flex items-center gap-1 tabular-nums">
|
||||||
|
<Timer className="size-3.5"/>{formatPlaytime(game.timeToBeatMinutes)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{game.priority && (
|
||||||
|
<Badge variant="secondary">{t(`BACKLOG.PRIORITY.${game.priority}`)}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={t('BACKLOG_ORDER.MOVE_DRAG')}
|
||||||
|
className={cn('touch-none select-none cursor-grab', dragFrom === index && 'cursor-grabbing')}
|
||||||
|
{...handleProps(index)}
|
||||||
|
>
|
||||||
|
<GripVertical/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={t('BACKLOG_ORDER.MOVE_UP')}
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => move(index, index - 1)}
|
||||||
|
>
|
||||||
|
<ChevronUp/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={t('BACKLOG_ORDER.MOVE_DOWN')}
|
||||||
|
disabled={index === games.length - 1}
|
||||||
|
onClick={() => move(index, index + 1)}
|
||||||
|
>
|
||||||
|
<ChevronDown/>
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label={t('BACKLOG_ORDER.MORE')}>
|
||||||
|
<EllipsisVertical/>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem disabled={index === 0} onSelect={() => move(index, 0)}>
|
||||||
|
{t('BACKLOG_ORDER.MOVE_TOP')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem disabled={index === games.length - 1}
|
||||||
|
onSelect={() => move(index, games.length - 1)}>
|
||||||
|
{t('BACKLOG_ORDER.MOVE_BOTTOM')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// @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 BacklogPage from './BacklogPage.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()}}));
|
||||||
|
|
||||||
|
const game = (appid, extra = {}) => ({appid, name: `Game ${appid}`, priority: null, timeToBeatMinutes: null, ...extra});
|
||||||
|
const backlog = {games: [game(1), game(2), game(3), game(4, {coming: true, comingOn: '2099-01-01'})]};
|
||||||
|
|
||||||
|
// The providers load the user and the preferences; this page does not use them.
|
||||||
|
const withAuth = (routes) => fetchRoutes({'GET /api/users/me': json(200, {settings: {}}), ...routes});
|
||||||
|
|
||||||
|
const renderPage = () => renderWithProviders(<BacklogPage/>,
|
||||||
|
{path: '/backlog', entries: ['/backlog'], guarded: true, routes: {'/library': <p>library</p>}});
|
||||||
|
|
||||||
|
const names = () => [...document.querySelectorAll('ol a[href^="/library/"]')].map((a) => a.textContent);
|
||||||
|
const puts = () => globalThis.fetch.mock.calls.filter(([, init]) => init?.method === 'PUT')
|
||||||
|
.map(([, init]) => JSON.parse(init.body).appids);
|
||||||
|
const rowButton = (rowName, label) => {
|
||||||
|
const row = screen.getByText(rowName).closest('li');
|
||||||
|
return [...row.querySelectorAll('button')].find((b) => b.getAttribute('aria-label') === label);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('BacklogPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the games in the order of the server, a coming game too', async () => {
|
||||||
|
withAuth({'GET /api/library/backlog': json(200, backlog)});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
expect(names()).toEqual(['Game 1', 'Game 2', 'Game 3', 'Game 4']);
|
||||||
|
expect(screen.getByText('LIBRARY.COMING', {exact: false})).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves a game up and down with the arrows and sends the whole list', async () => {
|
||||||
|
withAuth({
|
||||||
|
'GET /api/library/backlog': json(200, backlog),
|
||||||
|
'PUT /api/library/backlog': json(204, {}),
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
fireEvent.click(rowButton('Game 3', 'BACKLOG_ORDER.MOVE_UP'));
|
||||||
|
await waitFor(() => expect(puts()).toEqual([[1, 3, 2, 4]]));
|
||||||
|
expect(names()).toEqual(['Game 1', 'Game 3', 'Game 2', 'Game 4']);
|
||||||
|
|
||||||
|
fireEvent.click(rowButton('Game 1', 'BACKLOG_ORDER.MOVE_DOWN'));
|
||||||
|
await waitFor(() => expect(puts()).toEqual([[1, 3, 2, 4], [3, 1, 2, 4]]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not offer to move the first game up or the last game down', async () => {
|
||||||
|
withAuth({'GET /api/library/backlog': json(200, backlog)});
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
expect(rowButton('Game 1', 'BACKLOG_ORDER.MOVE_UP').disabled).toBe(true);
|
||||||
|
expect(rowButton('Game 4', 'BACKLOG_ORDER.MOVE_DOWN').disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['BACKLOG_ORDER.MOVE_TOP', 'Game 3', [3, 1, 2, 4]],
|
||||||
|
['BACKLOG_ORDER.MOVE_BOTTOM', 'Game 2', [1, 3, 4, 2]],
|
||||||
|
])('%s from the menu sends the new order', async (item, row, expected) => {
|
||||||
|
withAuth({
|
||||||
|
'GET /api/library/backlog': json(200, backlog),
|
||||||
|
'PUT /api/library/backlog': json(204, {}),
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
fireEvent.keyDown(rowButton(row, 'BACKLOG_ORDER.MORE'), {key: 'Enter'});
|
||||||
|
fireEvent.click(await screen.findByRole('menuitem', {name: item}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(puts()).toEqual([expected]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves a game with the drag handle to the row under the pointer', async () => {
|
||||||
|
withAuth({
|
||||||
|
'GET /api/library/backlog': json(200, backlog),
|
||||||
|
'PUT /api/library/backlog': json(204, {}),
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
document.querySelectorAll('ol > li').forEach((row, i) => {
|
||||||
|
row.getBoundingClientRect = () => ({top: i * 100, height: 100});
|
||||||
|
});
|
||||||
|
const grip = rowButton('Game 1', 'BACKLOG_ORDER.MOVE_DRAG');
|
||||||
|
|
||||||
|
fireEvent.pointerDown(grip, {button: 0, pointerId: 1});
|
||||||
|
fireEvent.pointerMove(grip, {clientY: 240, pointerId: 1});
|
||||||
|
fireEvent.pointerUp(grip, {pointerId: 1});
|
||||||
|
|
||||||
|
await waitFor(() => expect(puts()).toEqual([[2, 3, 1, 4]]));
|
||||||
|
expect(names()).toEqual(['Game 2', 'Game 3', 'Game 1', 'Game 4']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends nothing when a drag ends on the row it started on, or is cancelled', async () => {
|
||||||
|
withAuth({'GET /api/library/backlog': json(200, backlog)});
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
document.querySelectorAll('ol > li').forEach((row, i) => {
|
||||||
|
row.getBoundingClientRect = () => ({top: i * 100, height: 100});
|
||||||
|
});
|
||||||
|
const grip = rowButton('Game 1', 'BACKLOG_ORDER.MOVE_DRAG');
|
||||||
|
|
||||||
|
fireEvent.pointerDown(grip, {button: 0, pointerId: 1});
|
||||||
|
fireEvent.pointerUp(grip, {pointerId: 1});
|
||||||
|
fireEvent.pointerDown(grip, {button: 0, pointerId: 1});
|
||||||
|
fireEvent.pointerMove(grip, {clientY: 340, pointerId: 1});
|
||||||
|
fireEvent.pointerCancel(grip, {pointerId: 1});
|
||||||
|
|
||||||
|
expect(puts()).toEqual([]);
|
||||||
|
expect(names()).toEqual(['Game 1', 'Game 2', 'Game 3', 'Game 4']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a toast and loads the list again when saving fails', async () => {
|
||||||
|
let loads = 0;
|
||||||
|
withAuth({
|
||||||
|
'GET /api/library/backlog': () => json(200, ++loads === 1 ? backlog : {games: [game(2), game(1)]}),
|
||||||
|
'PUT /api/library/backlog': json(500, {}),
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
fireEvent.click(rowButton('Game 2', 'BACKLOG_ORDER.MOVE_UP'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('BACKLOG_ORDER.SAVE_FAILED'));
|
||||||
|
await waitFor(() => expect(names()).toEqual(['Game 2', 'Game 1']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends only the newest list after a running save, so the server gets them in order', async () => {
|
||||||
|
let release;
|
||||||
|
const first = new Promise((resolve) => { release = resolve; });
|
||||||
|
let puts_ = 0;
|
||||||
|
withAuth({
|
||||||
|
'GET /api/library/backlog': json(200, backlog),
|
||||||
|
'PUT /api/library/backlog': async () => {
|
||||||
|
if (++puts_ === 1) await first;
|
||||||
|
return json(204, {});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
fireEvent.click(rowButton('Game 2', 'BACKLOG_ORDER.MOVE_UP'));
|
||||||
|
fireEvent.click(rowButton('Game 3', 'BACKLOG_ORDER.MOVE_UP'));
|
||||||
|
fireEvent.click(rowButton('Game 4', 'BACKLOG_ORDER.MOVE_UP'));
|
||||||
|
expect(puts()).toEqual([[2, 1, 3, 4]]);
|
||||||
|
|
||||||
|
release();
|
||||||
|
|
||||||
|
await waitFor(() => expect(puts()).toEqual([[2, 1, 3, 4], [2, 3, 4, 1]]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('explains an empty backlog and links to the library', async () => {
|
||||||
|
withAuth({'GET /api/library/backlog': json(200, {games: []})});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('BACKLOG_ORDER.EMPTY')).toBeTruthy();
|
||||||
|
// The tab and the link of the empty card.
|
||||||
|
expect(screen.getAllByRole('link', {name: 'LIBRARY.TITLE'})).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an error when the list cannot be loaded', async () => {
|
||||||
|
withAuth({'GET /api/library/backlog': json(500, {})});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('BACKLOG_ORDER.LOAD_FAILED')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets only the drag handle stop the page scroll', async () => {
|
||||||
|
withAuth({'GET /api/library/backlog': json(200, backlog)});
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
expect(rowButton('Game 1', 'BACKLOG_ORDER.MOVE_DRAG').className).toContain('touch-none');
|
||||||
|
const row = screen.getByText('Game 1').closest('li');
|
||||||
|
expect(row.className).not.toContain('touch-none');
|
||||||
|
expect(screen.getByText('Game 1').closest('ol').className).not.toContain('touch-none');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ import DashboardWelcomeCard from '../components/DashboardWelcomeCard.jsx';
|
|||||||
import MostPlayedCard from '../components/MostPlayedCard.jsx';
|
import MostPlayedCard from '../components/MostPlayedCard.jsx';
|
||||||
import PileOfShameCard from '../components/PileOfShameCard.jsx';
|
import PileOfShameCard from '../components/PileOfShameCard.jsx';
|
||||||
import SteamGameCount from '../components/SteamGameCount';
|
import SteamGameCount from '../components/SteamGameCount';
|
||||||
|
import UpNextCard from '../components/UpNextCard.jsx';
|
||||||
import SyncChangesBanner from '../components/SyncChangesBanner.jsx';
|
import SyncChangesBanner from '../components/SyncChangesBanner.jsx';
|
||||||
import WhatsNewDialog from '../components/WhatsNewDialog.jsx';
|
import WhatsNewDialog from '../components/WhatsNewDialog.jsx';
|
||||||
import useDashboardData from '../hooks/useDashboardData.js';
|
import useDashboardData from '../hooks/useDashboardData.js';
|
||||||
@@ -29,7 +30,7 @@ import TileGrid from '../components/TileGrid.jsx';
|
|||||||
import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
|
import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
|
||||||
import statGroupTile from '../components/stats/statGroupTile.jsx';
|
import statGroupTile from '../components/stats/statGroupTile.jsx';
|
||||||
import {
|
import {
|
||||||
DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, PLAYING_GAMES, saveTiles, statGroup, TOP_GAMES,
|
DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, PLAYING_GAMES, saveTiles, statGroup, TOP_GAMES, UP_NEXT_GAMES,
|
||||||
} from '../utils/tiles.js';
|
} from '../utils/tiles.js';
|
||||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||||
@@ -38,7 +39,7 @@ const Dashboard = () => {
|
|||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const {user: userData, updateUser} = useCurrentUserContext();
|
const {user: userData, updateUser} = useCurrentUserContext();
|
||||||
const {
|
const {
|
||||||
steamData, topGames, playing, summary, summaryFailed, activity, achievements, isLoading, error,
|
steamData, topGames, playing, upNext, summary, summaryFailed, activity, achievements, isLoading, error,
|
||||||
fetchSummary, reloadLibrary,
|
fetchSummary, reloadLibrary,
|
||||||
} = useDashboardData();
|
} = useDashboardData();
|
||||||
const [shuffling, setShuffling] = useState(false);
|
const [shuffling, setShuffling] = useState(false);
|
||||||
@@ -110,6 +111,7 @@ const Dashboard = () => {
|
|||||||
return showLibrary && (
|
return showLibrary && (
|
||||||
<CurrentlyPlayingCard games={playing.slice(0, PLAYING_GAMES[size])} syncing={sync.isRunning} wide={size === 'L'}/>
|
<CurrentlyPlayingCard games={playing.slice(0, PLAYING_GAMES[size])} syncing={sync.isRunning} wide={size === 'L'}/>
|
||||||
);
|
);
|
||||||
|
case 'UP_NEXT': return showLibrary && <UpNextCard games={upNext.slice(0, UP_NEXT_GAMES[size])}/>;
|
||||||
case 'PILE_OF_SHAME':
|
case 'PILE_OF_SHAME':
|
||||||
if (!showLibrary) return null;
|
if (!showLibrary) return null;
|
||||||
if (summary) {
|
if (summary) {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const dashboardRoutes = (settings) => ({
|
|||||||
'GET /api/library/activity': json(200, {totalMinutes: 90, days: [{day: '2026-09-23', minutes: 0}, {day: '2026-09-24', minutes: 90}], recentGames: [
|
'GET /api/library/activity': json(200, {totalMinutes: 90, days: [{day: '2026-09-23', minutes: 0}, {day: '2026-09-24', minutes: 90}], recentGames: [
|
||||||
{appid: 1086940, name: "Baldur's Gate 3", playtimeMinutes: 900, playtime2Weeks: 90, status: 'PLAYING'}]}),
|
{appid: 1086940, name: "Baldur's Gate 3", playtimeMinutes: 900, playtime2Weeks: 90, status: 'PLAYING'}]}),
|
||||||
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true}),
|
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true}),
|
||||||
|
'GET /api/library/backlog': json(200, {games: [{appid: 7, name: 'Hades'}]}),
|
||||||
'GET /api/library/achievements/summary': json(200, {unlocked: 24, startedGames: 4, perfectGames: 2,
|
'GET /api/library/achievements/summary': json(200, {unlocked: 24, startedGames: 4, perfectGames: 2,
|
||||||
averageCompletion: 71.3, pendingGames: 0, hiddenGames: 0, closest: []}),
|
averageCompletion: 71.3, pendingGames: 0, hiddenGames: 0, closest: []}),
|
||||||
'PUT /api/users/settings/dashboard-tiles': json(204, {}),
|
'PUT /api/users/settings/dashboard-tiles': json(204, {}),
|
||||||
@@ -85,7 +86,7 @@ describe('Dashboard', () => {
|
|||||||
const container = mockDashboard();
|
const container = mockDashboard();
|
||||||
|
|
||||||
await screen.findByText('DASHBOARD.ACHIEVEMENTS.TITLE');
|
await screen.findByText('DASHBOARD.ACHIEVEMENTS.TITLE');
|
||||||
expect(order(container)).toEqual(['GAME_COUNT', 'MOST_PLAYED', 'PLAYING', 'PILE_OF_SHAME', 'ACTIVITY', 'ACHIEVEMENTS']);
|
expect(order(container)).toEqual(['GAME_COUNT', 'MOST_PLAYED', 'PLAYING', 'UP_NEXT', 'PILE_OF_SHAME', 'ACTIVITY', 'ACHIEVEMENTS']);
|
||||||
expect(screen.getByText('Game 4')).toBeTruthy();
|
expect(screen.getByText('Game 4')).toBeTruthy();
|
||||||
expect(screen.queryByText('Game 5')).toBeNull();
|
expect(screen.queryByText('Game 5')).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -213,6 +214,7 @@ describe('Dashboard', () => {
|
|||||||
'GET /api/library/stats': json(200, empty),
|
'GET /api/library/stats': json(200, empty),
|
||||||
'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo'}),
|
'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo'}),
|
||||||
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true}),
|
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true}),
|
||||||
|
'GET /api/library/backlog': json(200, {games: []}),
|
||||||
'GET /api/library/games': (url) => (mostPlayed(url)
|
'GET /api/library/games': (url) => (mostPlayed(url)
|
||||||
? {ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected token <'); }}
|
? {ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected token <'); }}
|
||||||
: json(200, empty)),
|
: json(200, empty)),
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ describe('Dashboard sync on a visit', () => {
|
|||||||
'GET /api/library/stats': json(200, {trend: null}),
|
'GET /api/library/stats': json(200, {trend: null}),
|
||||||
'GET /api/library/steam-profile': json(200, {personaName: 'Lobo', avatarUrl: null}),
|
'GET /api/library/steam-profile': json(200, {personaName: 'Lobo', avatarUrl: null}),
|
||||||
'GET /api/library/activity': json(200, {totalMinutes: 0, days: [], recentGames: []}),
|
'GET /api/library/activity': json(200, {totalMinutes: 0, days: [], recentGames: []}),
|
||||||
|
'GET /api/library/backlog': json(200, {games: []}),
|
||||||
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true, syncStarted: true}),
|
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true, syncStarted: true}),
|
||||||
'GET /api/library/sync-status': () => json(200, syncStatus),
|
'GET /api/library/sync-status': () => json(200, syncStatus),
|
||||||
'GET /api/library/achievements/summary': json(200, {unlocked: 0, startedGames: 0, perfectGames: 0,
|
'GET /api/library/achievements/summary': json(200, {unlocked: 0, startedGames: 0, perfectGames: 0,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const BUDGETS = [
|
|||||||
// Core derives the moods from IGDB's genres, themes and game modes, and SPEEDRUN from speedrun.com; a mood leaves out
|
// Core derives the moods from IGDB's genres, themes and game modes, and SPEEDRUN from speedrun.com; a mood leaves out
|
||||||
// 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'];
|
||||||
const PICK_COUNT = 3;
|
const PICK_COUNT = 4;
|
||||||
// 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;
|
||||||
|
|
||||||
@@ -139,13 +139,13 @@ export default function PlayNextPage() {
|
|||||||
{error && <p className="text-sm text-destructive">{t(error)}</p>}
|
{error && <p className="text-sm text-destructive">{t(error)}</p>}
|
||||||
|
|
||||||
{games === null && !error && (
|
{games === null && !error && (
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{Array.from({length: PICK_COUNT}, (_, i) => <Skeleton key={i} className="h-56 rounded-xl"/>)}
|
{Array.from({length: PICK_COUNT}, (_, i) => <Skeleton key={i} className="h-56 rounded-xl"/>)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{games?.length > 0 && (
|
{games?.length > 0 && (
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{games.map((game) => <PickCard key={game.appid} game={game}/>)}
|
{games.map((game) => <PickCard key={game.appid} game={game}/>)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -186,6 +186,7 @@ const reasonText = (reason, t, language) => {
|
|||||||
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'})});
|
||||||
}
|
}
|
||||||
|
if (reason.source === 'NEXT_UP') return t('PLAY_NEXT.REASON.NEXT_UP');
|
||||||
return t('PLAY_NEXT.REASON.RANDOM');
|
return t('PLAY_NEXT.REASON.RANDOM');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ describe('PlayNextPage', () => {
|
|||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows three picks for any length by default', async () => {
|
it('shows four picks for any length by default', async () => {
|
||||||
mockBackend([[game(1), game(2), game(3)]]);
|
mockBackend([[game(1), game(2), game(3)]]);
|
||||||
|
|
||||||
renderPage();
|
renderPage();
|
||||||
@@ -51,11 +51,19 @@ describe('PlayNextPage', () => {
|
|||||||
expect(await screen.findByText('Game 1')).toBeTruthy();
|
expect(await screen.findByText('Game 1')).toBeTruthy();
|
||||||
expect(screen.getByText('Game 3')).toBeTruthy();
|
expect(screen.getByText('Game 3')).toBeTruthy();
|
||||||
const params = pickerUrls()[0];
|
const params = pickerUrls()[0];
|
||||||
expect(params.get('count')).toBe('3');
|
expect(params.get('count')).toBe('4');
|
||||||
expect(params.has('maxMinutes')).toBe(false);
|
expect(params.has('maxMinutes')).toBe(false);
|
||||||
expect(params.has('exclude')).toBe(false);
|
expect(params.has('exclude')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('says why a game from the top of the backlog order was picked', async () => {
|
||||||
|
mockBackend([[game(1, {reason: {source: 'NEXT_UP'}})]]);
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('PLAY_NEXT.REASON.NEXT_UP')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it('asks for games up to the chosen length', async () => {
|
it('asks for games up to the chosen length', async () => {
|
||||||
mockBackend([[game(1)], [game(2)]]);
|
mockBackend([[game(1)], [game(2)]]);
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {apiFetch, request} from './api.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The listed backlog in the user's own order, coming games included.
|
||||||
|
*/
|
||||||
|
export const loadBacklog = (init) => request(`${import.meta.env.VITE_CORE_API_URL}/api/library/backlog`, init);
|
||||||
|
|
||||||
|
export const saveBacklogOrder = (appids) =>
|
||||||
|
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/backlog`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({appids}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A new list with the item at `from` moved to index `to`, which is clamped to the list. The same list when nothing moves.
|
||||||
|
*/
|
||||||
|
export const moveItem = (list, from, to) => {
|
||||||
|
const target = Math.min(Math.max(to, 0), list.length - 1);
|
||||||
|
if (from < 0 || from >= list.length || from === target) return list;
|
||||||
|
const next = [...list];
|
||||||
|
const [item] = next.splice(from, 1);
|
||||||
|
next.splice(target, 0, item);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {describe, expect, it} from 'vitest';
|
||||||
|
import {moveItem} from './backlogOrder.js';
|
||||||
|
|
||||||
|
const list = ['a', 'b', 'c', 'd'];
|
||||||
|
|
||||||
|
describe('moveItem', () => {
|
||||||
|
it('moves an item up and down by one', () => {
|
||||||
|
expect(moveItem(list, 2, 1)).toEqual(['a', 'c', 'b', 'd']);
|
||||||
|
expect(moveItem(list, 1, 2)).toEqual(['a', 'c', 'b', 'd']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves an item to the top and to the bottom', () => {
|
||||||
|
expect(moveItem(list, 2, 0)).toEqual(['c', 'a', 'b', 'd']);
|
||||||
|
expect(moveItem(list, 1, 3)).toEqual(['a', 'c', 'd', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps a target outside the list', () => {
|
||||||
|
expect(moveItem(list, 2, -5)).toEqual(['c', 'a', 'b', 'd']);
|
||||||
|
expect(moveItem(list, 1, 99)).toEqual(['a', 'c', 'd', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the list as it is for a source outside the list, and does not change the input', () => {
|
||||||
|
expect(moveItem(list, 9, 0)).toBe(list);
|
||||||
|
moveItem(list, 0, 3);
|
||||||
|
expect(list).toEqual(['a', 'b', 'c', 'd']);
|
||||||
|
});
|
||||||
|
});
|
||||||
+4
-1
@@ -23,6 +23,7 @@ export const DASHBOARD_TILES = {
|
|||||||
GAME_COUNT: {S: 2, M: 4, L: 8},
|
GAME_COUNT: {S: 2, M: 4, L: 8},
|
||||||
MOST_PLAYED: {S: 4, M: 8, L: 12},
|
MOST_PLAYED: {S: 4, M: 8, L: 12},
|
||||||
PLAYING: {S: 3, M: 6, L: 12},
|
PLAYING: {S: 3, M: 6, L: 12},
|
||||||
|
UP_NEXT: {S: 3, M: 6, L: 12},
|
||||||
PILE_OF_SHAME: {M: 6, L: 12},
|
PILE_OF_SHAME: {M: 6, L: 12},
|
||||||
ACTIVITY: {S: 6, M: 12},
|
ACTIVITY: {S: 6, M: 12},
|
||||||
ACHIEVEMENTS: {S: 6, M: 12},
|
ACHIEVEMENTS: {S: 6, M: 12},
|
||||||
@@ -43,6 +44,7 @@ const STAT_TITLES = Object.fromEntries(Object.keys(STAT_TILES).map((id) => [id,
|
|||||||
|
|
||||||
export const DASHBOARD_TITLES = {
|
export const DASHBOARD_TITLES = {
|
||||||
GAME_COUNT: 'DASHBOARD.STEAM_GAMES', MOST_PLAYED: 'DASHBOARD.MOST_PLAYED', PLAYING: 'DASHBOARD.CURRENTLY_PLAYING',
|
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',
|
PILE_OF_SHAME: 'DASHBOARD.PILE_OF_SHAME', ACTIVITY: 'DASHBOARD.ACTIVITY', ACHIEVEMENTS: 'DASHBOARD.ACHIEVEMENTS.TITLE',
|
||||||
STATS_OVERVIEW: 'STATS.OVERVIEW.TITLE', ...STAT_TITLES,
|
STATS_OVERVIEW: 'STATS.OVERVIEW.TITLE', ...STAT_TITLES,
|
||||||
};
|
};
|
||||||
@@ -54,13 +56,14 @@ export const PROFILE_TITLES = {
|
|||||||
|
|
||||||
const all = (ids) => ids.map((id) => ({id, size: 'M'}));
|
const all = (ids) => ids.map((id) => ({id, size: 'M'}));
|
||||||
|
|
||||||
export const DEFAULT_DASHBOARD = all(['GAME_COUNT', 'MOST_PLAYED', 'PLAYING', 'PILE_OF_SHAME', 'ACTIVITY', 'ACHIEVEMENTS']);
|
export const DEFAULT_DASHBOARD = all(['GAME_COUNT', 'MOST_PLAYED', 'PLAYING', 'UP_NEXT', 'PILE_OF_SHAME', 'ACTIVITY', 'ACHIEVEMENTS']);
|
||||||
export const DEFAULT_PROFILE = all(['SUMMARY', 'STATUS', 'PLAYING', 'FINISHED']);
|
export const DEFAULT_PROFILE = all(['SUMMARY', 'STATUS', 'PLAYING', 'FINISHED']);
|
||||||
|
|
||||||
export const SIZES = ['S', 'M', 'L'];
|
export const SIZES = ['S', 'M', 'L'];
|
||||||
// Games per dashboard tile size; the dashboard loads the most for the large tile.
|
// Games per dashboard tile size; the dashboard loads the most for the large tile.
|
||||||
export const TOP_GAMES = {S: 3, M: 5, L: 10};
|
export const TOP_GAMES = {S: 3, M: 5, L: 10};
|
||||||
export const PLAYING_GAMES = {S: 2, M: 4, L: 8};
|
export const PLAYING_GAMES = {S: 2, M: 4, L: 8};
|
||||||
|
export const UP_NEXT_GAMES = {S: 3, M: 5, L: 5};
|
||||||
|
|
||||||
// Written out, so Tailwind finds every class.
|
// Written out, so Tailwind finds every class.
|
||||||
const SPANS = {
|
const SPANS = {
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ describe('normalizeLayout', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('DEFAULT_DASHBOARD', () => {
|
||||||
|
it('has the up next tile, and a saved layout keeps it', () => {
|
||||||
|
expect(DEFAULT_DASHBOARD.map((tile) => tile.id)).toContain('UP_NEXT');
|
||||||
|
expect(normalizeLayout([{id: 'UP_NEXT', size: 'L'}], DASHBOARD_TILES, DEFAULT_DASHBOARD))
|
||||||
|
.toEqual([{id: 'UP_NEXT', size: 'L'}]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('spanClass', () => {
|
describe('spanClass', () => {
|
||||||
it('halves and doubles the normal width', () => {
|
it('halves and doubles the normal width', () => {
|
||||||
expect(['S', 'M', 'L'].map((size) => spanClass(DASHBOARD_TILES, {id: 'MOST_PLAYED', size})))
|
expect(['S', 'M', 'L'].map((size) => spanClass(DASHBOARD_TILES, {id: 'MOST_PLAYED', size})))
|
||||||
|
|||||||
Reference in new issue
Block a user