Public Access
Added more details and a landing page
This commit is contained in:
1 parent
950f7d2b80
commit
38976e3240
8 files changed
+654
-40
No files matched your search
+123
-38
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in new issue
Block a user