diff --git a/backend/endpoints/platform.py b/backend/endpoints/platform.py index 414d2092e..f22930d3b 100644 --- a/backend/endpoints/platform.py +++ b/backend/endpoints/platform.py @@ -113,7 +113,6 @@ def get_platform( async def update_platform( request: Request, id: Annotated[int, PathVar(description="Platform id.", ge=1)], - aspect_ratio: Annotated[str | None, Body(description="Cover aspect ratio.")] = None, custom_name: Annotated[ str | None, Body(description="Custom platform name.") ] = None, @@ -124,8 +123,6 @@ async def update_platform( if not platform_db: raise PlatformNotFoundInDatabaseException(id) - if aspect_ratio is not None: - platform_db.aspect_ratio = aspect_ratio if custom_name is not None: platform_db.custom_name = custom_name platform_db = db_platform_handler.add_platform(platform_db) diff --git a/backend/endpoints/responses/platform.py b/backend/endpoints/responses/platform.py index 24c54e1a0..fe873d861 100644 --- a/backend/endpoints/responses/platform.py +++ b/backend/endpoints/responses/platform.py @@ -1,7 +1,5 @@ from pydantic import ConfigDict, Field, computed_field, field_validator -from models.platform import DEFAULT_COVER_ASPECT_RATIO - from .base import BaseModel, UTCDatetime from .firmware import FirmwareSchema @@ -35,7 +33,6 @@ class PlatformSchema(BaseModel): url: str | None = None url_logo: str | None = None firmware: list[FirmwareSchema] = Field(default_factory=list) - aspect_ratio: str = DEFAULT_COVER_ASPECT_RATIO created_at: UTCDatetime updated_at: UTCDatetime fs_size_bytes: int diff --git a/backend/utils/platforms.py b/backend/utils/platforms.py index 705ecded4..c58b0b7d0 100644 --- a/backend/utils/platforms.py +++ b/backend/utils/platforms.py @@ -14,7 +14,7 @@ from handler.metadata import ( meta_tgdb_handler, ) from handler.metadata.base_handler import UniversalPlatformSlug as UPS -from models.platform import DEFAULT_COVER_ASPECT_RATIO, Platform +from models.platform import Platform def get_supported_platforms() -> list[PlatformSchema]: @@ -60,7 +60,6 @@ def get_supported_platforms() -> list[PlatformSchema]: "updated_at": now, "fs_size_bytes": 0, "missing_from_fs": False, - "aspect_ratio": DEFAULT_COVER_ASPECT_RATIO, } platform_attrs.update( diff --git a/frontend/src/__generated__/models/Body_update_platform_api_platforms__id__put.ts b/frontend/src/__generated__/models/Body_update_platform_api_platforms__id__put.ts index 00bede245..a3f9ed5c3 100644 --- a/frontend/src/__generated__/models/Body_update_platform_api_platforms__id__put.ts +++ b/frontend/src/__generated__/models/Body_update_platform_api_platforms__id__put.ts @@ -3,10 +3,6 @@ /* tslint:disable */ /* eslint-disable */ export type Body_update_platform_api_platforms__id__put = { - /** - * Cover aspect ratio. - */ - aspect_ratio?: (string | null); /** * Custom platform name. */ diff --git a/frontend/src/__generated__/models/PlatformSchema.ts b/frontend/src/__generated__/models/PlatformSchema.ts index 6510303eb..6b0cb53f8 100644 --- a/frontend/src/__generated__/models/PlatformSchema.ts +++ b/frontend/src/__generated__/models/PlatformSchema.ts @@ -30,7 +30,6 @@ export type PlatformSchema = { url?: (string | null); url_logo?: (string | null); firmware?: Array; - aspect_ratio?: string; created_at: string; updated_at: string; fs_size_bytes: number; diff --git a/frontend/src/v2/components/Activity/ActivityCard.vue b/frontend/src/v2/components/Activity/ActivityCard.vue index 385d3703c..13d9f3708 100644 --- a/frontend/src/v2/components/Activity/ActivityCard.vue +++ b/frontend/src/v2/components/Activity/ActivityCard.vue @@ -32,7 +32,7 @@ defineProps(); :alt="romName" width="100%" aspect-ratio="2/3" - cover + contain /> /* ── Hero ────────────────────────────────────────────────────────── - Cover sits in a fixed-width left column; fields take the rest. The - `align-items: start` keeps the cover anchored to the top so taller - stacks of fields (or the multiline summary growing) don't drag the - cover down with them. */ + Cover column sizes to the cover's natural width (`auto`) so the gap to + the fields is exactly the grid `gap`, consistent for any cover shape — + a fixed-width column would leave variable leftover space beside a + natural-width cover. `align-items: start` keeps the cover anchored to + the top so taller field stacks (or the growing summary) don't drag it + down. */ .r-v2-edit__hero { display: grid; - grid-template-columns: 240px 1fr; - gap: 18px; + grid-template-columns: auto 1fr; + gap: 24px; align-items: start; } diff --git a/frontend/src/v2/components/Dialogs/RefreshMetadataDialog.vue b/frontend/src/v2/components/Dialogs/RefreshMetadataDialog.vue index e738c6729..1a44329ae 100644 --- a/frontend/src/v2/components/Dialogs/RefreshMetadataDialog.vue +++ b/frontend/src/v2/components/Dialogs/RefreshMetadataDialog.vue @@ -681,7 +681,8 @@ function closeDialog() { .r-v2-refresh__cover img { width: 100%; height: 100%; - object-fit: cover; + /* Whole cover at its natural aspect (no crop); the slot stays uniform. */ + object-fit: contain; display: block; } .r-v2-refresh__cover-placeholder { diff --git a/frontend/src/v2/components/Gallery/GalleryShell.vue b/frontend/src/v2/components/Gallery/GalleryShell.vue index b80e2d41a..49f61e33f 100644 --- a/frontend/src/v2/components/Gallery/GalleryShell.vue +++ b/frontend/src/v2/components/Gallery/GalleryShell.vue @@ -61,6 +61,7 @@ import { type ListSortKey } from "@/v2/components/Gallery/listColumns"; import { GameCard, GameCardSkeleton } from "@/v2/components/GameCard"; import { useBreakpoint } from "@/v2/composables/useBreakpoint"; import { coverRatio, isBoxartStyle } from "@/v2/composables/useCoverArt"; +import { useGalleryCoverRatios } from "@/v2/composables/useGalleryCoverRatios"; import { useGalleryFilterUrl } from "@/v2/composables/useGalleryFilterUrl"; import { useGalleryMode } from "@/v2/composables/useGalleryMode"; import { useGalleryViewModeUrl } from "@/v2/composables/useGalleryViewModeUrl"; @@ -242,19 +243,19 @@ const { groupBy, layout, toolbarPosition } = useGalleryMode(); // CSS grid `minmax(--r-card-art-w, 1fr)` stay in lock-step. const { xs, smAndDown } = useBreakpoint(); const sectionEl = ref(null); -// Single source for the responsive card-art width — feeds both the column -// count and the virtualiser's row-height math so they never drift. -const cardWidth = () => (xs.value ? 108 : 158); -const { columns } = useResponsiveColumns(sectionEl, { +// Card-art width reference (matches GameCard's `--r-card-art-w`); sets the +// fixed card HEIGHT (a 2/3 cover at this width). Real width follows the ratio. +const CARD_GAP_PX = 12; +const cardWidth = () => (xs.value ? 130 : 158); +const cardHeight = () => Math.round(cardWidth() / (2 / 3)); +const { columns, usableWidth } = useResponsiveColumns(sectionEl, { cardWidth, - gap: 12, + gap: CARD_GAP_PX, inset: () => (xs.value ? 64 : smAndDown.value ? 76 : 108), }); -// Active cover aspect ratio from the gallery-wide boxart style. Drives -// both the cards' `--r-cover-ratio` (via the shell root, inherited) and -// the virtualiser's row height so the cover shape, the grid, and the -// scroll offsets all agree. +// Fallback cover ratio (boxart style) — the per-card `--r-cover-ratio` seed +// before GameCover measures the real image, plus the bootstrap skeletons. const { boxartStyle } = useUISettings(); const coverAspectRatio = computed(() => coverRatio( @@ -262,6 +263,11 @@ const coverAspectRatio = computed(() => ), ); +// Measured natural cover ratios feeding the flow-packer — GameCard reports +// each cover's ratio on load (`onCardRatio`), the packer reads `ratioAt`, +// and `ratioVersion` bumps (debounced) to trigger a single re-pack. +const { ratioVersion, ratioAt, onCardRatio } = useGalleryCoverRatios(); + // 2D arrow / gamepad nav for both layouts of the gallery. Two passes: // * Grid mode — rows are `.r-v2-shell__row` (the per-virtualizer-item // wrapper around the row's GameCards). ArrowLeft/Right within a row, @@ -301,8 +307,11 @@ const { virtualItems, letterToIndex, availableLetters, getItemHeight } = notFound: notFoundRef, notFoundMessage: notFoundMessageRef, skeletonRowCount: props.skeletonRowCount, - coverRatio: coverAspectRatio, - cardWidth, + cardHeight, + rowWidth: usableWidth, + gap: CARD_GAP_PX, + ratioAt, + ratioVersion, }); const scrollerRef = ref | null>(null); @@ -682,10 +691,6 @@ const asEmpty = (i: GalleryItem) => i as EmptyItem; const asListRow = (i: GalleryItem) => i as ListRowItem; const itemKind = (i: GalleryItem) => i.kind; -const rowGridStyle = computed(() => ({ - gridTemplateColumns: `repeat(${Math.max(1, columns.value)}, minmax(var(--r-card-art-w), 1fr))`, -})); - // View-facing surface. Methods only — internal state stays internal. defineExpose({ /** Re-apply the previously-saved scroll position for the current route @@ -787,7 +792,6 @@ defineExpose({
@@ -831,7 +836,6 @@ defineExpose({
* { + flex-shrink: 0; +} /* Card reveal animation (.r-v2-card-fade) lives in global.css — shared with the Home dashboard rows. */ @@ -1086,14 +1101,10 @@ defineExpose({ ); } -/* Smaller default cards on phones so the grid packs 2–3 per row instead - of one stretched card. The grid `minmax(--r-card-art-w, 1fr)` and the - GameCards (default size, reading the token) both shrink in lock-step; - the JS column-chunking above uses a matching 108px card width. Height - is left to the card's `--r-cover-ratio` derive rule so the boxart style - drives the cover shape on phones too. */ +/* Smaller cards on phones. Matches GameCard's own xs `--r-card-art-w` so + skeletons and the packer's card-height reference track the real cards. */ html[data-bp~="xs"] .r-v2-shell { - --r-card-art-w: 108px; + --r-card-art-w: 130px; } html[data-bp~="xs"] .r-v2-shell__scroller { diff --git a/frontend/src/v2/components/Gallery/GameListRow.vue b/frontend/src/v2/components/Gallery/GameListRow.vue index 596f8b7b4..1615220b8 100644 --- a/frontend/src/v2/components/Gallery/GameListRow.vue +++ b/frontend/src/v2/components/Gallery/GameListRow.vue @@ -313,7 +313,7 @@ onBeforeUnmount(() => { />
-
+
{ :show-title="false" :show-platform-icon="false" /> +
+ +
@@ -576,10 +579,18 @@ onBeforeUnmount(() => { justify-content: flex-end; } +/* Cover sits in its own fixed-width column (right-aligned, vertically + centred) so the title/meta column starts at the same x on every row and + the cover hugs the title side. */ +.game-list-row__cover { + display: flex; + align-items: center; + justify-content: flex-end; +} + .game-list-row__title { display: flex; align-items: center; - gap: var(--r-space-3); min-width: 0; } diff --git a/frontend/src/v2/components/Gallery/GameListSkeletonRow.vue b/frontend/src/v2/components/Gallery/GameListSkeletonRow.vue index 5e889a73d..7869c76df 100644 --- a/frontend/src/v2/components/Gallery/GameListSkeletonRow.vue +++ b/frontend/src/v2/components/Gallery/GameListSkeletonRow.vue @@ -44,13 +44,18 @@ const gridStyle = computed(() => ({