Public Access
fix(layout): fit names on phones without sideways scroll
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:
1 parent
04387fcd0c
commit
d7810fbf94
37 files changed
+263
-79
No files matched your search
@@ -281,6 +281,18 @@ npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Overflow check (phones)
|
||||
|
||||
`scripts/check-overflow.js` visits every page at 360, 390, 412 and 414 px and reports any page that scrolls sideways
|
||||
(`scrollWidth != clientWidth`) with its widest elements. It is not part of the gate and needs no dependency:
|
||||
|
||||
1. Start the local stack (see "Run locally") and log in as an admin in the Playwright MCP browser.
|
||||
2. Set `GAME_ID` (an appid in the library) and `PROFILE_NAME` (the display name of that user, the profile must be public) at the top of the script.
|
||||
3. Run the file with the Playwright MCP tool `browser_run_code_unsafe` (`filename` = the script). An empty `hits` list is green.
|
||||
4. Other runs: edit `FONT_PERCENT = 130` (large system font), `DARK`, `SKIN`. For the logged-out pages set `LOGGED_OUT = true` and run in a browser without a session (a logged-in browser redirects them).
|
||||
|
||||
Use long display names (up to 24 characters in Auth) and long game names in the local data to stress the layout.
|
||||
|
||||
## Roadmap
|
||||
|
||||
See `ROADMAP.md` in the ToBePlayed-Deployment repo.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Sideways scroll check for phones. Not part of the gate, run by hand on the local stack (see README, "Overflow check").
|
||||
* A Playwright function, run with the Playwright MCP tool browser_run_code_unsafe (filename = this file) or
|
||||
* `playwright-cli run-code`. Log in as an admin in that browser first. A logged-in browser redirects the logged-out
|
||||
* pages, so check those in a second run with LOGGED_OUT = true in a clean browser (no session).
|
||||
* A hit is: scrollWidth != clientWidth, an element sticking out on the left (never raises scrollWidth), or a
|
||||
* redirect away from the page.
|
||||
* Content clipped inside an overflow-hidden ancestor is not checked (same rule as scrollWidth).
|
||||
*/
|
||||
async (page) => {
|
||||
const BASE = 'http://localhost:5173';
|
||||
const WIDTHS = [360, 390, 412, 414];
|
||||
const FONT_PERCENT = 100; // set 130 for the large system font run
|
||||
const DARK = false; // set true for dark mode
|
||||
const SKIN = ''; // e.g. 'pastel' to check a skin
|
||||
const LOGGED_OUT = false; // true: only the logged-out pages, run in a browser without a session
|
||||
const GAME_ID = 620; // any appid in the library
|
||||
const PROFILE_NAME = 'Dev'; // display name of the logged-in user, the profile must be public
|
||||
const PAGES = LOGGED_OUT ? ['/', '/login', '/register', '/reset-password', '/impressum', `/u/${encodeURIComponent(PROFILE_NAME)}`] : [
|
||||
'/dashboard', '/library', `/library/${GAME_ID}`, '/wishlist', '/play-next', '/stats', '/import', '/points',
|
||||
'/settings', '/whats-new', '/welcome', '/audit/games', '/audit/links', '/audit/log', '/admin/users',
|
||||
'/admin/hints', '/admin/news', '/admin/broadcasts', `/u/${encodeURIComponent(PROFILE_NAME)}`,
|
||||
];
|
||||
|
||||
const hits = [];
|
||||
let checked = 0;
|
||||
for (const width of WIDTHS) {
|
||||
await page.setViewportSize({width, height: 800});
|
||||
for (const path of PAGES) {
|
||||
await page.goto(BASE + path); // no networkidle: some pages poll
|
||||
await page.waitForTimeout(1500);
|
||||
if (FONT_PERCENT !== 100) await page.addStyleTag({content: `html{font-size:${FONT_PERCENT}%}`});
|
||||
await page.evaluate(([dark, skin]) => {
|
||||
document.documentElement.classList.toggle('dark', dark);
|
||||
if (skin) document.documentElement.dataset.skin = skin;
|
||||
}, [DARK, SKIN]);
|
||||
await page.waitForTimeout(300);
|
||||
const result = await page.evaluate(() => {
|
||||
const root = document.documentElement;
|
||||
const describe = (element) => `${element.tagName.toLowerCase()}.${String(element.className).slice(0, 60)}`;
|
||||
const all = [...document.querySelectorAll('body *')].map((element) => [element, element.getBoundingClientRect()]);
|
||||
const right = all.filter(([, box]) => box.right > root.clientWidth).sort((a, b) => b[1].right - a[1].right);
|
||||
// Clipped by an overflow:hidden/auto ancestor (an effect preview, a scroller): not visible, not a hit.
|
||||
const clipped = (element) => {
|
||||
for (let parent = element.parentElement; parent && parent !== document.body; parent = parent.parentElement) {
|
||||
if (getComputedStyle(parent).overflowX !== 'visible') return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const left = all.filter(([element, box]) => box.width > 0 && box.left < 0 && !clipped(element));
|
||||
if (root.scrollWidth === root.clientWidth && left.length === 0) return null;
|
||||
return {
|
||||
scrollWidth: root.scrollWidth, clientWidth: root.clientWidth,
|
||||
widest: right.slice(0, 5).map(([element]) => describe(element)),
|
||||
offLeft: left.slice(0, 5).map(([element]) => describe(element)),
|
||||
};
|
||||
});
|
||||
checked++;
|
||||
const landed = await page.evaluate(() => location.pathname);
|
||||
if (decodeURIComponent(landed) !== decodeURIComponent(path)) hits.push({path, width, redirectedTo: landed});
|
||||
if (result) hits.push({path, width, ...result});
|
||||
}
|
||||
}
|
||||
return {checked, hits};
|
||||
}
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 +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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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"/>}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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(' · ')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}/>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user