Added more details and a landing page
Build, Test & Push Frontend / quality-check (push) Successful in 6s
Build, Test & Push Frontend / docker (push) Successful in 26s

This commit is contained in:
Andreas Dahm committed 2026-05-04 15:13:24 +02:00
1 parent 950f7d2b80
commit 38976e3240
8 files changed
+654 -40

No files matched your search

+123 -38
View File
@@ -1,11 +1,18 @@
import {useCallback, useEffect, useState} from 'react';
import {useNavigate, useSearchParams} from 'react-router-dom';
import {Link, useNavigate, useSearchParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import useLibrarySync from '../hooks/useLibrarySync.js';
import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
const PAGE_SIZES = [20, 40, 80];
const DEFAULT_SIZE = 20;
const DEFAULT_SORT = 'playtimeMinutes,desc';
const SORT_OPTIONS = [
{value: 'playtimeMinutes,desc', labelKey: 'LIBRARY.SORT_PLAYTIME_DESC'},
{value: 'playtimeMinutes,asc', labelKey: 'LIBRARY.SORT_PLAYTIME_ASC'},
{value: 'lastSeenAt,desc', labelKey: 'LIBRARY.SORT_LAST_SEEN'},
{value: 'firstSeenAt,desc', labelKey: 'LIBRARY.SORT_FIRST_SEEN'},
];
const iconUrl = (appid, hash) =>
hash
@@ -26,6 +33,9 @@ export default function GameLibrary() {
const page = Math.max(0, parseInt(searchParams.get('page') ?? '0', 10) || 0);
const sizeParam = parseInt(searchParams.get('size') ?? String(DEFAULT_SIZE), 10);
const size = PAGE_SIZES.includes(sizeParam) ? sizeParam : DEFAULT_SIZE;
const sortParam = searchParams.get('sort') ?? DEFAULT_SORT;
const sort = SORT_OPTIONS.some((o) => o.value === sortParam) ? sortParam : DEFAULT_SORT;
const librarySearch = searchParams.toString();
const [data, setData] = useState({content: [], page: 0, size, totalElements: 0, totalPages: 0});
const [isLoading, setIsLoading] = useState(true);
@@ -40,7 +50,7 @@ export default function GameLibrary() {
setIsLoading(true);
setError(null);
try {
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?page=${page}&size=${size}`;
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?page=${page}&size=${size}&sort=${encodeURIComponent(sort)}`;
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
@@ -67,7 +77,7 @@ export default function GameLibrary() {
} finally {
setIsLoading(false);
}
}, [navigate, page, size, t]);
}, [navigate, page, size, sort, t]);
useEffect(() => {
load();
@@ -90,11 +100,50 @@ export default function GameLibrary() {
updateParams({size: event.target.value, page: 0});
};
const onSortChange = (event) => {
updateParams({sort: event.target.value, page: 0});
};
const goToPage = (target) => {
if (target < 0 || target >= data.totalPages) return;
updateParams({page: target});
};
const [syncBusy, setSyncBusy] = useState(false);
const [syncMessage, setSyncMessage] = useState(null);
const triggerSync = async () => {
const token = localStorage.getItem('tbp_jwt');
if (!token) {
navigate('/login');
return;
}
setSyncBusy(true);
setSyncMessage(null);
try {
const response = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/sync`, {
method: 'POST',
headers: {'Authorization': `Bearer ${token}`},
});
if (response.status === 429) {
const json = await response.json().catch(() => ({}));
setSyncMessage(t('LIBRARY.SYNC_COOLDOWN', {seconds: json.retryAfterSeconds ?? 60}));
return;
}
if (!response.ok) {
setSyncMessage(t('LIBRARY.SYNC_FAILED'));
return;
}
setSyncMessage(t('LIBRARY.SYNC_STARTED'));
setTimeout(load, 4000);
} catch (err) {
console.error('sync trigger error:', err);
setSyncMessage(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setSyncBusy(false);
}
};
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 p-8">
<div className="max-w-4xl mx-auto space-y-6">
@@ -103,24 +152,53 @@ export default function GameLibrary() {
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
{t('LIBRARY.TITLE')}
</h1>
<SyncStatusBadge
status={sync.status}
lastSyncedAt={sync.lastSyncedAt}
isRunning={sync.isRunning}
/>
<div className="flex items-center gap-3 flex-wrap">
<SyncStatusBadge
status={sync.status}
lastSyncedAt={sync.lastSyncedAt}
isRunning={sync.isRunning}
/>
<button
type="button"
onClick={triggerSync}
disabled={syncBusy || sync.isRunning}
className="inline-flex items-center gap-1 text-xs font-bold text-blue-600 dark:text-blue-400 hover:text-blue-500 disabled:opacity-50"
>
<i className={`mdi mdi-sync ${syncBusy || sync.isRunning ? 'mdi-spin' : ''}`}></i>
{t('LIBRARY.RESYNC')}
</button>
{syncMessage && (
<span className="text-xs text-slate-500 dark:text-slate-400">{syncMessage}</span>
)}
</div>
</div>
<div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
<label htmlFor="pageSize">{t('LIBRARY.PER_PAGE')}</label>
<select
id="pageSize"
value={size}
onChange={onSizeChange}
className="px-3 py-1 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white"
>
{PAGE_SIZES.map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
<div className="flex items-center gap-4 text-sm text-slate-600 dark:text-slate-300 flex-wrap">
<div className="flex items-center gap-2">
<label htmlFor="sort">{t('LIBRARY.SORT_BY')}</label>
<select
id="sort"
value={sort}
onChange={onSortChange}
className="px-3 py-1 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white"
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{t(opt.labelKey)}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label htmlFor="pageSize">{t('LIBRARY.PER_PAGE')}</label>
<select
id="pageSize"
value={size}
onChange={onSizeChange}
className="px-3 py-1 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white"
>
{PAGE_SIZES.map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
</div>
</div>
</div>
@@ -145,24 +223,31 @@ export default function GameLibrary() {
{data.content.map((g) => {
const icon = iconUrl(g.appid, g.imgIconUrl);
return (
<li key={g.appid} className="flex items-center gap-4 px-4 py-3">
{icon ? (
<img
src={icon}
alt=""
className="w-8 h-8 rounded"
loading="lazy"
onError={(e) => { e.currentTarget.style.visibility = 'hidden'; }}
/>
) : (
<div className="w-8 h-8 rounded bg-slate-200 dark:bg-slate-700"/>
)}
<span className="flex-1 text-slate-900 dark:text-white truncate">
{g.name ?? `App ${g.appid}`}
</span>
<span className="text-sm text-slate-500 dark:text-slate-400 tabular-nums">
{formatPlaytime(g.playtimeMinutes)}
</span>
<li key={g.appid}>
<Link
to={`/library/${g.appid}`}
state={{librarySearch}}
className="flex items-center gap-4 px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors"
>
{icon ? (
<img
src={icon}
alt=""
className="w-8 h-8 rounded"
loading="lazy"
onError={(e) => { e.currentTarget.style.visibility = 'hidden'; }}
/>
) : (
<div className="w-8 h-8 rounded bg-slate-200 dark:bg-slate-700"/>
)}
<span className="flex-1 text-slate-900 dark:text-white truncate">
{g.name ?? `App ${g.appid}`}
</span>
<span className="text-sm text-slate-500 dark:text-slate-400 tabular-nums">
{formatPlaytime(g.playtimeMinutes)}
</span>
<i className="mdi mdi-chevron-right text-slate-400 dark:text-slate-500"></i>
</Link>
</li>
);
})}