Public Access
feat(achievements): dashboard card, library badge, 100 % hint and profile stats
This commit is contained in:
1 parent
2efdf64675
commit
085fcf15a4
14 files changed
+369
-19
No files matched your search
@@ -0,0 +1,87 @@
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Trophy} from 'lucide-react';
|
||||
|
||||
import {Card} from '@/components/ui/card';
|
||||
import GameCover from './GameCover.jsx';
|
||||
|
||||
/**
|
||||
* @param summary the answer of GET /api/library/achievements/summary
|
||||
*/
|
||||
export default function AchievementsCard({summary}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
const closest = summary.closest ?? [];
|
||||
const started = summary.startedGames ?? 0;
|
||||
const pending = summary.pendingGames ?? 0;
|
||||
const hidden = summary.hiddenGames ?? 0;
|
||||
// A private profile keeps games pending; the privacy hint explains that better than the pending count.
|
||||
const steamHidesAll = hidden > 0 && started === 0;
|
||||
|
||||
const stats = [
|
||||
[(summary.unlocked ?? 0).toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.UNLOCKED')],
|
||||
[(summary.perfectGames ?? 0).toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.PERFECT')],
|
||||
[summary.averageCompletion != null ? `${summary.averageCompletion.toLocaleString(i18n.language)} %` : '–',
|
||||
t('DASHBOARD.ACHIEVEMENTS.AVERAGE')],
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="gap-5 p-5">
|
||||
<h2 className="flex items-center gap-2 font-semibold">
|
||||
<Trophy className="size-4 text-amber-500"/>
|
||||
{t('DASHBOARD.ACHIEVEMENTS.TITLE')}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{stats.map(([value, label]) => (
|
||||
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
|
||||
<div className="text-xl md:text-2xl font-bold tabular-nums">{value}</div>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{started === 0 && pending === 0 && hidden === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('DASHBOARD.ACHIEVEMENTS.NONE')}</p>
|
||||
)}
|
||||
|
||||
{closest.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">{t('DASHBOARD.ACHIEVEMENTS.CLOSEST')}</h3>
|
||||
<ul className="space-y-2">
|
||||
{closest.map((game) => (
|
||||
<li key={game.appid}>
|
||||
<Link
|
||||
to={`/library/${game.appid}`}
|
||||
className="group flex items-center gap-3 rounded-lg p-1 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<GameCover appid={game.appid} className="w-16 shrink-0"/>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium group-hover:text-primary transition-colors">
|
||||
{game.name ?? `App ${game.appid}`}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||
{game.unlocked} / {game.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-amber-500"
|
||||
style={{width: `${(game.unlocked / game.total) * 100}%`}}/>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pending > 0 && !steamHidesAll && (
|
||||
<p className="text-xs text-muted-foreground">{t('DASHBOARD.ACHIEVEMENTS.PENDING', {count: pending})}</p>
|
||||
)}
|
||||
{steamHidesAll && (
|
||||
<p className="text-xs text-muted-foreground">{t('DASHBOARD.ACHIEVEMENTS.HIDDEN')}</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user