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
+676
-23
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
|
||||
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.
|
||||
- **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
|
||||
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
|
||||
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.
|
||||
@@ -195,6 +199,7 @@ flowchart LR
|
||||
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
|
||||
(`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).
|
||||
|
||||
+18
-2
@@ -202,6 +202,8 @@
|
||||
"WELCOME_BACK": "Willkommen zurück",
|
||||
"CURRENTLY_PLAYING": "Spiele ich gerade",
|
||||
"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_SHARE": "{{percent}} % deiner Bibliothek",
|
||||
"NEVER_PLAYED_one": "{{count}} Spiel nie gestartet",
|
||||
@@ -386,6 +388,19 @@
|
||||
"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": {
|
||||
"TITLE": "Wunschliste",
|
||||
"ONLY_ON_SALE": "Nur im Angebot",
|
||||
@@ -411,11 +426,12 @@
|
||||
},
|
||||
"PLAY_NEXT": {
|
||||
"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": {
|
||||
"SIMILAR": "Weil du {{name}} gespielt hast ({{playtime}})",
|
||||
"LONGEST": "Auf deiner Liste seit {{date}}",
|
||||
"RANDOM": "Zufallstreffer"
|
||||
"RANDOM": "Zufallstreffer",
|
||||
"NEXT_UP": "Als Nächstes in deinem Backlog"
|
||||
},
|
||||
"BUDGET_LABEL": "Spieldauer",
|
||||
"BUDGET": {
|
||||
|
||||
+18
-2
@@ -202,6 +202,8 @@
|
||||
"WELCOME_BACK": "Welcome back",
|
||||
"CURRENTLY_PLAYING": "Currently 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_SHARE": "{{percent}}% of your library",
|
||||
"NEVER_PLAYED_one": "{{count}} game never started",
|
||||
@@ -386,6 +388,19 @@
|
||||
"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": {
|
||||
"TITLE": "Wishlist",
|
||||
"ONLY_ON_SALE": "On sale only",
|
||||
@@ -411,11 +426,12 @@
|
||||
},
|
||||
"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": {
|
||||
"SIMILAR": "Because you played {{name}} ({{playtime}})",
|
||||
"LONGEST": "On your list since {{date}}",
|
||||
"RANDOM": "Random pick"
|
||||
"RANDOM": "Random pick",
|
||||
"NEXT_UP": "Next in your backlog"
|
||||
},
|
||||
"BUDGET_LABEL": "Time to beat",
|
||||
"BUDGET": {
|
||||
|
||||
@@ -23,6 +23,7 @@ import LinkQueuePage from './pages/LinkQueuePage.jsx';
|
||||
import GameLibrary from './pages/GameLibrary.jsx';
|
||||
import GameDetail from './pages/GameDetail.jsx';
|
||||
import WishlistPage from './pages/WishlistPage.jsx';
|
||||
import BacklogPage from './pages/BacklogPage.jsx';
|
||||
import PlayNextPage from './pages/PlayNextPage.jsx';
|
||||
import StatsPage from './pages/StatsPage.jsx';
|
||||
import ImportPage from './pages/ImportPage.jsx';
|
||||
@@ -83,6 +84,7 @@ function App() {
|
||||
<Route path="/library" element={<GameLibrary/>}/>
|
||||
<Route path="/library/:appid" element={<GameDetail/>}/>
|
||||
<Route path="/wishlist" element={<WishlistPage/>}/>
|
||||
<Route path="/backlog" element={<BacklogPage/>}/>
|
||||
<Route path="/play-next" element={<PlayNextPage/>}/>
|
||||
<Route path="/stats" element={<StatsPage/>}/>
|
||||
<Route path="/import" element={<ImportPage/>}/>
|
||||
|
||||
@@ -29,8 +29,8 @@ import {expireSession, markSession} from '../utils/auth.js';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
|
||||
// The wishlist is a tab of the library.
|
||||
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist']},
|
||||
// The wishlist and the backlog order are tabs of the library.
|
||||
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist', '/backlog']},
|
||||
{to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices},
|
||||
{to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn},
|
||||
];
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {NavLink} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Gift, Library} from 'lucide-react';
|
||||
import {Gift, Library, ListOrdered} from 'lucide-react';
|
||||
|
||||
import {cn} from '@/lib/utils';
|
||||
|
||||
const TABS = [
|
||||
{to: '/library', labelKey: 'LIBRARY.TITLE', icon: Library},
|
||||
{to: '/backlog', labelKey: 'BACKLOG_ORDER.TITLE', icon: ListOrdered},
|
||||
{to: '/wishlist', labelKey: 'WISHLIST.TITLE', icon: Gift},
|
||||
];
|
||||
|
||||
@@ -18,13 +19,19 @@ export default function LibraryTabs() {
|
||||
key={to}
|
||||
to={to}
|
||||
end
|
||||
title={t(labelKey)}
|
||||
className={({isActive}) => cn(
|
||||
'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}) => (
|
||||
<>
|
||||
<Icon className="size-4 shrink-0"/>
|
||||
<span className="truncate">{t(labelKey)}</span>
|
||||
{/* 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>
|
||||
))}
|
||||
</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 {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
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.
|
||||
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';
|
||||
|
||||
// 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 [topGames, setTopGames] = useState([]);
|
||||
const [playing, setPlaying] = useState([]);
|
||||
const [upNext, setUpNext] = useState([]);
|
||||
const [summary, setSummary] = useState(null);
|
||||
const [activity, setActivity] = 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 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 fetchUpNext = useCallback(() => loadInto(UP_NEXT_URL, setUpNext, (data) => data.games ?? []), []);
|
||||
const fetchActivity = useCallback(() => loadInto('/api/library/activity', setActivity), []);
|
||||
const fetchAchievements = useCallback(() => loadInto('/api/library/achievements/summary', setAchievements), []);
|
||||
|
||||
@@ -47,6 +50,7 @@ export default function useDashboardData() {
|
||||
fetchSteamCount();
|
||||
fetchTopGames();
|
||||
fetchPlaying();
|
||||
fetchUpNext();
|
||||
fetchSummary();
|
||||
fetchActivity();
|
||||
fetchAchievements();
|
||||
@@ -57,7 +61,7 @@ export default function useDashboardData() {
|
||||
const {ok, status, data} = await reloadUser();
|
||||
if (ok) {
|
||||
// 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()] : [])]);
|
||||
} else if (status === 0) {
|
||||
setError('INFO.ERROR.NETWORK_FAILURE');
|
||||
@@ -68,10 +72,10 @@ export default function useDashboardData() {
|
||||
};
|
||||
|
||||
fetchUserData();
|
||||
}, [reloadUser, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
|
||||
}, [reloadUser, fetchSteamCount, fetchTopGames, fetchPlaying, fetchUpNext, fetchSummary, fetchActivity, fetchAchievements]);
|
||||
|
||||
return {
|
||||
steamData, topGames, playing, summary, summaryFailed, activity, achievements, isLoading, error,
|
||||
steamData, topGames, playing, upNext, summary, summaryFailed, activity, achievements, isLoading, error,
|
||||
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 PileOfShameCard from '../components/PileOfShameCard.jsx';
|
||||
import SteamGameCount from '../components/SteamGameCount';
|
||||
import UpNextCard from '../components/UpNextCard.jsx';
|
||||
import SyncChangesBanner from '../components/SyncChangesBanner.jsx';
|
||||
import WhatsNewDialog from '../components/WhatsNewDialog.jsx';
|
||||
import useDashboardData from '../hooks/useDashboardData.js';
|
||||
@@ -29,7 +30,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,
|
||||
DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, 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';
|
||||
@@ -38,7 +39,7 @@ const Dashboard = () => {
|
||||
const {t} = useTranslation();
|
||||
const {user: userData, updateUser} = useCurrentUserContext();
|
||||
const {
|
||||
steamData, topGames, playing, summary, summaryFailed, activity, achievements, isLoading, error,
|
||||
steamData, topGames, playing, upNext, summary, summaryFailed, activity, achievements, isLoading, error,
|
||||
fetchSummary, reloadLibrary,
|
||||
} = useDashboardData();
|
||||
const [shuffling, setShuffling] = useState(false);
|
||||
@@ -110,6 +111,7 @@ const Dashboard = () => {
|
||||
return showLibrary && (
|
||||
<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':
|
||||
if (!showLibrary) return null;
|
||||
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: [
|
||||
{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/backlog': json(200, {games: [{appid: 7, name: 'Hades'}]}),
|
||||
'GET /api/library/achievements/summary': json(200, {unlocked: 24, startedGames: 4, perfectGames: 2,
|
||||
averageCompletion: 71.3, pendingGames: 0, hiddenGames: 0, closest: []}),
|
||||
'PUT /api/users/settings/dashboard-tiles': json(204, {}),
|
||||
@@ -85,7 +86,7 @@ describe('Dashboard', () => {
|
||||
const container = mockDashboard();
|
||||
|
||||
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.queryByText('Game 5')).toBeNull();
|
||||
});
|
||||
@@ -213,6 +214,7 @@ describe('Dashboard', () => {
|
||||
'GET /api/library/stats': json(200, empty),
|
||||
'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/backlog': json(200, {games: []}),
|
||||
'GET /api/library/games': (url) => (mostPlayed(url)
|
||||
? {ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected token <'); }}
|
||||
: json(200, empty)),
|
||||
|
||||
@@ -36,6 +36,7 @@ describe('Dashboard sync on a visit', () => {
|
||||
'GET /api/library/stats': json(200, {trend: 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/backlog': json(200, {games: []}),
|
||||
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true, syncStarted: true}),
|
||||
'GET /api/library/sync-status': () => json(200, syncStatus),
|
||||
'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
|
||||
// games without that data.
|
||||
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.
|
||||
const MAX_EXCLUDES = 100;
|
||||
|
||||
@@ -139,13 +139,13 @@ export default function PlayNextPage() {
|
||||
{error && <p className="text-sm text-destructive">{t(error)}</p>}
|
||||
|
||||
{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"/>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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}/>)}
|
||||
</div>
|
||||
)}
|
||||
@@ -186,6 +186,7 @@ const reasonText = (reason, t, language) => {
|
||||
if (reason.source === 'LONGEST') {
|
||||
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');
|
||||
};
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('PlayNextPage', () => {
|
||||
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)]]);
|
||||
|
||||
renderPage();
|
||||
@@ -51,11 +51,19 @@ describe('PlayNextPage', () => {
|
||||
expect(await screen.findByText('Game 1')).toBeTruthy();
|
||||
expect(screen.getByText('Game 3')).toBeTruthy();
|
||||
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('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 () => {
|
||||
mockBackend([[game(1)], [game(2)]]);
|
||||
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},
|
||||
MOST_PLAYED: {S: 4, M: 8, L: 12},
|
||||
PLAYING: {S: 3, M: 6, L: 12},
|
||||
UP_NEXT: {S: 3, M: 6, L: 12},
|
||||
PILE_OF_SHAME: {M: 6, L: 12},
|
||||
ACTIVITY: {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 = {
|
||||
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',
|
||||
STATS_OVERVIEW: 'STATS.OVERVIEW.TITLE', ...STAT_TITLES,
|
||||
};
|
||||
@@ -54,13 +56,14 @@ export const PROFILE_TITLES = {
|
||||
|
||||
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 SIZES = ['S', 'M', 'L'];
|
||||
// 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 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.
|
||||
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', () => {
|
||||
it('halves and doubles the normal width', () => {
|
||||
expect(['S', 'M', 'L'].map((size) => spanClass(DASHBOARD_TILES, {id: 'MOST_PLAYED', size})))
|
||||
|
||||
Reference in new issue
Block a user