Public Access
feat(dashboard): currently playing and pile of shame tiles
Pile of Shame shows the backlog count and share, a status bar and a random backlog pick. Both tiles show the first-sync text while the initial sync runs; a failed summary load shows a retry button.
This commit is contained in:
1 parent
f94e0aff15
commit
c24c22d5ed
8 files changed
+262
-11
No files matched your search
@@ -0,0 +1,47 @@
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {ArrowRight, Gamepad2} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import GameCover from './GameCover.jsx';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
|
||||
export default function CurrentlyPlayingCard({games, syncing}) {
|
||||
const {t} = useTranslation();
|
||||
return (
|
||||
<Card className="gap-0 py-0">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b">
|
||||
<h2 className="flex items-center gap-2 font-semibold">
|
||||
<Gamepad2 className="size-4 text-primary"/>
|
||||
{t('DASHBOARD.CURRENTLY_PLAYING')}
|
||||
</h2>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/library?status=PLAYING">{t('DASHBOARD.SHOW_ALL')}<ArrowRight/></Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{games.length === 0 ? (
|
||||
<p className="px-5 py-10 text-center text-sm text-muted-foreground">
|
||||
{syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('DASHBOARD.NOTHING_PLAYING')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="grid grid-cols-2 gap-3 p-4">
|
||||
{games.map((g) => (
|
||||
<li key={g.appid}>
|
||||
<Link to={`/library/${g.appid}`} className="group block space-y-1.5">
|
||||
<GameCover appid={g.appid} className="w-full transition-opacity group-hover:opacity-80"/>
|
||||
<div className="truncate text-sm font-medium group-hover:text-primary transition-colors">
|
||||
{g.name ?? `App ${g.appid}`}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground tabular-nums">
|
||||
{formatPlaytime(g.playtimeMinutes)}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -5,11 +5,11 @@ import {Badge} from '@/components/ui/badge';
|
||||
import {cn} from '@/lib/utils';
|
||||
|
||||
export const STATUS_META = {
|
||||
BACKLOG: {icon: Inbox, text: 'text-muted-foreground', badge: 'bg-secondary text-secondary-foreground'},
|
||||
PLAYING: {icon: Gamepad2, text: 'text-primary', badge: 'bg-primary/15 text-primary'},
|
||||
FINISHED: {icon: CircleCheck, text: 'text-emerald-600 dark:text-emerald-400', badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'},
|
||||
COMPLETED: {icon: Trophy, text: 'text-amber-600 dark:text-amber-400', badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400'},
|
||||
DROPPED: {icon: Ban, text: 'text-rose-600 dark:text-rose-400', badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400'},
|
||||
BACKLOG: {icon: Inbox, text: 'text-muted-foreground', badge: 'bg-secondary text-secondary-foreground', bar: 'bg-zinc-400 dark:bg-zinc-500'},
|
||||
PLAYING: {icon: Gamepad2, text: 'text-primary', badge: 'bg-primary/15 text-primary', bar: 'bg-primary'},
|
||||
FINISHED: {icon: CircleCheck, text: 'text-emerald-600 dark:text-emerald-400', badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400', bar: 'bg-emerald-500'},
|
||||
COMPLETED: {icon: Trophy, text: 'text-amber-600 dark:text-amber-400', badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400', bar: 'bg-amber-500'},
|
||||
DROPPED: {icon: Ban, text: 'text-rose-600 dark:text-rose-400', badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400', bar: 'bg-rose-500'},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Inbox, Shuffle} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import GameCover from './GameCover.jsx';
|
||||
import {STATUS_META} from './GameStatusBadge.jsx';
|
||||
import {backlogShare, GAME_STATUSES} from '../utils/backlog.js';
|
||||
|
||||
export default function PileOfShameCard({summary, syncing, onShuffle, shuffling}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
const backlog = summary.byStatus?.BACKLOG ?? 0;
|
||||
const suggestion = summary.suggestion;
|
||||
|
||||
if (syncing && summary.total === 0) {
|
||||
return (
|
||||
<Card className="gap-5 p-5">
|
||||
<Title/>
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('LIBRARY.SYNCING_FIRST_TIME')}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="gap-5 p-5">
|
||||
<Title/>
|
||||
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl font-bold tabular-nums">{backlog.toLocaleString(i18n.language)}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('DASHBOARD.PILE_SHARE', {percent: backlogShare(summary)})}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('DASHBOARD.NEVER_PLAYED', {count: summary.neverPlayed})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{summary.total > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex h-2.5 overflow-hidden rounded-full bg-muted">
|
||||
{GAME_STATUSES.map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={STATUS_META[s].bar}
|
||||
style={{width: `${(summary.byStatus[s] / summary.total) * 100}%`}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
{GAME_STATUSES.map((s) => (
|
||||
<li key={s} className="flex items-center gap-1.5">
|
||||
<span className={`size-2 rounded-full ${STATUS_META[s].bar}`}/>
|
||||
{t(`BACKLOG.STATUS.${s}`)}
|
||||
<span className="tabular-nums text-foreground">{summary.byStatus[s]}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{suggestion ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{t('DASHBOARD.SUGGESTION')}</span>
|
||||
{backlog > 1 && (
|
||||
<Button variant="ghost" size="sm" onClick={onShuffle} disabled={shuffling}>
|
||||
<Shuffle/>{t('DASHBOARD.OTHER_SUGGESTION')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to={`/library/${suggestion.appid}`}
|
||||
className="group flex items-center gap-3 rounded-lg border p-2 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<GameCover appid={suggestion.appid} className="w-24 shrink-0"/>
|
||||
<span className="min-w-0 truncate font-medium group-hover:text-primary transition-colors">
|
||||
{suggestion.name ?? `App ${suggestion.appid}`}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t('DASHBOARD.BACKLOG_EMPTY')}</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Title() {
|
||||
const {t} = useTranslation();
|
||||
return (
|
||||
<h2 className="flex items-center gap-2 font-semibold">
|
||||
<Inbox className="size-4 text-rose-500"/>
|
||||
{t('DASHBOARD.PILE_OF_SHAME')}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user