Files
LudosData/frontend/src/app/features/game-grid/game-grid.scss
T
ckochandClaude Opus 5 55182a4da7 Add cover art fetcher; letterbox covers instead of cropping
tools/cover-art/fetch_art.py matches each game against libretro-thumbnails
by title + system and attaches the result through the app's own
POST /api/images, so fetched art goes through the same validation and WebP
re-encoding as a manual upload. Standard library only.

Matching bridges a personal catalogue and a ROM-naming one:
  * accents stripped, so "Pokemon Yellow" reaches "Pokémon"
  * roman numerals folded to digits, so the SNES "Final Fantasy 2" lands on
    "Final Fantasy II" and the PS1 "Final Fantasy V" on its own entry
  * trailing articles unwound ("Sims 2, The" -> "The Sims 2")
  * subtitle containment in both directions, since our rows sometimes omit
    what the catalogue carries ("Wave Race 64" vs "... - Kawasaki Jet Ski")
    and sometimes carry what it omits ("Donkey Kong Country 2: Diddy's Kong
    Quest" vs the GBA set's "Donkey Kong Country 2")
  * a sequel guard, so containment cannot collapse "Donkey Kong Country 2"
    onto "Donkey Kong Country"
  * fuzzy enough to absorb typos: "Brett Hull Hocky 95" finds "Hockey 95"

93 of 105 games now have art. The remainder: 10 Xbox 360 titles, which
libretro has no thumbnail set for, and two rows whose platform looks wrong
in the source data (a Game Boy "Donkey Kong Country 2", which was never
released on that system, and a DS "Donkey Kong Country Returns", which was
Wii and later 3DS).

Real art also invalidated a layout assumption: the grid used object-fit:
cover, which was fine for uniform placeholders but crops actual boxes, whose
aspect ratios run from near-square SNES to tall N64. Switched the grid and
the editor preview to object-fit: contain so the whole cover is visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:41:02 -04:00

186 lines
3.4 KiB
SCSS

.search-field {
width: min(28rem, 40vw);
@media (max-width: 899px) {
width: 12rem;
}
@media (max-width: 599px) {
width: 100%;
}
}
.filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.5rem 0.5rem;
mat-form-field {
min-width: 10rem;
}
@media (max-width: 599px) {
padding-inline: 1rem;
mat-form-field {
flex: 1 1 8rem;
min-width: 0;
}
}
}
.grid {
display: grid;
/* Cards size themselves; no masonry library required for this. */
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
gap: 1rem;
padding: 1rem 1.5rem;
@media (max-width: 599px) {
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
gap: 0.75rem;
padding: 0.75rem 1rem;
}
}
.game-card {
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
cursor: pointer;
transition:
transform 120ms ease,
box-shadow 120ms ease;
&:hover,
&:focus-visible {
transform: translateY(-2px);
box-shadow: var(--mat-sys-level3);
}
&:focus-visible {
outline: 2px solid var(--mat-sys-primary);
outline-offset: 2px;
}
.art {
aspect-ratio: 3 / 4;
background: var(--mat-sys-surface-container-high);
}
img {
width: 100%;
height: 100%;
/* `contain`, not `cover`: box art spans everything from near-square SNES
boxes to tall N64 ones, and cropping to a single ratio slices the title
off the edges. Letterboxing against the tile background shows the whole
cover, which is what the user is scanning for. */
object-fit: contain;
display: block;
}
.art-placeholder {
display: grid;
place-items: center;
height: 100%;
color: var(--mat-sys-outline);
mat-icon {
font-size: 2.5rem;
width: 2.5rem;
height: 2.5rem;
}
}
.body {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.625rem 0.75rem 0.75rem;
}
.title {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
line-height: 1.3;
/* Two lines, then ellipsis — keeps every card the same height. */
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.meta {
display: flex;
gap: 0.5rem;
margin: 0;
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
}
.system {
font-weight: 600;
}
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.125rem;
}
.badge {
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
background: var(--mat-sys-surface-container-highest);
color: var(--mat-sys-on-surface-variant);
&--finished {
background: var(--mat-sys-primary);
color: var(--mat-sys-on-primary);
}
&--played {
background: var(--mat-sys-tertiary-container);
color: var(--mat-sys-on-tertiary-container);
}
}
.state-panel {
display: grid;
place-items: center;
gap: 0.75rem;
padding: 4rem 1.5rem;
text-align: center;
color: var(--mat-sys-on-surface-variant);
mat-icon {
font-size: 3rem;
width: 3rem;
height: 3rem;
opacity: 0.6;
}
h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 500;
}
p {
margin: 0;
max-width: 28rem;
}
}
mat-paginator {
background: transparent;
}