fix(layout): fit names on phones without sideways scroll
Build, Test & Push Frontend / quality-check (push) Successful in 31s
Build, Test & Push Frontend / docker (push) Successful in 24s

Display and game names shrink to 0.875rem, then end with an ellipsis
(FitText + fit-text utility, CSS container units, full text in title).
Rows and buttons wrap at large fonts, header points chip can shrink.
Guard: scripts/check-overflow.js.
This commit is contained in:
lobo committed 2026-10-03 12:18:31 +02:00
1 parent 04387fcd0c
commit d7810fbf94
37 files changed
+263 -79

No files matched your search

+1 -1
View File
@@ -40,7 +40,7 @@ export default function AccountInfoCard() {
</div>
))}
</dl>
<Button asChild variant="link" className="mt-3 h-auto p-0">
<Button asChild variant="link" className="mt-3 h-auto max-w-full whitespace-normal p-0 text-left">
<Link to="/welcome"><Rocket/>{t('SETTINGS.OPEN_WELCOME')}</Link>
</Button>
</CardContent>
+4 -3
View File
@@ -4,6 +4,7 @@ import {Trophy} from 'lucide-react';
import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
/**
* @param summary the answer of GET /api/library/achievements/summary
@@ -55,10 +56,10 @@ export default function AchievementsCard({summary}) {
className="group flex items-center gap-3 rounded-lg p-1 transition-colors hover:bg-muted/50"
>
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-16 shrink-0"/>
<div className="min-w-0 flex-1 space-y-1">
<div className="@container 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 className="min-w-0 text-sm font-medium group-hover:text-primary transition-colors">
<FitText text={game.name ?? `App ${game.appid}`}/>
</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{game.unlocked} / {game.total}
+3 -2
View File
@@ -4,6 +4,7 @@ import {Activity} from 'lucide-react';
import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import {formatPlaytime} from '../utils/games.js';
import {formatDay} from '../utils/format.js';
@@ -64,8 +65,8 @@ export default function ActivityCard({activity}) {
<li key={g.appid}>
<Link to={`/library/${g.appid}`} className="group flex items-center gap-3">
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 shrink-0"/>
<span className="min-w-0 flex-1 truncate text-sm font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`}
<span className="@container min-w-0 flex-1 text-sm font-medium group-hover:text-primary transition-colors">
<FitText text={g.name ?? `App ${g.appid}`}/>
</span>
<span className="text-sm text-muted-foreground tabular-nums">{formatPlaytime(g.playtime2Weeks)}</span>
</Link>
+4 -1
View File
@@ -10,6 +10,7 @@ import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import GameSearch from './GameSearch.jsx';
import useStartImport from '../hooks/useStartImport.js';
import {GAME_STATUSES, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
@@ -122,7 +123,9 @@ function AddForm({hit, favorite, onBack, navigate}) {
<form onSubmit={submit} className="space-y-4">
<div className="flex items-center gap-3">
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-24 shrink-0"/>
<div className="min-w-0 font-medium">{hit.name}{hit.year ? ` (${hit.year})` : ''}</div>
<div className="@container min-w-0 flex-1 font-medium">
<FitText text={`${hit.name}${hit.year ? ` (${hit.year})` : ''}`}/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
+3 -3
View File
@@ -123,14 +123,14 @@ export default function AppHeader({isAuthenticated, onLogout}) {
</nav>
)}
<div className="ml-auto flex items-center gap-1">
<div className="ml-auto flex min-w-0 items-center gap-1">
{/* Below md the badge hangs on the header's bottom edge: in the row it would push the page sideways. */}
{isAuthenticated && viewingAs && (
<Badge variant="secondary" className="max-md:absolute max-md:top-full max-md:left-1/2 max-md:-translate-x-1/2 max-md:-translate-y-1/2 max-md:pointer-events-none max-md:border-border md:mr-1">{t('NAV.VIEW_AS.BADGE', {role: t(`ROLES.${viewingAs}`)})}</Badge>
)}
{isAuthenticated && wallet && (
<Button asChild variant="ghost" size="sm" title={t('POINTS.TITLE')}>
<Link to="/points"><Coins className="text-primary"/><span className="tabular-nums">{wallet.balance}</span></Link>
<Button asChild variant="ghost" size="sm" title={`${t('POINTS.TITLE')}: ${wallet.balance}`} className="min-w-0 shrink">
<Link to="/points"><Coins className="text-primary"/><span className="min-w-0 truncate tabular-nums">{wallet.balance}</span></Link>
</Button>
)}
{isAuthenticated ? (
+1 -1
View File
@@ -31,7 +31,7 @@ describe('AppHeader points chip', () => {
fireEvent.click(screen.getByText('load'));
const chip = await screen.findByTitle('POINTS.TITLE');
const chip = await screen.findByTitle('POINTS.TITLE: 135');
expect(chip.textContent).toBe('135');
expect(chip.getAttribute('href')).toBe('/points');
});
+4 -3
View File
@@ -6,6 +6,7 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import {formatPlaytime} from '../utils/games.js';
/**
@@ -15,7 +16,7 @@ export default function CurrentlyPlayingCard({games, syncing, wide}) {
const {t} = useTranslation();
return (
<Card className="gap-0 py-0">
<div className="flex items-center justify-between px-5 py-4 border-b">
<div className="flex flex-wrap items-center justify-between gap-x-3 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')}
@@ -35,8 +36,8 @@ export default function CurrentlyPlayingCard({games, syncing, wide}) {
<li key={g.appid}>
<Link to={`/library/${g.appid}`} className="group block space-y-1.5">
<GameCover appid={g.appid} imageUrl={g.imageUrl} 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 className="@container text-sm font-medium group-hover:text-primary transition-colors">
<FitText text={g.name ?? `App ${g.appid}`}/>
</div>
<div className="text-xs text-muted-foreground tabular-nums">
{formatPlaytime(g.playtimeMinutes)}
+7 -6
View File
@@ -7,6 +7,7 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import HeroBanner from './HeroBanner.jsx';
import FitText from './FitText.jsx';
import PointsBadge from './PointsBadge.jsx';
import SteamAvatar from './SteamAvatar.jsx';
import SyncStatusBadge from './SyncStatusBadge.jsx';
@@ -24,7 +25,7 @@ export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forc
lookClass(equipped, 'BANNER_FRAME'))}>
{/* A picked game, else the game played most in the last two weeks. */}
<HeroBanner appid={bannerAppid} effect={bannerEffect(equipped)}/>
<div className="relative flex min-w-0 items-center gap-4">
<div className="relative flex min-w-0 items-center gap-4 md:flex-1">
{/* The slot is there before the avatar loads, so the title does not jump. */}
{hasSteamId && (
<span className={cn('flex size-16 shrink-0 items-center justify-center rounded-full bg-muted',
@@ -32,16 +33,16 @@ export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forc
<SteamAvatar url={avatarUrl} className="size-16" iconClassName="size-8 text-muted-foreground"/>
</span>
)}
<div className="min-w-0">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-muted-foreground">{t('DASHBOARD.WELCOME_BACK')}</p>
<h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight truncate">
{t('DASHBOARD.HELLO')} {userData?.displayName ?? userData?.email?.split('@')[0]} 👋
<h1 className="@container mt-1 text-3xl md:text-4xl font-bold tracking-tight">
<FitText text={[t('DASHBOARD.HELLO'), userData?.displayName ?? userData?.email?.split('@')[0], '👋'].filter(Boolean).join(' ')}/>
</h1>
<PointsBadge equipped={equipped} className="mt-2"/>
{hasSteamId ? (
<Badge variant="outline" className="mt-3 gap-1.5 font-mono text-muted-foreground">
<Badge variant="outline" className="mt-3 max-w-full gap-1.5 font-mono text-muted-foreground">
<Hash className="size-3"/>
{userData.steamId}
<span className="min-w-0 truncate">{userData.steamId}</span>
</Badge>
) : (
<Button asChild variant="link" className="mt-2 h-auto p-0">
@@ -52,3 +52,14 @@ describe('DashboardWelcomeCard looks', () => {
.toContain('ring-primary/60');
});
});
describe('DashboardWelcomeCard greeting', () => {
it('greetsWithoutUndefinedBeforeTheUserIsLoaded', () => {
fetchRoutes({'GET /api/library/hero-art': json(200, {})});
renderWithProviders(<DashboardWelcomeCard sync={{}} forceSync={{}} equipped={{}}/>);
const heading = screen.getByRole('heading', {level: 1});
expect(heading.textContent).not.toContain('undefined');
expect(heading.querySelector('[title*="undefined"]')).toBeNull();
});
});
+4 -3
View File
@@ -7,6 +7,7 @@ import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import GameStatusBadge from './GameStatusBadge.jsx';
import FitText from './FitText.jsx';
import {unlinkGame} from '../utils/links.js';
import {platformLabel} from '../utils/platforms.js';
@@ -44,9 +45,9 @@ export default function EditionsCard({game, auditor, onRequest, onChanged}) {
<p className="font-medium">{t('LINKS.ALSO_IN_LIBRARY')}</p>
<ul className="space-y-1">
{game.editions.map((edition) => (
<li key={edition.appid} className="flex flex-wrap items-center gap-2">
<Link to={`/library/${edition.appid}`} className="hover:text-primary hover:underline">
{edition.name}
<li key={edition.appid} className="@container flex flex-wrap items-center gap-2">
<Link to={`/library/${edition.appid}`} className="min-w-0 max-w-full hover:text-primary hover:underline">
<FitText text={edition.name}/>
</Link>
{platformLabel(edition) && (
<span className="text-muted-foreground">({platformLabel(edition)})</span>
+1 -1
View File
@@ -60,7 +60,7 @@ export default function FinishedHint({game, onChanged, onDismissed}) {
<Flag className="size-5 shrink-0 text-emerald-500"/>
<p className="text-sm">{text}</p>
</div>
<div className="flex gap-2 self-end sm:self-auto">
<div className="flex flex-wrap justify-end gap-2">
<Button size="sm" variant="outline" onClick={notYet} disabled={busy}>{t('GAME_DETAIL.NOT_YET')}</Button>
<Button size="sm" onClick={setFinished} disabled={busy}>
{busy && <Loader2 className="animate-spin"/>}
+18
View File
@@ -0,0 +1,18 @@
import {cn} from '@/lib/utils';
const WRAP_ON_DESKTOP = 'md:whitespace-normal md:overflow-visible md:[font-size:1em] md:break-words';
/**
* A name that shrinks to 0.875rem with its length, then ends with "...". The parent sets the font size, so keep
* text-* classes off this element; the nearest `@container` ancestor sets the width. Put @container only on an element
* whose width does not come from its content, or it collapses to 0.
* @param wrapOnDesktop from md up: full size and wrapping, no shrink or "..."
*/
export default function FitText({text, wrapOnDesktop, className}) {
return (
<span className={cn('fit-text', wrapOnDesktop && WRAP_ON_DESKTOP, className)}
style={{'--fit-chars': Math.max(text?.length ?? 0, 1)}} title={text || undefined}>
{text}
</span>
);
}
+25
View File
@@ -0,0 +1,25 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import FitText from './FitText.jsx';
afterEach(cleanup);
describe('FitText', () => {
it('shows the text, the full text as title, and its length for the CSS', () => {
render(<FitText text="Hollow Knight"/>);
const element = screen.getByText('Hollow Knight');
expect(element.title).toBe('Hollow Knight');
expect(element.style.getPropertyValue('--fit-chars')).toBe('13');
expect(element.className).toContain('fit-text');
});
it('renders an empty span without title for missing text', () => {
const {container} = render(<FitText/>);
const element = container.querySelector('span');
expect(element.hasAttribute('title')).toBe(false);
expect(element.style.getPropertyValue('--fit-chars')).toBe('1');
});
});
+3 -2
View File
@@ -8,6 +8,7 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import GameStatusBadge from './GameStatusBadge.jsx';
import ProfileArtMenu from './ProfileArtMenu.jsx';
import TimeToBeat from './TimeToBeat.jsx';
@@ -68,8 +69,8 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
className="w-full rounded-none md:w-96 md:shrink-0"/>
<div className="relative flex flex-1 flex-col justify-between gap-4 p-6">
<div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
{game.name ?? `App ${game.appid}`}
<h1 className="@container text-2xl md:text-3xl font-bold tracking-tight">
<FitText wrapOnDesktop text={game.name ?? `App ${game.appid}`}/>
</h1>
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant="secondary" className="tabular-nums">
+4 -3
View File
@@ -5,6 +5,7 @@ import {Loader2, Search} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Input} from '@/components/ui/input';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import {searchGames} from '../utils/manualGames.js';
import {MAX_GAME_SEARCH_LENGTH, MIN_GAME_SEARCH_LENGTH} from '../utils/limits.js';
@@ -59,9 +60,9 @@ export default function GameSearch({query, onQueryChange, onPick, hide = () => f
<button type="button" disabled={blocked} onClick={() => onPick(hit)}
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-60">
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-20 shrink-0"/>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{hit.name}{hit.year ? ` (${hit.year})` : ''}
<span className="@container min-w-0 flex-1">
<span className="block font-medium">
<FitText text={`${hit.name}${hit.year ? ` (${hit.year})` : ''}`}/>
</span>
<span className="block truncate text-xs text-muted-foreground">
{hit.platforms.join(' · ')}
+4 -4
View File
@@ -12,19 +12,19 @@ const TABS = [
export default function LibraryTabs() {
const {t} = useTranslation();
return (
<nav aria-label={t('LIBRARY.TITLE')} className="inline-flex rounded-lg bg-muted p-1 text-sm">
<nav aria-label={t('LIBRARY.TITLE')} className="inline-flex max-w-full rounded-lg bg-muted p-1 text-sm">
{TABS.map(({to, labelKey, icon: Icon}) => (
<NavLink
key={to}
to={to}
end
className={({isActive}) => cn(
'flex items-center gap-1.5 rounded-md px-3 py-1.5 font-medium transition-colors',
'flex min-w-0 items-center gap-1.5 rounded-md px-3 py-1.5 font-medium transition-colors',
isActive ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
)}
>
<Icon className="size-4"/>
{t(labelKey)}
<Icon className="size-4 shrink-0"/>
<span className="truncate">{t(labelKey)}</span>
</NavLink>
))}
</nav>
+2 -1
View File
@@ -7,6 +7,7 @@ import {Button} from '@/components/ui/button';
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog';
import {Input} from '@/components/ui/input';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import {linkGames, requestLink, searchOwnGames} from '../utils/links.js';
import {MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
import {platformLabel} from '../utils/platforms.js';
@@ -112,7 +113,7 @@ export default function LinkGameDialog({game, open, onOpenChange, auditor, onDon
<button type="button" onClick={() => setPicked(hit)}
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50">
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-16 shrink-0"/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{hit.name}</span>
<span className="@container min-w-0 flex-1 text-sm font-medium"><FitText text={hit.name}/></span>
{platformLabel(hit) && (
<span className="text-xs text-muted-foreground">{platformLabel(hit)}</span>
)}
+5 -4
View File
@@ -5,6 +5,7 @@ import {ArrowRight, Trophy} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import {formatPlaytime} from '../utils/games.js';
import {coverEffect} from '../utils/rewards.js';
@@ -13,7 +14,7 @@ export default function MostPlayedCard({games, syncing, equipped}) {
const maxMinutes = games[0]?.playtimeMinutes || 1;
return (
<Card className="gap-0 py-0">
<div className="flex items-center justify-between px-5 py-4 border-b">
<div className="flex flex-wrap items-center justify-between gap-x-3 px-5 py-4 border-b">
<h2 className="flex items-center gap-2 font-semibold">
<Trophy className="size-4 text-amber-500"/>
{t('DASHBOARD.MOST_PLAYED')}
@@ -40,9 +41,9 @@ export default function MostPlayedCard({games, syncing, equipped}) {
</span>
<GameCover appid={g.appid} imageUrl={g.imageUrl} fx={coverEffect(equipped, g.status)}
className="w-16 sm:w-24 shrink-0"/>
<div className="min-w-0 flex-1">
<div className="truncate font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`}
<div className="@container min-w-0 flex-1">
<div className="font-medium group-hover:text-primary transition-colors">
<FitText text={g.name ?? `App ${g.appid}`}/>
</div>
<div className="mt-2 h-1.5 rounded-full bg-muted">
<div
+4 -2
View File
@@ -73,12 +73,14 @@ export default function SteamAccountCard({steamId, verified, onUnlinked}) {
<p className="text-sm text-muted-foreground">{t('SETTINGS.STEAM_LINK.VERIFY_HINT')}</p>
)}
<div className="flex flex-wrap gap-2">
<Button type="button" variant={steamId && verified ? 'outline' : 'default'} onClick={link} disabled={busy}>
<Button type="button" variant={steamId && verified ? 'outline' : 'default'} onClick={link} disabled={busy}
className="h-auto max-w-full whitespace-normal py-2 text-left">
{busy ? <Loader2 className="animate-spin"/> : <Link2/>}
{t(!steamId ? 'SETTINGS.STEAM_LINK.LINK' : verified ? 'SETTINGS.STEAM_LINK.CHANGE' : 'SETTINGS.STEAM_LINK.VERIFY')}
</Button>
{steamId && (
<Button type="button" variant="outline" onClick={unlink} disabled={busy}>
<Button type="button" variant="outline" onClick={unlink} disabled={busy}
className="h-auto max-w-full whitespace-normal py-2 text-left">
<Unlink/>{t('SETTINGS.STEAM_LINK.UNLINK')}
</Button>
)}
+1 -1
View File
@@ -43,7 +43,7 @@ export default function ImportInput({storeRows, onCreated}) {
return (
<Card className="gap-4 p-5">
<p className="text-sm text-muted-foreground">{t('IMPORT.HINT')}</p>
<div role="group" aria-label={t('IMPORT.SOURCE')} className="flex gap-2">
<div role="group" aria-label={t('IMPORT.SOURCE')} className="flex flex-wrap gap-2">
{['TEXT', 'CSV', 'BOOKMARKLET'].map((value) => (
<Button key={value} size="sm" variant={source === value ? 'default' : 'outline'} aria-pressed={source === value}
onClick={() => setSource(value)}>{t(`IMPORT.SOURCE_${value}`)}</Button>
+2 -1
View File
@@ -1,5 +1,6 @@
import {Link} from 'react-router-dom';
import GameCover from '../GameCover.jsx';
import FitText from '../FitText.jsx';
/**
* A game with its cover and a value on the right; links to the game page only in the user's own library.
@@ -8,7 +9,7 @@ export default function GameRow({game, value, linked = true}) {
const content = (
<>
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-16 shrink-0"/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{game.name ?? `App ${game.appid}`}</span>
<span className="@container min-w-0 flex-1 text-sm font-medium"><FitText text={game.name ?? `App ${game.appid}`}/></span>
<span className="text-sm text-muted-foreground tabular-nums">{value}</span>
</>
);
+8 -7
View File
@@ -18,6 +18,7 @@ import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Textarea} from '@/components/ui/textarea';
import PageContainer from '../components/PageContainer.jsx';
import FitText from '../components/FitText.jsx';
import {fetchUsers} from '../utils/admin.js';
import {fetchBroadcasts, fetchRecipientCount, sendBroadcast, sendTestBroadcast} from '../utils/broadcasts.js';
import {formatDateTime} from '../utils/format.js';
@@ -218,12 +219,12 @@ export default function AdminBroadcastPage() {
{found.length > 0 && (
<ul className="space-y-1">
{found.map((user) => (
<li key={user.id}>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" className="accent-primary"
<li key={user.id} className="@container">
<label className="flex min-w-0 items-center gap-2 text-sm">
<input type="checkbox" className="shrink-0 accent-primary"
checked={picked.some((p) => p.id === user.id)}
onChange={() => toggle(user)}/>
{[user.email, user.displayName].filter(Boolean).join(' · ')}
<FitText className="min-w-0 flex-1" text={[user.email, user.displayName].filter(Boolean).join(' · ')}/>
</label>
</li>
))}
@@ -235,9 +236,9 @@ export default function AdminBroadcastPage() {
{picked.length > 0 && (
<ul className="flex flex-wrap gap-2">
{picked.map((user) => (
<li key={user.id} className="flex items-center gap-1 rounded-md border px-2 py-1 text-sm">
{user.email}
<button type="button" aria-label={`${t('ADMIN_BROADCAST.REMOVE')} ${user.email}`}
<li key={user.id} className="flex min-w-0 max-w-full items-center gap-1 rounded-md border px-2 py-1 text-sm">
<span className="min-w-0 truncate" title={user.email}>{user.email}</span>
<button type="button" className="shrink-0" aria-label={`${t('ADMIN_BROADCAST.REMOVE')} ${user.email}`}
onClick={() => toggle(user)}>
<X className="size-3"/>
</button>
+2 -2
View File
@@ -182,11 +182,11 @@ export default function AdminHintsPage() {
<p className="text-xs text-muted-foreground">{t('ADMIN_HINTS.TARGET_HELP')}</p>
</fieldset>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<div className="min-w-0 space-y-2">
<Label htmlFor="hintStartsAt">{t('ADMIN_HINTS.STARTS_AT')}</Label>
<Input id="hintStartsAt" type="datetime-local" required value={form.startsAt} onChange={change('startsAt')}/>
</div>
<div className="space-y-2">
<div className="min-w-0 space-y-2">
<Label htmlFor="hintEndsAt">{t('ADMIN_HINTS.ENDS_AT')}</Label>
<Input id="hintEndsAt" type="datetime-local" required value={form.endsAt} onChange={change('endsAt')}/>
</div>
+7 -5
View File
@@ -151,6 +151,11 @@ export default function AdminUsersPage() {
const confirmDisabled = busy || (pending?.kind === 'delete'
&& typedEmail.trim().toLowerCase() !== pending.user.email.toLowerCase());
const metaLine = (user) => [user.displayName,
t('ADMIN.REGISTERED', {date: formatDateOrNull(user.registrationDate, i18n.language)}),
t('ADMIN.LAST_LOGIN', {date: formatDateOrNull(user.lastLoginDate, i18n.language) ?? t('ADMIN.NEVER')}),
].filter(Boolean).join(' · ');
return (
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
<Card>
@@ -184,11 +189,8 @@ export default function AdminUsersPage() {
<li key={user.id} className="flex flex-wrap items-center gap-3 py-3">
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{user.email}</p>
<p className="truncate text-xs text-muted-foreground">
{[user.displayName,
t('ADMIN.REGISTERED', {date: formatDateOrNull(user.registrationDate, i18n.language)}),
t('ADMIN.LAST_LOGIN', {date: formatDateOrNull(user.lastLoginDate, i18n.language) ?? t('ADMIN.NEVER')}),
].filter(Boolean).join(' · ')}
<p className="truncate text-xs text-muted-foreground" title={metaLine(user)}>
{metaLine(user)}
</p>
</div>
<div className="flex flex-wrap items-center gap-1">
+8 -3
View File
@@ -7,6 +7,7 @@ import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import PageContainer from '../components/PageContainer.jsx';
import FitText from '../components/FitText.jsx';
import {toast} from 'sonner';
import {fetchAuditLog, undoMerge} from '../utils/links.js';
import {formatDateTime} from '../utils/format.js';
@@ -18,7 +19,11 @@ const ALL = 'ALL';
function GameRef({appid, name, fallback}) {
if (appid == null) return null;
return <Link to={`/audit/games?appid=${appid}`} className="font-medium hover:text-primary hover:underline">{name ?? fallback}</Link>;
return (
<Link to={`/audit/games?appid=${appid}`} className="min-w-0 max-w-full font-medium hover:text-primary hover:underline">
<FitText text={String(name ?? fallback ?? appid)}/>
</Link>
);
}
/**
@@ -117,10 +122,10 @@ export default function AuditLogPage() {
{status === 'ready' && result.content.length > 0 && (
<ul className="divide-y">
{result.content.map((entry) => (
<li key={entry.id} className="space-y-1 py-3 text-sm">
<li key={entry.id} className="@container space-y-1 py-3 text-sm">
<p className="flex flex-wrap items-center gap-x-2">
<span className="text-xs text-muted-foreground">{formatDateTime(entry.createdAt, i18n.language)}</span>
<span>{entry.actorName ?? t('AUDIT.USER_ID', {id: entry.actorUserId})}</span>
<FitText className="min-w-0 max-w-full" text={entry.actorName ?? t('AUDIT.USER_ID', {id: entry.actorUserId})}/>
<span className="font-medium">{t(`AUDIT.ACTION.${entry.action}`)}</span>
<GameRef appid={entry.appid} name={entry.gameName} fallback={entry.detail}/>
<GameRef appid={entry.targetAppid} name={entry.targetGameName} fallback={entry.targetAppid}/>
+8
View File
@@ -51,6 +51,14 @@ describe('AuditLogPage', () => {
expect(screen.getAllByText('Old key').length).toBeGreaterThan(0);
});
it('showsNoNullTextForAKeyWithoutNameAndDetail', async () => {
renderPage(answer([entry({action: 'GAME_LINK_DISMISS', gameName: null, detail: null, targetGameName: 'Hades'})]));
await screen.findByText('lobo');
expect(screen.queryByText('null')).toBeNull();
expect(screen.queryByText('undefined')).toBeNull();
});
it('pagesThroughTheLog', async () => {
renderPage(() => answer([entry({})], 2));
await screen.findByText('lobo');
+3 -2
View File
@@ -9,6 +9,7 @@ import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import GameCorrectionDialog from '../components/GameCorrectionDialog.jsx';
import GameCover from '../components/GameCover.jsx';
import FitText from '../components/FitText.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {fetchCatalogGame, searchCatalog} from '../utils/corrections.js';
import {MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
@@ -137,8 +138,8 @@ export default function GameCatalogPage() {
{result.content.map((game) => (
<li key={game.appid} className="flex items-center gap-3 py-2">
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-20 shrink-0"/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{game.name}</p>
<div className="@container min-w-0 flex-1">
<p className="text-sm font-medium"><FitText text={game.name}/></p>
<p className="text-xs text-muted-foreground">
{game.platforms.join(', ')} · {t('CATALOG.KEY', {appid: game.appid})}
</p>
+3 -2
View File
@@ -17,6 +17,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c
import {Skeleton} from '@/components/ui/skeleton';
import AddGamePanel from '../components/AddGamePanel.jsx';
import GameCover from '../components/GameCover.jsx';
import FitText from '../components/FitText.jsx';
import LibraryTabs from '../components/LibraryTabs.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx';
import PageContainer from '../components/PageContainer.jsx';
@@ -302,8 +303,8 @@ export default function GameLibrary() {
>
<GameCover appid={g.appid} imageUrl={g.imageUrl} fx={coverEffect(equipped, g.status)}
className="w-16 sm:w-24 shrink-0"/>
<span className="min-w-0 flex-1 truncate font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`}
<span className="@container min-w-0 flex-1 font-medium group-hover:text-primary transition-colors">
<FitText text={g.name ?? `App ${g.appid}`}/>
</span>
{platformLabel(g) && (
<Badge variant="outline" className="hidden sm:inline-flex" title={platformLabel(g)}>
+2 -1
View File
@@ -9,6 +9,7 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton';
import GameCover from '../components/GameCover.jsx';
import FitText from '../components/FitText.jsx';
import Logo from '../components/Logo.jsx';
import StatCard from '../components/StatCard.jsx';
import {YearBars} from '../components/stats/FinishedStats.jsx';
@@ -30,7 +31,7 @@ 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="min-w-0 flex-1 truncate font-medium">{g.name ?? `App ${g.appid}`}</span>
<span className="@container min-w-0 flex-1 font-medium"><FitText text={g.name ?? `App ${g.appid}`}/></span>
<span className="text-sm text-muted-foreground tabular-nums">{formatValue(g.value ?? 0)}</span>
</li>
))}
+2 -1
View File
@@ -8,6 +8,7 @@ import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import GameCover from '../components/GameCover.jsx';
import FitText from '../components/FitText.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {dismissPair, fetchLinkQueue, linkGames} from '../utils/links.js';
@@ -17,7 +18,7 @@ function PairGame({game}) {
return (
<Link to={`/library/${game.appid}`} className="flex min-w-0 flex-1 items-center gap-2 hover:text-primary">
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-16 shrink-0"/>
<span className="min-w-0 truncate text-sm font-medium">{game.name ?? `App ${game.appid}`}</span>
<span className="@container min-w-0 flex-1 text-sm font-medium"><FitText text={game.name ?? `App ${game.appid}`}/></span>
</Link>
);
}
+3 -2
View File
@@ -9,6 +9,7 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton';
import GameCover from '../components/GameCover.jsx';
import FitText from '../components/FitText.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {saveGameStatus} from '../utils/backlog.js';
import {formatPlaytime} from '../utils/games.js';
@@ -210,8 +211,8 @@ function PickCard({game}) {
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-full"/>
</Link>
<div className="flex flex-1 flex-col gap-3 px-4 pb-4">
<Link to={`/library/${game.appid}`} className="font-semibold hover:text-primary transition-colors">
{game.name ?? `App ${game.appid}`}
<Link to={`/library/${game.appid}`} className="@container block min-w-0 font-semibold hover:text-primary transition-colors">
<FitText text={game.name ?? `App ${game.appid}`}/>
</Link>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{game.timeToBeatMinutes > 0 && (
+7 -6
View File
@@ -10,6 +10,7 @@ import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton';
import {cn} from '@/lib/utils';
import EffectBackground from '../components/EffectBackground.jsx';
import FitText from '../components/FitText.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {usePreferences} from '../context/PreferencesContext.jsx';
import useInView from '../hooks/useInView.js';
@@ -53,8 +54,8 @@ export default function PointsPage() {
const entryLabel = (entry) => {
if (GAME_KINDS.includes(entry.kind)) {
return (
<Link to={`/library/${entry.ref}`} className="font-medium text-primary hover:underline">
{entry.gameName ?? entry.ref}
<Link to={`/library/${entry.ref}`} className="block min-w-0 max-w-full font-medium text-primary hover:underline">
<FitText text={entry.gameName ?? entry.ref}/>
</Link>
);
}
@@ -128,13 +129,13 @@ export default function PointsPage() {
{wallet.history.map((entry) => {
const label = entryLabel(entry);
return (
<li key={`${entry.kind}-${entry.ref}-${entry.eventAt}`} className="flex items-center gap-3 py-2 text-sm">
<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="min-w-0 flex-1 truncate">
{t(`POINTS.KINDS.${entry.kind}`)}
{label && <> {label}</>}
<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')}>
+6 -3
View File
@@ -8,6 +8,7 @@ import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton';
import {cn} from '@/lib/utils';
import GameCover from '../components/GameCover.jsx';
import FitText from '../components/FitText.jsx';
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
import {formatDay} from '../utils/format.js';
import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, equippedSkin, lookClass} from '../utils/rewards.js';
@@ -162,7 +163,9 @@ export default function PublicProfilePage() {
<HeroBanner appid={style.bannerAppid} effect={bannerEffect(equipped)}/>
<div className="relative">
<p className="text-sm font-medium text-muted-foreground">{t('PROFILE.TITLE')}</p>
<h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight break-words">{profile.displayName}</h1>
<h1 className="@container mt-1 text-3xl md:text-4xl font-bold tracking-tight">
<FitText wrapOnDesktop text={profile.displayName}/>
</h1>
<PointsBadge equipped={equipped} className="mt-2"/>
{memberSince && (
<p className="mt-2 text-sm text-muted-foreground">{t('PROFILE.MEMBER_SINCE', {date: memberSince})}</p>
@@ -241,7 +244,7 @@ function Playing({games, compact}) {
{games.map((g) => (
<li key={g.appid} className="space-y-1.5">
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-full"/>
<div className="truncate text-sm font-medium">{g.name ?? `App ${g.appid}`}</div>
<div className="@container text-sm font-medium"><FitText text={g.name ?? `App ${g.appid}`}/></div>
<div className="text-xs text-muted-foreground tabular-nums">{formatPlaytime(g.playtimeMinutes)}</div>
</li>
))}
@@ -262,7 +265,7 @@ function Finished({games, equipped}) {
<li key={g.appid} className="flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3">
<GameCover appid={g.appid} imageUrl={g.imageUrl} fx={coverEffect(equipped, g.status)}
className="w-16 sm:w-24 shrink-0"/>
<span className="min-w-0 flex-1 truncate font-medium">{g.name ?? `App ${g.appid}`}</span>
<span className="@container min-w-0 flex-1 font-medium"><FitText text={g.name ?? `App ${g.appid}`}/></span>
{g.rating && (
<span className="flex items-center gap-0.5" aria-label={t('BACKLOG.RATING_STARS', {count: g.rating})}>
{Array.from({length: g.rating}, (_, i) => (
+4 -3
View File
@@ -7,6 +7,7 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton';
import GameCover from '../components/GameCover.jsx';
import FitText from '../components/FitText.jsx';
import LibraryTabs from '../components/LibraryTabs.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {apiFetch} from '../utils/api.js';
@@ -101,9 +102,9 @@ export default function WishlistPage() {
className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50"
>
<GameCover appid={g.appid} className="w-16 sm:w-24 shrink-0"/>
<div className="min-w-0 flex-1">
<div className="truncate font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`}
<div className="@container min-w-0 flex-1">
<div className="font-medium group-hover:text-primary transition-colors">
<FitText text={g.name ?? `App ${g.appid}`}/>
</div>
{g.addedAt && (
<div className="text-xs text-muted-foreground">
+12
View File
@@ -162,6 +162,16 @@
--radius: 1.25rem;
}
/* See components/FitText.jsx. 0.55 = rough glyph width of Inter in em. No @container ancestor: the viewport counts. */
@utility fit-text {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: min(1em, max(0.875rem, 100cqi / (var(--fit-chars, 1) * 0.55)));
}
@theme inline {
--font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
--radius-sm: calc(var(--radius) - 4px);
@@ -271,6 +281,8 @@
body {
@apply bg-background text-foreground antialiased;
/* One long word breaks instead of widening the page. */
overflow-wrap: break-word;
}
/* The open list of a native select is drawn by the browser: without these it stays light in dark mode, with the