Public Access
feat(ui): make stat numbers clickable to open games
StatDrill context holds one dialog for a page. StatCount wraps a number: total and status counts link to the library filter, other numbers open StatGamesDialog. DrillRow and DrillButton support drill-down in tag bars (genres, platforms, moods) and other stat displays. Dialog shows 40 games per page with bottom-sheet layout on phones, handles pagination and errors (403 not shared, 401 expired, 400 bad argument).
This commit is contained in:
1 parent
ed5df8c40d
commit
777ba87fd6
31 files changed
+1026
-261
No files matched your search
+95
-79
@@ -12,6 +12,7 @@ import {cn} from '@/lib/utils';
|
||||
import EffectBackground from '../components/EffectBackground.jsx';
|
||||
import FitText from '../components/FitText.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {StatCount, StatDrillProvider, useDrillTarget} from '../components/stats/StatDrill.jsx';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import useInView from '../hooks/useInView.js';
|
||||
import {formatDate} from '../utils/format.js';
|
||||
@@ -60,95 +61,103 @@ export default function PointsPage() {
|
||||
);
|
||||
}
|
||||
if (['BUY', 'UNLOCK'].includes(entry.kind)) return t(`POINTS.ITEMS.${entry.ref}`);
|
||||
if (['MONTH', 'STREAK'].includes(entry.kind)) return entry.ref;
|
||||
if (['MONTH', 'STREAK'].includes(entry.kind)) {
|
||||
return (
|
||||
<StatCount params={{kind: entry.kind, ref: entry.ref}} title={`${t(`POINTS.KINDS.${entry.kind}`)} ${entry.ref}`}>
|
||||
{entry.ref}
|
||||
</StatCount>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('POINTS.TITLE')}</h1>
|
||||
<p className="mt-2 flex items-center gap-2 text-lg">
|
||||
<Coins className="size-5 text-primary"/>
|
||||
{t('POINTS.BALANCE')}: <span className="font-bold tabular-nums">{wallet.balance}</span>
|
||||
</p>
|
||||
</div>
|
||||
<StatDrillProvider source="points">
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('POINTS.TITLE')}</h1>
|
||||
<p className="mt-2 flex items-center gap-2 text-lg">
|
||||
<Coins className="size-5 text-primary"/>
|
||||
{t('POINTS.BALANCE')}: <span className="font-bold tabular-nums">{wallet.balance}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="gap-2 p-5">
|
||||
<h2 className="font-semibold">{t('POINTS.HOW_TO')}</h2>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
|
||||
{HOW_TO.map((kind) => <li key={kind}>{t(`POINTS.RULES.${kind}`)}</li>)}
|
||||
</ul>
|
||||
</Card>
|
||||
<Card className="gap-2 p-5">
|
||||
<h2 className="font-semibold">{t('POINTS.HOW_TO')}</h2>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
|
||||
{HOW_TO.map((kind) => <li key={kind}>{t(`POINTS.RULES.${kind}`)}</li>)}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">{t('POINTS.SHOP')}</h2>
|
||||
{SLOTS.map((slot) => (
|
||||
<Card key={slot} className="gap-3 p-5">
|
||||
<h3 className="font-medium">{t(`POINTS.SLOTS.${slot}`)}</h3>
|
||||
<ul className="divide-y">
|
||||
{wallet.catalog.filter((item) => item.slot === slot).map((item) => {
|
||||
const owned = wallet.owned.includes(item.code);
|
||||
const worn = wallet.equipped[slot] === item.code;
|
||||
const locked = !owned && item.track;
|
||||
return (
|
||||
<li key={item.code} className="flex items-center gap-3 py-3">
|
||||
<Preview code={item.code}/>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{t(`POINTS.ITEMS.${item.code}`)}</span>
|
||||
{worn && <Badge variant="secondary">{t('POINTS.EQUIPPED')}</Badge>}
|
||||
{owned ? (
|
||||
<Button size="sm" variant="outline"
|
||||
onClick={() => act(() => equipItem(slot, worn ? null : item.code))}>
|
||||
{t(worn ? 'POINTS.UNEQUIP' : 'POINTS.EQUIP')}
|
||||
</Button>
|
||||
) : locked ? (
|
||||
<LockedProgress item={item} progress={wallet.progress?.[item.track] ?? 0}/>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm tabular-nums text-muted-foreground">{item.price}</span>
|
||||
<Button size="sm" disabled={wallet.balance < item.price}
|
||||
onClick={() => act(() => buyItem(item.code))}>
|
||||
{t('POINTS.BUY')}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">{t('POINTS.SHOP')}</h2>
|
||||
{SLOTS.map((slot) => (
|
||||
<Card key={slot} className="gap-3 p-5">
|
||||
<h3 className="font-medium">{t(`POINTS.SLOTS.${slot}`)}</h3>
|
||||
<ul className="divide-y">
|
||||
{wallet.catalog.filter((item) => item.slot === slot).map((item) => {
|
||||
const owned = wallet.owned.includes(item.code);
|
||||
const worn = wallet.equipped[slot] === item.code;
|
||||
const locked = !owned && item.track;
|
||||
return (
|
||||
<li key={item.code} className="flex items-center gap-3 py-3">
|
||||
<Preview code={item.code}/>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{t(`POINTS.ITEMS.${item.code}`)}</span>
|
||||
{worn && <Badge variant="secondary">{t('POINTS.EQUIPPED')}</Badge>}
|
||||
{owned ? (
|
||||
<Button size="sm" variant="outline"
|
||||
onClick={() => act(() => equipItem(slot, worn ? null : item.code))}>
|
||||
{t(worn ? 'POINTS.UNEQUIP' : 'POINTS.EQUIP')}
|
||||
</Button>
|
||||
</>
|
||||
) : locked ? (
|
||||
<LockedProgress item={item} progress={wallet.progress?.[item.track] ?? 0}/>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm tabular-nums text-muted-foreground">{item.price}</span>
|
||||
<Button size="sm" disabled={wallet.balance < item.price}
|
||||
onClick={() => act(() => buyItem(item.code))}>
|
||||
{t('POINTS.BUY')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<Card className="gap-3 p-5">
|
||||
<h2 className="text-xl font-semibold">{t('POINTS.HISTORY')}</h2>
|
||||
{wallet.history.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('POINTS.EMPTY_HISTORY')}</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{wallet.history.map((entry) => {
|
||||
const label = entryLabel(entry);
|
||||
return (
|
||||
<li key={`${entry.kind}-${entry.ref}-${entry.eventAt}`} className="@container flex items-center gap-3 py-2 text-sm">
|
||||
<span className="w-24 shrink-0 text-muted-foreground tabular-nums">
|
||||
{formatDate(entry.eventAt, i18n.language)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 gap-1">
|
||||
<span className="min-w-0 truncate">{t(`POINTS.KINDS.${entry.kind}`)}</span>
|
||||
{label && <span className="min-w-0 truncate">{label}</span>}
|
||||
</span>
|
||||
{entry.points !== 0 && (
|
||||
<span className={cn('font-semibold tabular-nums', entry.points < 0 && 'text-muted-foreground')}>
|
||||
{entry.points > 0 ? `+${entry.points}` : entry.points}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<Card className="gap-3 p-5">
|
||||
<h2 className="text-xl font-semibold">{t('POINTS.HISTORY')}</h2>
|
||||
{wallet.history.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('POINTS.EMPTY_HISTORY')}</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{wallet.history.map((entry) => {
|
||||
const label = entryLabel(entry);
|
||||
return (
|
||||
<li key={`${entry.kind}-${entry.ref}-${entry.eventAt}`} className="@container flex items-center gap-3 py-2 text-sm">
|
||||
<span className="w-24 shrink-0 text-muted-foreground tabular-nums">
|
||||
{formatDate(entry.eventAt, i18n.language)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 gap-1">
|
||||
<span className="min-w-0 truncate">{t(`POINTS.KINDS.${entry.kind}`)}</span>
|
||||
{label && <span className="min-w-0 truncate">{label}</span>}
|
||||
</span>
|
||||
{entry.points !== 0 && (
|
||||
<span className={cn('font-semibold tabular-nums', entry.points < 0 && 'text-muted-foreground')}>
|
||||
{entry.points > 0 ? `+${entry.points}` : entry.points}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</PageContainer>
|
||||
)}
|
||||
</Card>
|
||||
</PageContainer>
|
||||
</StatDrillProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -192,8 +201,15 @@ function LockedProgress({item, progress}) {
|
||||
const {t} = useTranslation();
|
||||
const count = Math.min(progress, item.target);
|
||||
const text = t(`POINTS.PROGRESS.${item.track}`, {count, target: item.target});
|
||||
// Playtime months compare two numbers: no list of games shows them.
|
||||
const target = useDrillTarget({
|
||||
params: item.track === 'PLAY_MORE_MONTHS' ? null : {track: item.track}, title: t(`POINTS.ITEMS.${item.code}`),
|
||||
});
|
||||
const className = 'flex w-40 shrink-0 flex-col gap-1 text-xs text-muted-foreground';
|
||||
const Wrapper = target ? 'button' : 'div';
|
||||
return (
|
||||
<div className="flex w-40 shrink-0 flex-col gap-1 text-xs text-muted-foreground">
|
||||
<Wrapper {...(target && {type: 'button', onClick: target.onClick})}
|
||||
className={cn(className, target && 'min-h-11 text-left hover:text-primary focus-visible:ring-2 focus-visible:ring-ring rounded-sm')}>
|
||||
<span className="flex items-center gap-1">
|
||||
<LockKeyhole className="size-3 shrink-0"/>
|
||||
<span title={text}>{text}</span>
|
||||
@@ -202,6 +218,6 @@ function LockedProgress({item, progress}) {
|
||||
aria-valuemax={item.target} className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full bg-primary" style={{width: `${(count / item.target) * 100}%`}}/>
|
||||
</div>
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user