feat(follow): add feed links, following page and tile (#52, #51)
Build, Test & Push Frontend / quality-check (push) Successful in 37s
Build, Test & Push Frontend / docker (push) Successful in 1m23s

Feed links go to the game and to the profile view of that game.
Add /following page, nav item and an opt-in Following tile.
Games not in your library show a catalog view with an add button.
This commit is contained in:
lobo committed 2026-10-14 16:23:11 +02:00
1 parent 720764bf68
commit 5be0d42bca
31 files changed
+692 -67

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Trophy} from 'lucide-react';
import {Card} from '@/components/ui/card';
import FitText from './FitText.jsx';
import GameCover from './GameCover.jsx';
import GameStatusBadge from './GameStatusBadge.jsx';
import {formatPlaytime} from '../utils/games.js';
import {fetchProfileGame} from '../utils/people.js';
/**
* One game of a public profile, above its tiles. Shows what the profile shares of it; a game the user hides or does
* not have gets one message.
*/
export default function ProfileGameCard({ticket, appid}) {
const {t, i18n} = useTranslation();
const [game, setGame] = useState(null);
const [missing, setMissing] = useState(false);
useEffect(() => {
let cancelled = false;
setGame(null);
setMissing(false);
(async () => {
const {ok, data} = await fetchProfileGame(ticket, appid);
if (cancelled) return;
if (ok) setGame(data);
else setMissing(true);
})();
return () => {
cancelled = true;
};
}, [ticket, appid]);
if (missing) {
return <Card className="p-5 text-sm text-muted-foreground">{t('PROFILE.GAME_NOT_SHOWN')}</Card>;
}
if (!game) return null;
return (
<Card className="gap-4 p-5 sm:flex-row sm:items-center">
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-full sm:w-56 sm:shrink-0"/>
<div className="min-w-0 flex-1 space-y-2">
<div className="@container text-lg font-semibold"><FitText text={game.name ?? `App ${game.appid}`}/></div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
{game.status && <GameStatusBadge status={game.status}/>}
{game.playtimeMinutes != null && <span className="tabular-nums">{formatPlaytime(game.playtimeMinutes)}</span>}
{game.achievementsTotal != null && (
<span className="flex items-center gap-1 tabular-nums">
<Trophy className="size-4 text-primary"/>
{t('PROFILE.GAME_ACHIEVEMENTS', {
unlocked: game.achievementsUnlocked,
total: game.achievementsTotal,
percent: game.completionPercent.toLocaleString(i18n.language),
})}
</span>
)}
</div>
</div>
</Card>
);
}