Public Access
feat(library): time to beat, short-games filter and backlog hours
This commit is contained in:
1 parent
fd343da4be
commit
91d5672421
11 files changed
+131
-11
No files matched your search
@@ -12,6 +12,7 @@ import BacklogCard from '../components/BacklogCard.jsx';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||
import PlayingHint from '../components/PlayingHint.jsx';
|
||||
import TimeToBeat from '../components/TimeToBeat.jsx';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
|
||||
@@ -134,6 +135,9 @@ export default function GameDetail() {
|
||||
</Badge>
|
||||
<GameStatusBadge status={data.status}/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<TimeToBeat time={data.timeToBeat}/>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm" className="self-start">
|
||||
<a href={`https://store.steampowered.com/app/${data.appid}`} target="_blank" rel="noopener noreferrer">
|
||||
|
||||
@@ -7,7 +7,7 @@ import useForceSync from '../hooks/useForceSync.js';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import {GAME_STATUSES, libraryQuery, parseStatus} from '../utils/backlog.js';
|
||||
import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, Library, RefreshCw, Search, SearchX} from 'lucide-react';
|
||||
import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, Library, RefreshCw, Search, SearchX, Timer} from 'lucide-react';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
@@ -23,6 +23,8 @@ const PAGE_SIZES = [20, 40, 80];
|
||||
const DEFAULT_SIZE = 20;
|
||||
const DEFAULT_SORT = 'playtimeMinutes,desc';
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
// "Short" games: IGDB's normal time to beat of at most 10 hours.
|
||||
const SHORT_GAME_MINUTES = 600;
|
||||
const SORT_OPTIONS = [
|
||||
{value: 'playtimeMinutes,desc', labelKey: 'LIBRARY.SORT_PLAYTIME_DESC'},
|
||||
{value: 'playtimeMinutes,asc', labelKey: 'LIBRARY.SORT_PLAYTIME_ASC'},
|
||||
@@ -45,7 +47,8 @@ export default function GameLibrary() {
|
||||
const sort = SORT_OPTIONS.some((o) => o.value === sortParam) ? sortParam : DEFAULT_SORT;
|
||||
const status = parseStatus(searchParams.get('status'));
|
||||
const q = searchParams.get('q') ?? '';
|
||||
const filtered = Boolean(status || q.trim());
|
||||
const shortOnly = searchParams.get('maxMinutes') === String(SHORT_GAME_MINUTES);
|
||||
const filtered = Boolean(status || q.trim() || shortOnly);
|
||||
const librarySearch = searchParams.toString();
|
||||
const [search, setSearch] = useState(q);
|
||||
|
||||
@@ -62,7 +65,7 @@ export default function GameLibrary() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status, q})}`;
|
||||
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status, q, maxMinutes: shortOnly ? SHORT_GAME_MINUTES : null})}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
@@ -88,7 +91,7 @@ export default function GameLibrary() {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [navigate, expireSession, page, size, sort, status, q, t]);
|
||||
}, [navigate, expireSession, page, size, sort, status, q, shortOnly, t]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -199,6 +202,15 @@ export default function GameLibrary() {
|
||||
{s ? t(`BACKLOG.STATUS.${s}`) : t('LIBRARY.FILTER_ALL')}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={shortOnly ? 'default' : 'outline'}
|
||||
aria-pressed={shortOnly}
|
||||
className="h-7 rounded-full px-3 text-xs"
|
||||
onClick={() => updateParams({maxMinutes: shortOnly ? null : SHORT_GAME_MINUTES, page: 0})}
|
||||
>
|
||||
<Timer className="size-3"/>{t('LIBRARY.FILTER_SHORT')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative sm:w-64">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
|
||||
|
||||
@@ -102,3 +102,16 @@ describe('GameLibrary sort', () => {
|
||||
expect(new URL(fetch.mock.calls[0][0], 'http://x').searchParams.get('sort')).toBe(sort);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GameLibrary short games', () => {
|
||||
it('filters by time to beat and keeps the chip state in the URL', async () => {
|
||||
const lib = renderLibrary();
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'LIBRARY.FILTER_SHORT'}));
|
||||
|
||||
await waitFor(() => expect(lib.params().get('maxMinutes')).toBe('600'));
|
||||
await waitFor(() => expect(fetch.mock.calls.at(-1)[0]).toMatch(/[?&]maxMinutes=600(&|$)/));
|
||||
expect(screen.getByRole('button', {name: 'LIBRARY.FILTER_SHORT'}).getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user