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

+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">