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
+105
-70
@@ -14,12 +14,16 @@ import Logo from '../components/Logo.jsx';
|
||||
import StatCard from '../components/StatCard.jsx';
|
||||
import {YearBars} from '../components/stats/FinishedStats.jsx';
|
||||
import {TagBars} from '../components/stats/TasteStats.jsx';
|
||||
import {StatDrillProvider, useStatDrill, useTagDrill} from '../components/stats/StatDrill.jsx';
|
||||
import useSession from '../hooks/useSession.js';
|
||||
import {genreKey} from '../utils/stats.js';
|
||||
import {gameHref} from '../utils/statGames.js';
|
||||
|
||||
import usePublicStats from '../hooks/usePublicStats.js';
|
||||
|
||||
function RankedGames({title, games, formatValue}) {
|
||||
function RankedGames({title, games, formatValue, drillKey}) {
|
||||
const {t} = useTranslation();
|
||||
const drill = useStatDrill();
|
||||
return (
|
||||
<Card className="gap-0 py-0">
|
||||
<h3 className="flex items-center gap-2 border-b px-5 py-4 font-semibold">
|
||||
@@ -31,15 +35,58 @@ function RankedGames({title, games, formatValue}) {
|
||||
<li key={g.appid} className="flex items-center gap-3 px-5 py-3">
|
||||
<span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">{idx + 1}</span>
|
||||
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 shrink-0"/>
|
||||
<span className="@container min-w-0 flex-1 font-medium"><FitText text={g.name ?? `App ${g.appid}`}/></span>
|
||||
<GameName game={g}/>
|
||||
<span className="text-sm text-muted-foreground tabular-nums">{formatValue(g.value ?? 0)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{drill && (
|
||||
<Button variant="ghost" className="m-2 self-start max-sm:min-h-11"
|
||||
onClick={() => drill.open(title, {key: drillKey})}>
|
||||
{t('STATS.DRILL.SHOW_ALL')}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function GameName({game}) {
|
||||
const href = gameHref(game.appid, false);
|
||||
const name = <FitText text={game.name ?? `App ${game.appid}`}/>;
|
||||
return (
|
||||
<span className="@container min-w-0 flex-1 font-medium">
|
||||
{href ? <a href={href} target="_blank" rel="noopener noreferrer" className="hover:text-primary">{name}</a> : name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CommunityBars({core}) {
|
||||
const {t} = useTranslation();
|
||||
const drill = useStatDrill();
|
||||
const select = useTagDrill();
|
||||
const genreLabel = (name) => t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name});
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{core?.gamesPerPlatform?.length > 0 && (
|
||||
<BarsCard title={t('LANDING.PLATFORMS')}>
|
||||
<TagBars tags={core.gamesPerPlatform} onSelect={select('PLATFORM')}/>
|
||||
</BarsCard>
|
||||
)}
|
||||
{core?.topGenres?.length > 0 && (
|
||||
<BarsCard title={t('LANDING.TOP_GENRES')}>
|
||||
<TagBars tags={core.topGenres} label={genreLabel} onSelect={select('GENRE')}/>
|
||||
</BarsCard>
|
||||
)}
|
||||
{core?.finishedPerYear?.length > 0 && (
|
||||
<BarsCard title={t('LANDING.FINISHED_PER_YEAR')}>
|
||||
<YearBars perYear={core.finishedPerYear}
|
||||
onSelect={drill && ((year) => drill.open(`${t('LANDING.FINISHED_PER_YEAR')}: ${year}`, {key: 'FINISHED_YEAR', arg: year}))}/>
|
||||
</BarsCard>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BarsCard({title, children}) {
|
||||
return (
|
||||
<Card className="gap-0 py-0">
|
||||
@@ -60,81 +107,69 @@ export default function LandingPage() {
|
||||
const stats = [
|
||||
[t('LANDING.TOTAL_USERS'), auth?.totalUsers, Users],
|
||||
[t('LANDING.STEAM_LINKED'), auth?.usersWithSteamLinked, Link2],
|
||||
[t('LANDING.TOTAL_GAMES'), core?.totalLibraryEntries, Gamepad2],
|
||||
[t('LANDING.GAMES_PLAYED'), core?.totalGamesPlayed, Sparkles],
|
||||
[t('LANDING.DISTINCT_GAMES'), core?.totalDistinctGames, Blocks],
|
||||
[t('LANDING.TOTAL_GAMES'), core?.totalLibraryEntries, Gamepad2, undefined, 'ENTRIES'],
|
||||
[t('LANDING.GAMES_PLAYED'), core?.totalGamesPlayed, Sparkles, undefined, 'PLAYED'],
|
||||
[t('LANDING.DISTINCT_GAMES'), core?.totalDistinctGames, Blocks, undefined, 'OWNED'],
|
||||
[t('LANDING.TOTAL_PLAYTIME_DAYS'), core?.totalPlaytimeDays, Clock, ` ${t('LANDING.DAYS_SUFFIX')}`],
|
||||
[t('LANDING.BIGGEST_LIBRARY'), core?.biggestLibrary, Library],
|
||||
[t('LANDING.BIGGEST_BACKLOG'), core?.biggestBacklog, Archive],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grow">
|
||||
<section className="relative overflow-hidden border-b">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-primary/15 via-primary/5 to-transparent"/>
|
||||
<div className="relative mx-auto max-w-3xl px-4 py-20 text-center md:py-28">
|
||||
<Badge variant="outline" className="mb-6 gap-1.5 bg-background/60">
|
||||
<span className="flex"><Logo className="h-4 w-auto"/></span> ToBePlayed
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold tracking-tight md:text-6xl">{t('MAIN.WELCOME')}</h1>
|
||||
<p className="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">{t('MAIN.DESCRIPTION')}</p>
|
||||
<Button asChild size="lg" className="mt-10">
|
||||
{active ? (
|
||||
<Link to="/dashboard">{t('NAV.TO_DASHBOARD')}<ArrowRight/></Link>
|
||||
) : (
|
||||
<Link to="/login">{t('MAIN.GET_STARTED')}<ArrowRight/></Link>
|
||||
<StatDrillProvider source="community">
|
||||
<div className="grow">
|
||||
<section className="relative overflow-hidden border-b">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-primary/15 via-primary/5 to-transparent"/>
|
||||
<div className="relative mx-auto max-w-3xl px-4 py-20 text-center md:py-28">
|
||||
<Badge variant="outline" className="mb-6 gap-1.5 bg-background/60">
|
||||
<span className="flex"><Logo className="h-4 w-auto"/></span> ToBePlayed
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold tracking-tight md:text-6xl">{t('MAIN.WELCOME')}</h1>
|
||||
<p className="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">{t('MAIN.DESCRIPTION')}</p>
|
||||
<Button asChild size="lg" className="mt-10">
|
||||
{active ? (
|
||||
<Link to="/dashboard">{t('NAV.TO_DASHBOARD')}<ArrowRight/></Link>
|
||||
) : (
|
||||
<Link to="/login">{t('MAIN.GET_STARTED')}<ArrowRight/></Link>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto w-full max-w-5xl space-y-8 px-4 py-12 md:px-8 md:py-16">
|
||||
<h2 className="text-center text-2xl font-bold tracking-tight">{t('LANDING.STATS_TITLE')}</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{isLoading
|
||||
? stats.map(([label]) => <Skeleton key={label} className="h-36 rounded-xl"/>)
|
||||
: stats.map(([label, value, icon, suffix, key]) => (
|
||||
<StatCard key={label} label={label} value={value ?? 0} icon={icon} suffix={suffix}
|
||||
drill={key && {params: {key}}}/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{core?.topPlayedGames?.length > 0 && (
|
||||
<RankedGames
|
||||
title={t('LANDING.TOP_PLAYED')}
|
||||
games={core.topPlayedGames}
|
||||
formatValue={(minutes) => `${Math.round(minutes / 60).toLocaleString(i18n.language)}h`}
|
||||
drillKey="MOST_PLAYED"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
{core?.topOwnedGames?.length > 0 && (
|
||||
<RankedGames
|
||||
title={t('LANDING.TOP_OWNED')}
|
||||
games={core.topOwnedGames}
|
||||
formatValue={(count) => count.toLocaleString(i18n.language)}
|
||||
drillKey="OWNED"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="mx-auto w-full max-w-5xl space-y-8 px-4 py-12 md:px-8 md:py-16">
|
||||
<h2 className="text-center text-2xl font-bold tracking-tight">{t('LANDING.STATS_TITLE')}</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{isLoading
|
||||
? stats.map(([label]) => <Skeleton key={label} className="h-36 rounded-xl"/>)
|
||||
: stats.map(([label, value, icon, suffix]) => (
|
||||
<StatCard key={label} label={label} value={value ?? 0} icon={icon} suffix={suffix}/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{core?.topPlayedGames?.length > 0 && (
|
||||
<RankedGames
|
||||
title={t('LANDING.TOP_PLAYED')}
|
||||
games={core.topPlayedGames}
|
||||
formatValue={(minutes) => `${Math.round(minutes / 60).toLocaleString(i18n.language)}h`}
|
||||
/>
|
||||
)}
|
||||
{core?.topOwnedGames?.length > 0 && (
|
||||
<RankedGames
|
||||
title={t('LANDING.TOP_OWNED')}
|
||||
games={core.topOwnedGames}
|
||||
formatValue={(count) => count.toLocaleString(i18n.language)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{core?.gamesPerPlatform?.length > 0 && (
|
||||
<BarsCard title={t('LANDING.PLATFORMS')}>
|
||||
<TagBars tags={core.gamesPerPlatform}/>
|
||||
</BarsCard>
|
||||
)}
|
||||
{core?.topGenres?.length > 0 && (
|
||||
<BarsCard title={t('LANDING.TOP_GENRES')}>
|
||||
<TagBars tags={core.topGenres}
|
||||
label={(name) => t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name})}/>
|
||||
</BarsCard>
|
||||
)}
|
||||
{core?.finishedPerYear?.length > 0 && (
|
||||
<BarsCard title={t('LANDING.FINISHED_PER_YEAR')}>
|
||||
<YearBars perYear={core.finishedPerYear}/>
|
||||
</BarsCard>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<CommunityBars core={core}/>
|
||||
</section>
|
||||
</div>
|
||||
</StatDrillProvider>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user