Merge pull request #3573 from rommapp/aspect-ratio-no-no

feat(v2): render cover art at its natural aspect ratio
This commit is contained in:
Georges-Antoine Assi
2026-06-21 21:45:36 -04:00
committed by GitHub
28 changed files with 529 additions and 434 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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(

View File

@@ -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.
*/

View File

@@ -30,7 +30,6 @@ export type PlatformSchema = {
url?: (string | null);
url_logo?: (string | null);
firmware?: Array<FirmwareSchema>;
aspect_ratio?: string;
created_at: string;
updated_at: string;
fs_size_bytes: number;

View File

@@ -32,7 +32,7 @@ defineProps<Props>();
:alt="romName"
width="100%"
aspect-ratio="2/3"
cover
contain
/>
<RChip
color="success"

View File

@@ -334,7 +334,9 @@ function closeDialog() {
.r-v2-del-rom__cover img {
width: 100%;
height: 100%;
object-fit: cover;
/* Show the whole cover at its natural aspect (no crop); the slot stays a
uniform width so the delete list's rows keep their alignment. */
object-fit: contain;
display: block;
}
.r-v2-del-rom__cover-placeholder {

View File

@@ -480,14 +480,16 @@ function handleRomUpdateFromMetadata(updatedRom: UpdateRom) {
<style scoped>
/* ── 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;
}

View File

@@ -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 {

View File

@@ -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<HTMLElement | null>(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<InstanceType<typeof RVirtualScroller> | 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({
<div
v-else-if="itemKind(item as GalleryItem) === 'row'"
class="r-v2-shell__row"
:style="rowGridStyle"
>
<template
v-for="(p, slotIdx) in rowPositions(asRow(item as GalleryItem))"
@@ -802,6 +806,7 @@ defineExpose({
:show-platform-badge="showPlatformBadge"
selectable
:position="p"
@ratio="onCardRatio"
/>
<GameCardSkeleton v-else />
</template>
@@ -831,7 +836,6 @@ defineExpose({
<div
v-else-if="itemKind(item as GalleryItem) === 'skeleton-row'"
class="r-v2-shell__row"
:style="rowGridStyle"
>
<GameCardSkeleton
v-for="n in Math.max(1, columns)"
@@ -988,11 +992,22 @@ defineExpose({
margin-bottom: 16px;
}
/* Flow-packed wrapping row: same-height, natural-width cards. The packer
sized it to fit, so `nowrap` is safe; gaps match the packer (12) and the
chrome math (18). `flex-start` pins every card to the same top. */
.r-v2-shell__row {
display: grid;
gap: 18px 12px;
display: flex;
flex-wrap: nowrap;
align-items: flex-start;
gap: 12px;
padding-bottom: 18px;
}
/* Never shrink: float rounding can push a "just fits" row a hair over, and
shrinking a fixed-height card would crop its cover. Take ragged overflow
instead (also keeps skeletons, default shrink:1, at their packed width). */
.r-v2-shell__row > * {
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 23 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 {

View File

@@ -313,7 +313,7 @@ onBeforeUnmount(() => {
/>
</div>
<div class="game-list-row__cell game-list-row__title">
<div class="game-list-row__cell game-list-row__cover">
<GameCard
:rom="rom"
size="xs"
@@ -322,6 +322,9 @@ onBeforeUnmount(() => {
:show-title="false"
:show-platform-icon="false"
/>
</div>
<div class="game-list-row__cell game-list-row__title">
<div class="game-list-row__meta">
<div class="game-list-row__name-row">
<div class="game-list-row__name">
@@ -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;
}

View File

@@ -44,13 +44,18 @@ const gridStyle = computed(() => ({
<template v-for="col in columns" :key="String(col.key)">
<div v-if="col.key === 'select'" class="r-glr-skel__cell" />
<div
v-else-if="col.key === 'name'"
class="r-glr-skel__cell r-glr-skel__title"
v-else-if="col.key === 'cover'"
class="r-glr-skel__cell r-glr-skel__cover"
>
<RSkeletonBlock
:width="LIST_COVER_WIDTH_PX"
:height="LIST_COVER_HEIGHT_PX"
/>
</div>
<div
v-else-if="col.key === 'name'"
class="r-glr-skel__cell r-glr-skel__title"
>
<div class="r-glr-skel__meta">
<RSkeletonBlock width="60%" :height="12" />
<RSkeletonBlock width="40%" :height="10" />
@@ -107,6 +112,13 @@ const gridStyle = computed(() => ({
justify-content: flex-end;
}
/* Right-align the cover block to match the real row. */
.r-glr-skel__cover {
display: flex;
align-items: center;
justify-content: flex-end;
}
.r-glr-skel__title {
display: flex;
align-items: center;

View File

@@ -1,20 +1,18 @@
<script setup lang="ts">
// SettingsTab — platform-scoped settings rendered as the `Settings`
// tab inside Platform.vue. Two-column layout: details (editable name
// + read-only platform fields) on the left, cover-style picker on
// the right. A danger zone underneath the details column holds the
// destructive "Delete platform" action.
// tab inside Platform.vue. Details (editable name + read-only platform
// fields) with a danger zone underneath holding the destructive
// "Delete platform" action.
//
// Mutation paths:
// Mutation path:
// • `custom_name` → `platformApi.updatePlatform({ platform: { …, custom_name } })`
// • `aspect_ratio` → `platformApi.updatePlatform({ platform: { …, aspect_ratio } })`
// Both flows are optimistic — the picker / form updates the local
// platform reactively, a snackbar fires on success/failure.
// Optimistic — the form updates the local platform reactively, a
// snackbar fires on success/failure.
//
// Delete: emitted upward (`@delete`) so the view orchestrator can
// drive the confirm + router navigation. Same vocabulary as the
// pre-tabs admin kebab.
import { RBtn, RChip, RForm, RIcon, RTextField } from "@v2/lib";
import { RBtn, RForm, RIcon, RTextField } from "@v2/lib";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import platformApi from "@/services/api/platform";
@@ -125,119 +123,6 @@ const details = computed<DetailRow[]>(() => {
});
return rows;
});
// ── Aspect ratio ────────────────────────────────────────────────
// Platform-specific options. DVD / Blu-ray / DS / PSP / Switch
// families each add their natural-fit aspect on top of the universal
// 2:3 / 3:4 / 1:1 / 16:11 baseline.
const DVD_PLATFORMS = new Set([
"dvd-player",
"ps2",
"ngc",
"wii",
"wiiu",
"xbox",
"xbox360",
"win",
]);
const BLU_RAY_PLATFORMS = new Set([
"blu-ray-player",
"ps3",
"ps4",
"ps5",
"psvita",
"xboxone",
"series-x-s",
]);
const DS_3DS_PLATFORMS = new Set([
"nds",
"nintendo-dsi",
"3ds",
"new-nintendo-3ds",
"psx",
"dc",
]);
const PSP_PLATFORMS = new Set(["psp", "psp-minis"]);
const SWITCH_PLATFORMS = new Set(["switch", "switch-2"]);
interface AspectOption {
name: string;
size: number;
source: string;
}
const aspectOptions = computed<AspectOption[]>(() => {
const slug = props.platform.slug?.toLowerCase() ?? "";
return [
{ name: "2 / 3", size: 2 / 3, source: "SteamGridDB" },
{ name: "3 / 4", size: 3 / 4, source: "IGDB / MobyGames" },
{
name: "1 / 1",
size: 1 / 1,
source: t("platform.old-squared-cases"),
},
{
name: "16 / 11",
size: 16 / 11,
source: t("platform.old-horizontal-cases"),
},
...(DVD_PLATFORMS.has(slug)
? [{ name: "0.71 / 1", size: 0.71 / 1, source: "DVD" }]
: []),
...(BLU_RAY_PLATFORMS.has(slug)
? [
{
name: "0.79 / 1",
size: 0.79 / 1,
source: "Blu-ray (Full artwork)",
},
{
name: "0.87 / 1",
size: 0.87 / 1,
source: "Blu-ray (Plastic header)",
},
]
: []),
...(DS_3DS_PLATFORMS.has(slug)
? [{ name: "1.08 / 1", size: 1.08 / 1, source: "Nintendo DS / 3DS" }]
: []),
...(PSP_PLATFORMS.has(slug)
? [{ name: "0.58 / 1", size: 0.58 / 1, source: "PSP" }]
: []),
...(SWITCH_PLATFORMS.has(slug)
? [{ name: "0.62 / 1", size: 0.62 / 1, source: "Switch" }]
: []),
];
});
const selectedAspect = computed(() => props.platform.aspect_ratio ?? "3 / 4");
async function setAspect(option: AspectOption) {
if (option.name === selectedAspect.value) return;
try {
const { data } = await platformApi.updatePlatform({
platform: { ...props.platform, aspect_ratio: option.name },
});
platformsStore.update(data);
if (galleryRoms.currentPlatform?.id === data.id) {
galleryRoms.setCurrentPlatform(data);
}
snackbar.success(t("platform.updated") || "Platform updated", {
icon: "mdi-check-bold",
});
} catch (err) {
const e = err as {
response?: { data?: { msg?: string } };
message?: string;
};
snackbar.error(
`Failed to update aspect ratio: ${
e?.response?.data?.msg || e?.message || "unknown error"
}`,
{ icon: "mdi-close-circle" },
);
}
}
</script>
<template>
@@ -346,65 +231,19 @@ async function setAspect(option: AspectOption) {
</div>
</section>
</div>
<!-- Right column cover-style picker. -->
<div class="r-v2-plat-settings__col">
<section class="r-v2-plat-settings__section">
<header class="r-v2-plat-settings__section-head">
<RIcon icon="mdi-aspect-ratio" size="14" />
<span>{{ t("platform.cover-style") }}</span>
</header>
<div class="r-v2-plat-settings__aspects">
<button
v-for="opt in aspectOptions"
:key="opt.name"
type="button"
class="r-v2-plat-settings__aspect"
:class="{
'r-v2-plat-settings__aspect--active': opt.name === selectedAspect,
}"
:aria-pressed="opt.name === selectedAspect"
@click="setAspect(opt)"
>
<span
class="r-v2-plat-settings__aspect-tile"
:style="{ aspectRatio: String(opt.size) }"
>
<span class="r-v2-plat-settings__aspect-name">{{
opt.name
}}</span>
</span>
<RChip
size="x-small"
variant="translucent"
class="r-v2-plat-settings__aspect-source"
>
{{ opt.source }}
</RChip>
</button>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.r-v2-plat-settings {
/* Single column — details + danger zone, constrained to a readable
width rather than stretching the full tab. */
display: grid;
/* Two-column layout — details + danger zone on the left, cover-style
picker on the right. Collapses to a single column under 900px so
the aspect grid keeps reasonable card sizing. */
grid-template-columns: minmax(280px, 360px) 1fr;
grid-template-columns: minmax(280px, 460px);
gap: 28px;
align-items: start;
}
@media (max-width: 900px) {
.r-v2-plat-settings {
grid-template-columns: 1fr;
}
}
.r-v2-plat-settings__col {
display: flex;
flex-direction: column;
@@ -504,66 +343,4 @@ async function setAspect(option: AspectOption) {
color: var(--r-color-fg-muted);
line-height: 1.4;
}
/* ── Aspect ratio grid ─────────────────────────────────────────── */
.r-v2-plat-settings__aspects {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 10px;
}
.r-v2-plat-settings__aspect {
appearance: none;
background: var(--r-color-bg-elevated);
border: 1px solid var(--r-color-border);
border-radius: var(--r-radius-md);
padding: 10px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
cursor: pointer;
color: var(--r-color-fg);
transition:
background var(--r-motion-fast) var(--r-motion-ease-out),
border-color var(--r-motion-fast) var(--r-motion-ease-out);
}
.r-v2-plat-settings__aspect:hover {
background: var(--r-color-surface-hover);
border-color: var(--r-color-border-strong);
}
.r-v2-plat-settings__aspect--active {
background: color-mix(in srgb, var(--r-color-brand-primary) 14%, transparent);
border-color: color-mix(
in srgb,
var(--r-color-brand-primary) 55%,
transparent
);
color: var(--r-color-brand-primary);
}
.r-v2-plat-settings__aspect-tile {
display: grid;
place-items: center;
width: 80%;
background: var(--r-color-surface);
border: 1px solid var(--r-color-border-strong);
border-radius: 6px;
font-size: 12px;
font-weight: var(--r-font-weight-semibold);
color: var(--r-color-fg-secondary);
}
.r-v2-plat-settings__aspect--active .r-v2-plat-settings__aspect-tile {
border-color: color-mix(
in srgb,
var(--r-color-brand-primary) 60%,
transparent
);
color: var(--r-color-brand-primary);
}
.r-v2-plat-settings__aspect-name {
pointer-events: none;
}
.r-v2-plat-settings__aspect-source {
font-size: 10px;
text-align: center;
}
</style>

View File

@@ -30,7 +30,7 @@ export type ListSortKey = Extract<
export interface ListColumn {
/** Sort key (matches `galleryRoms.orderBy`). `null` for non-sortable
* display-only columns (icon labels, action menus). */
key: ListSortKey | "select" | "languages" | "regions" | "actions";
key: ListSortKey | "select" | "cover" | "languages" | "regions" | "actions";
/** Column header label. Empty string renders no text (used for the
* leading select column + trailing actions column). */
label: string;
@@ -51,6 +51,9 @@ export interface ListColumn {
export function getListColumns(showPlatform: boolean): readonly ListColumn[] {
const cols: ListColumn[] = [
{ key: "select", label: "", sortable: false, align: "start" },
// Cover gets its own fixed-width column so the title/meta column starts
// at the same x on every row, regardless of the cover's natural width.
{ key: "cover", label: "", sortable: false, align: "start" },
{ key: "name", label: "Title", sortable: true, align: "start" },
];
if (showPlatform) {
@@ -115,7 +118,9 @@ export function getListColumns(showPlatform: boolean): readonly ListColumn[] {
* order matches the array. */
export function getListGridTemplate(showPlatform: boolean): string {
const platformTrack = showPlatform ? " 200px" : "";
return `36px minmax(0, 1.6fr)${platformTrack} 88px 96px 84px 56px 110px 110px 88px`;
// Cover track = the xs cover height (64px), so any portrait→square cover
// fits without clipping and the title column starts at a fixed x.
return `36px 64px minmax(0, 1.6fr)${platformTrack} 88px 96px 84px 56px 110px 110px 88px`;
}
/** Default exports — the cross-platform variant. Used by the bootstrap-

View File

@@ -234,6 +234,10 @@ function onPlatformClick(e: MouseEvent) {
const emit = defineEmits<{
(e: "click", event: MouseEvent): void;
/** The cover's natural aspect ratio (width / height) once its image
* loads — forwarded from GameCover so the gallery can flow-pack cards
* at their true shape. Carries the rom id so the consumer can key it. */
(e: "ratio", payload: { romId: number; ratio: number }): void;
}>();
// Gallery selection — only wired when the consumer opts in via
@@ -409,6 +413,7 @@ function onStaticKeydown(e: KeyboardEvent) {
:webp="webp"
:active="coverActive"
:morph-id="isSynthetic ? null : rom.id"
@ratio="emit('ratio', { romId: rom.id, ratio: $event })"
>
<!-- Selection checkbox top-left, drawn over the cover. Hidden
at rest; appears on hover for discoverability, and stays
@@ -524,13 +529,27 @@ function onStaticKeydown(e: KeyboardEvent) {
color: var(--r-color-fg);
}
/* Default (gallery) card derives its art height from the active cover
ratio so the boxart style drives the shape (cover_path 2/3, box3d 3/4,
physical / miximage 1/1). Explicit size tiers + hero keep their fixed
footprints — they set `--r-card-art-h` directly. `--r-cover-ratio` is
set inline by `useCoverArt`; the fallback keeps standalone cards sane. */
/* Non-hero cards render at a FIXED HEIGHT with NATURAL WIDTH — same height,
varying widths, never cropped. `size` just picks the height (the default
derives it from the reference width; tiers set it directly). Only `hero`
(16:9) keeps a fixed w×h footprint. */
.r-gc:not([class*="r-gc--size-"]):not(.r-gc--hero) {
--r-card-art-h: calc(var(--r-card-art-w) / var(--r-cover-ratio, 0.6667));
--r-card-art-h: calc(var(--r-card-art-w) / 0.6667);
}
.r-gc:not(.r-gc--hero) {
width: auto;
display: flex;
flex-direction: column;
}
/* Width follows the cover (overrides GameCover's base `width: 100%`). */
.r-gc:not(.r-gc--hero) .r-gc__art {
width: auto;
}
/* Label fills the cover's width and ellipsises, without widening the card. */
.r-gc:not(.r-gc--hero) .r-gc__label {
width: 0;
min-width: 100%;
max-width: 100%;
}
/* The art box IS the shared <GameCover> (this class lands on its root).

View File

@@ -41,10 +41,6 @@ const actions = useGameActions(() => props.rom);
</h1>
<div class="r-v2-det-header__meta">
<span v-if="releaseDate">{{ releaseDate }}</span>
<span v-if="releaseDate && platformLabel" class="r-v2-det-header__sep">
·
</span>
<router-link
v-if="actions.platformPath.value"
:to="actions.platformPath.value"
@@ -59,12 +55,9 @@ const actions = useGameActions(() => props.rom);
/>
{{ platformLabel }}
</router-link>
<span
v-if="(releaseDate || platformLabel) && verified"
class="r-v2-det-header__sep"
>
·
</span>
<span v-if="releaseDate" class="r-v2-det-header__sep"> · </span>
<span v-if="releaseDate">{{ releaseDate }}</span>
<span v-if="verified" class="r-v2-det-header__sep"> · </span>
<!-- Icon-only verified indicator. The check decagram is a strong
enough signal on its own; the "Verified" word was just noise
in a row that's already mostly text. RTooltip preserves the
@@ -77,27 +70,34 @@ const actions = useGameActions(() => props.rom);
activator="parent"
/>
</span>
</div>
<div
v-if="regions.length || languages.length || tags.length"
class="r-v2-det-header__tags"
>
<RTag
v-for="r in regions"
:key="`r-${r}`"
:text="r"
tone="info"
size="small"
/>
<RTag
v-for="l in languages"
:key="`l-${l}`"
:text="l"
tone="brand"
size="small"
/>
<RTag v-for="t in tags" :key="`t-${t}`" :text="t" size="small" />
<span
v-if="regions.length || languages.length || tags.length"
class="r-v2-det-header__sep"
>
·
</span>
<span
v-if="regions.length || languages.length || tags.length"
class="r-v2-det-header__tags"
>
<RTag
v-for="r in regions"
:key="`r-${r}`"
:text="r"
tone="info"
size="small"
/>
<RTag
v-for="l in languages"
:key="`l-${l}`"
:text="l"
tone="brand"
size="small"
/>
<RTag v-for="t in tags" :key="`t-${t}`" :text="t" size="small" />
</span>
</div>
<div v-if="rom.sibling_roms.length > 0" class="r-v2-det-header__versions">

View File

@@ -60,7 +60,7 @@ const visible = () => props.sections.filter((s) => s.items.length > 0);
surfaces feel like siblings. */
.r-v2-det-infogrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(240px, auto));
gap: 18px 24px;
width: 100%;
}

View File

@@ -65,6 +65,16 @@ const hasQuickFacts = computed(
() => !!props.playerCount || hasAgeRatings.value,
);
// Mirror HLTBStrip's "has anything to show" so the section heading only
// appears alongside the strip (which renders nothing without durations).
const hasHltb = computed(() => {
const m = props.hltb;
if (!m) return false;
return [m.main_story, m.main_plus_extra, m.completionist, m.all_styles].some(
(v) => (v ?? 0) > 0,
);
});
// Enrich the slim `{ id, name }` user_collections payload from the
// ROM with the full Collection record (cover paths, rom_count) the
// store already holds — so we can render real CollectionTile mosaics
@@ -204,13 +214,22 @@ const coverSource = computed(() => {
<InfoGrid :sections="sections" />
<!-- 4. Screenshots scraped metadata images, read-only here. -->
<ScreenshotsTab
v-if="screenshots.length"
:screenshots="screenshots.map((url) => ({ url }))"
/>
<div v-if="screenshots.length" class="overview-tab__section">
<h4 class="overview-tab__section-heading">
<RIcon icon="mdi-image-multiple-outline" size="14" />
{{ t("rom.screenshots") }}
</h4>
<ScreenshotsTab :screenshots="screenshots.map((url) => ({ url }))" />
</div>
<!-- 5. HLTB -->
<HLTBStrip :metadata="hltb" />
<div v-if="hasHltb" class="overview-tab__section">
<h4 class="overview-tab__section-heading">
<RIcon icon="mdi-clock-outline" size="14" />
{{ t("rom.how-long-to-beat") }}
</h4>
<HLTBStrip :metadata="hltb" />
</div>
<!-- 5. Related games each category gets its own labelled section,
rendered inline as siblings to the rest of the overview blocks.
@@ -219,36 +238,36 @@ const coverSource = computed(() => {
metadata, and the empty sections are already hidden by their
own `v-if`. -->
<template v-if="hasRelated">
<div v-if="expansions.length" class="overview-tab__related-section">
<h4 class="overview-tab__related-heading">
<div v-if="expansions.length" class="overview-tab__section">
<h4 class="overview-tab__section-heading">
<RIcon icon="mdi-puzzle-outline" size="14" />
Expansions
</h4>
<RelatedGamesGrid title="" :items="expansions" />
</div>
<div v-if="dlcs.length" class="overview-tab__related-section">
<h4 class="overview-tab__related-heading">
<div v-if="dlcs.length" class="overview-tab__section">
<h4 class="overview-tab__section-heading">
<RIcon icon="mdi-package-variant-closed" size="14" />
DLC
</h4>
<RelatedGamesGrid title="" :items="dlcs" />
</div>
<div v-if="remakes.length" class="overview-tab__related-section">
<h4 class="overview-tab__related-heading">
<div v-if="remakes.length" class="overview-tab__section">
<h4 class="overview-tab__section-heading">
<RIcon icon="mdi-refresh" size="14" />
Remakes
</h4>
<RelatedGamesGrid title="" :items="remakes" />
</div>
<div v-if="remasters.length" class="overview-tab__related-section">
<h4 class="overview-tab__related-heading">
<div v-if="remasters.length" class="overview-tab__section">
<h4 class="overview-tab__section-heading">
<RIcon icon="mdi-image-auto-adjust" size="14" />
Remasters
</h4>
<RelatedGamesGrid title="" :items="remasters" />
</div>
<div v-if="similarGames.length" class="overview-tab__related-section">
<h4 class="overview-tab__related-heading">
<div v-if="similarGames.length" class="overview-tab__section">
<h4 class="overview-tab__section-heading">
<RIcon icon="mdi-shape-outline" size="14" />
Similar games
</h4>
@@ -389,14 +408,15 @@ const coverSource = computed(() => {
border-radius: 3px;
}
/* Related games — each category is a sibling block in the overview
/* Labelled overview section (screenshots, HLTB, each related-games
category) — a heading + its content as a sibling block in the overview
flex column; the outer column's `gap: 30px` provides separation. */
.overview-tab__related-section {
.overview-tab__section {
display: flex;
flex-direction: column;
gap: 8px;
}
.overview-tab__related-heading {
.overview-tab__section-heading {
margin: 0;
display: inline-flex;
align-items: center;

View File

@@ -5,15 +5,14 @@
// calls per pick: one to learn the library total, one to fetch the
// selected offset; same approach the v1 RandomBtn uses. The pick is
// intentionally not cached so each mount re-shuffles.
import { RBtn, RImg } from "@v2/lib";
import { computed, onMounted, ref } from "vue";
import { RBtn } from "@v2/lib";
import { onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { ROUTES } from "@/plugins/router";
import romApi from "@/services/api/rom";
import type { SimpleRom } from "@/stores/roms";
import { useCoverArt } from "@/v2/composables/useCoverArt";
import { coverPlaceholderArt } from "@/v2/utils/covers";
import GameCover from "@/v2/components/shared/GameCover.vue";
import WidgetCard from "./WidgetCard.vue";
defineOptions({ inheritAttrs: false });
@@ -24,21 +23,6 @@ const router = useRouter();
const pick = ref<SimpleRom | null>(null);
const loading = ref(false);
// Honour the chosen boxart style here too — the picked cover matches the
// gallery. `useCoverArt` resolves the styled art + webp; we fall back to
// the generated placeholder when the rom has no cover at all.
const art = useCoverArt(() => pick.value);
const coverSrc = computed(() => {
const r = pick.value;
if (!r) return "";
return (
art.coverUrl.value ??
art.fallbackUrl.value ??
coverPlaceholderArt(r.name || r.fs_name, r.is_identified)
);
});
const coverContain = computed(() => art.objectFit.value === "contain");
async function reroll() {
loading.value = true;
try {
@@ -73,13 +57,10 @@ onMounted(reroll);
<template>
<WidgetCard :title="t('home.widget-random-pick')" :loading="loading">
<div v-if="pick" class="r-v2-widget-pick__body">
<RImg
:src="coverSrc"
:alt="pick.name || pick.fs_name"
:width="52"
:height="70"
:cover="!coverContain"
:contain="coverContain"
<GameCover
:rom="pick"
:title="pick.name || pick.fs_name"
:identified="pick.is_identified"
class="r-v2-widget-pick__cover"
/>
<div class="r-v2-widget-pick__info">
@@ -126,10 +107,15 @@ onMounted(reroll);
min-width: 0;
}
.r-v2-widget-pick__cover {
/* Fixed height, natural width — the cover renders at its image's true
aspect (GameCover measures it), matching the gallery. The descendant
selector outweighs GameCover's base `width: 100%` so width can follow
the ratio. */
.r-v2-widget-pick__body .r-v2-widget-pick__cover {
height: 70px;
width: auto;
flex-shrink: 0;
border-radius: var(--r-radius-sm);
overflow: hidden;
--r-cover-radius: var(--r-radius-sm);
}
.r-v2-widget-pick__info {

View File

@@ -85,7 +85,7 @@ function coverFor(rom: SimpleRom): string {
:alt="displayName"
:width="36"
:height="48"
cover
contain
class="r-v2-scan-platform__cover"
:class="{ 'r-v2-scan-platform__cover--reveal': coverPop }"
@load="onCoverLoad"

View File

@@ -37,6 +37,9 @@ const artUrl = computed(() =>
align-items: center;
justify-content: center;
overflow: hidden;
/* Query container so the title can scale with the cover box's size
(tiny list thumb → big detail hero) instead of a fixed px. */
container-type: size;
}
.cover-ph__art {
position: absolute;
@@ -62,9 +65,9 @@ const artUrl = computed(() =>
position: relative;
z-index: 1;
max-width: 100%;
padding: 8px 10px;
font-size: clamp(8px, 8cqmin, 18px);
padding: 0.5em 0.7em;
text-align: center;
font-size: 12px;
font-weight: var(--r-font-weight-semibold);
line-height: 1.35;
color: var(--r-color-overlay-fg);

View File

@@ -81,6 +81,12 @@ const props = withDefaults(defineProps<Props>(), {
morphStatic: false,
});
const emit = defineEmits<{
/** The rendered image's natural ratio (w / h) once loaded — lets the
* gallery's wrapping rows pack by true shape. */
ratio: [number];
}>();
const art = useCoverArt(() => props.rom, {
coverSrc: () => props.coverSrc,
forceStyle: props.forceStyle
@@ -115,12 +121,27 @@ const coverLoaded = ref(false);
const activeSrc = computed(() =>
showFallback.value ? art.fallbackUrl.value : art.coverUrl.value,
);
// Natural ratio (w / h) of the rendered image, measured on load — drives
// the box shape. Null until decoded / for the placeholder (→ style ratio).
const naturalRatio = ref<number | null>(null);
function measureNaturalRatio() {
const el = imgEl.value;
if (el && el.naturalWidth > 0 && el.naturalHeight > 0) {
const r = el.naturalWidth / el.naturalHeight;
naturalRatio.value = r;
emit("ratio", r);
}
}
watch(activeSrc, () => {
coverLoaded.value = false;
naturalRatio.value = null;
});
const onCoverLoad = () => {
coverLoaded.value = true;
measureNaturalRatio();
};
// True ratio once known, else the style ratio as a first guess.
const boxRatio = computed(() => naturalRatio.value ?? art.ratio.value);
const selfHover = ref(false);
const coverActive = computed(
@@ -161,6 +182,7 @@ onMounted(() => {
// soft initial paint instead of getting stuck blurred).
if (imgEl.value?.complete && imgEl.value.naturalWidth > 0) {
coverLoaded.value = true;
measureNaturalRatio();
}
if (!props.hoverMotion) return;
rootEl.value?.addEventListener("mouseenter", onEnter);
@@ -187,7 +209,7 @@ defineExpose({
ref="rootEl"
class="game-cover"
:class="{ 'game-cover--alt': isAltStyle }"
:style="[{ '--r-cover-ratio': art.ratio.value }, morphStyle]"
:style="[{ '--r-cover-ratio': boxRatio }, morphStyle]"
>
<img
v-if="showingImage"

View File

@@ -0,0 +1,77 @@
import { mount } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { defineComponent } from "vue";
import storeGalleryRoms from "@/v2/stores/galleryRoms";
import { useGalleryCoverRatios } from "./index";
// Run the composable inside a real component so its `onBeforeUnmount`
// cleanup has a host instance. Returns the composable's result.
function withComposable<T>(fn: () => T): T {
let result!: T;
mount(
defineComponent({
setup() {
result = fn();
return () => null;
},
}),
);
return result;
}
describe("useGalleryCoverRatios", () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("ratioAt maps position → rom id → measured ratio, else 0", () => {
storeGalleryRoms().romIdIndex = [101, 102, 103];
const { ratioAt, onCardRatio } = withComposable(() =>
useGalleryCoverRatios(),
);
expect(ratioAt(1)).toBe(0); // not yet measured
expect(ratioAt(99)).toBe(0); // position outside the index
onCardRatio({ romId: 102, ratio: 1.2 });
expect(ratioAt(1)).toBeCloseTo(1.2); // position 1 → rom 102
expect(ratioAt(0)).toBe(0); // rom 101 still unmeasured
});
it("bumps ratioVersion once per debounce burst", () => {
storeGalleryRoms().romIdIndex = [101, 102];
const { ratioVersion, onCardRatio } = withComposable(() =>
useGalleryCoverRatios(),
);
expect(ratioVersion.value).toBe(0);
onCardRatio({ romId: 101, ratio: 0.7 });
onCardRatio({ romId: 102, ratio: 0.5 });
expect(ratioVersion.value).toBe(0); // still debouncing
vi.advanceTimersByTime(150);
expect(ratioVersion.value).toBe(1); // one bump for the whole burst
});
it("ignores sub-epsilon changes (no re-store, no extra re-pack)", () => {
storeGalleryRoms().romIdIndex = [101];
const { ratioVersion, ratioAt, onCardRatio } = withComposable(() =>
useGalleryCoverRatios(),
);
onCardRatio({ romId: 101, ratio: 0.7 });
vi.advanceTimersByTime(150);
expect(ratioVersion.value).toBe(1);
expect(ratioAt(0)).toBeCloseTo(0.7);
onCardRatio({ romId: 101, ratio: 0.705 }); // delta 0.005 < 0.01
vi.advanceTimersByTime(150);
expect(ratioVersion.value).toBe(1); // no new bump
expect(ratioAt(0)).toBeCloseTo(0.7); // value unchanged
});
});

View File

@@ -0,0 +1,55 @@
// useGalleryCoverRatios — the gallery's measured natural cover ratios.
//
// Covers render at their image's natural aspect, so the flow-packer needs
// each card's ratio (width = cardHeight * ratio) to decide where rows break.
// The API doesn't ship cover dimensions, so the only source is the image
// itself: GameCard reports `@ratio` once its cover loads, and this collects
// the values for the packer to read via `ratioAt(position)`.
//
// Keyed by rom id (not position), so the key survives a gallery context
// switch — a re-visited platform re-packs without re-waiting on images.
// Intentionally unbounded: two numbers per rom, reset on page reload.
//
// Updates batch behind `ratioVersion`: a burst of image loads bumps it once
// (after a short debounce) so the packed layout recomputes a single time
// instead of once per cover.
import { onBeforeUnmount, ref } from "vue";
import storeGalleryRoms from "@/v2/stores/galleryRoms";
// Below this delta a new measurement isn't worth a re-pack (sub-pixel noise).
const RATIO_EPSILON = 0.01;
// Wait this long after the last new ratio before bumping `ratioVersion`.
const REPACK_DEBOUNCE_MS = 150;
export function useGalleryCoverRatios() {
const galleryRoms = storeGalleryRoms();
const ratioByRomId = new Map<number, number>();
const ratioVersion = ref(0);
let bumpTimer: ReturnType<typeof setTimeout> | null = null;
/** Record a cover's measured ratio (GameCard's `@ratio` handler). */
function onCardRatio(payload: { romId: number; ratio: number }) {
const prev = ratioByRomId.get(payload.romId);
if (prev != null && Math.abs(prev - payload.ratio) < RATIO_EPSILON) return;
ratioByRomId.set(payload.romId, payload.ratio);
if (bumpTimer) return;
bumpTimer = setTimeout(() => {
bumpTimer = null;
ratioVersion.value++;
}, REPACK_DEBOUNCE_MS);
}
/** Measured ratio for a position, or 0 when unknown (the packer then
* falls back to its default ratio). */
function ratioAt(position: number): number {
const romId = galleryRoms.romIdIndex[position];
if (romId == null) return 0;
return ratioByRomId.get(romId) ?? 0;
}
onBeforeUnmount(() => {
if (bumpTimer) clearTimeout(bumpTimer);
});
return { ratioVersion, ratioAt, onCardRatio };
}

View File

@@ -68,6 +68,40 @@ export function galleryRowHeight(
return Math.round(cardWidth / ratio) + ROW_CHROME_PX;
}
/** Flow-pack a contiguous run of positions into width-filling rows: each
* card is `cardHeight * ratio` wide and wraps when it would overflow (an
* over-wide card gets its own row). Pure; `ratioAt` applies its own default. */
export function packFlowRows(
start: number,
end: number,
rowWidth: number,
cardHeight: number,
gap: number,
ratioAt: (position: number) => number,
): Array<{ start: number; end: number }> {
const rows: Array<{ start: number; end: number }> = [];
if (end <= start) return rows;
let rowStart = start;
let acc = 0;
for (let p = start; p < end; p++) {
const w = cardHeight * ratioAt(p);
if (p === rowStart) {
acc = w;
continue;
}
const next = acc + gap + w;
if (next > rowWidth) {
rows.push({ start: rowStart, end: p });
rowStart = p;
acc = w;
} else {
acc = next;
}
}
rows.push({ start: rowStart, end });
return rows;
}
interface Options {
layout: Ref<LayoutMode> | ComputedRef<LayoutMode>;
groupBy: Ref<GroupByMode> | ComputedRef<GroupByMode>;
@@ -89,13 +123,18 @@ interface Options {
notFoundMessage?: Ref<string> | ComputedRef<string>;
/** Skeleton row count while loading the first window. */
skeletonRowCount?: number;
/** Active cover aspect ratio (width / height) — drives grid row height
* so AlphaStrip jumps and scroll offsets stay exact when the boxart
* style changes the cover shape. Defaults to box-art 2/3. */
coverRatio?: MaybeRefOrGetter<number>;
/** Card-art width used to derive the row height (match the value fed to
* `useResponsiveColumns`). Defaults to the md card (158px). */
cardWidth?: MaybeRefOrGetter<number>;
/** Fixed card-art height in px, shared by every card so every row has the
* same height. Defaults to the md footprint (158px / (2/3) → 237px). */
cardHeight?: MaybeRefOrGetter<number>;
/** Usable px width of a row (container minus gutters / AlphaStrip). */
rowWidth?: MaybeRefOrGetter<number>;
/** Horizontal gap between cards in px (default 12). */
gap?: MaybeRefOrGetter<number>;
/** Natural cover ratio (w / h) for a position (card width =
* `cardHeight * ratioAt(p)`). Defaults to 2/3 until the image is measured. */
ratioAt?: (position: number) => number;
/** Bump to force a re-pack when measured ratios change (Vue tracks it). */
ratioVersion?: Ref<number> | ComputedRef<number>;
}
const ALPHABET = "#ABCDEFGHIJKLMNOPQRSTUVWXYZ@".split("");
@@ -156,21 +195,22 @@ export function useGalleryVirtualItems(opts: Options) {
buildLetterRanges(opts.charIndex.value, opts.total.value),
);
// Per-style row height — recomputes when the boxart ratio or the
// responsive card width changes, so every grid row keeps an exact
// offset (the scroller is exact-offset, not measured).
const rowHeightPx = computed(() =>
galleryRowHeight(
opts.coverRatio != null ? toValue(opts.coverRatio) : DEFAULT_COVER_RATIO,
opts.cardWidth != null
? toValue(opts.cardWidth)
: REFERENCE_COVER_WIDTH_PX,
),
);
const cardHeightPx = () =>
opts.cardHeight != null
? toValue(opts.cardHeight)
: Math.round(REFERENCE_COVER_WIDTH_PX / DEFAULT_COVER_RATIO);
// Signature matches RVirtualScroller's `getItemHeight` prop (which uses
// `unknown` because the primitive is generic). Row / skeleton-row use
// the per-style height; every other kind is fixed.
// Uniform row height (cards share one fixed art height) keeps the scroller
// exact-offset regardless of how many variable-width cards a row holds.
const rowHeightPx = computed(() => cardHeightPx() + ROW_CHROME_PX);
const ratioAt = (position: number): number => {
const r = opts.ratioAt?.(position);
return r != null && r > 0 ? r : DEFAULT_COVER_RATIO;
};
// `unknown` matches RVirtualScroller's generic prop. Row / skeleton-row
// share the uniform height; every other kind is fixed.
function getItemHeight(item: unknown): number {
const { kind } = item as GalleryItem;
if (kind === "row" || kind === "skeleton-row") return rowHeightPx.value;
@@ -259,47 +299,59 @@ export function useGalleryVirtualItems(opts: Options) {
return items;
}
const cols = Math.max(1, opts.columns.value);
const total = opts.total.value;
const ranges = letterRanges.value;
const cardHeight = cardHeightPx();
const gap = opts.gap != null ? toValue(opts.gap) : 12;
// At least one card wide before the container is measured.
const rowWidth = Math.max(
cardHeight,
opts.rowWidth != null ? toValue(opts.rowWidth) : 0,
);
// Track the version so a measured-ratio change re-runs the pack.
void (opts.ratioVersion ? opts.ratioVersion.value : 0);
if (opts.groupBy.value === "letter") {
// Group by letter — each letter section gets a header followed by
// its own row chunks (rows always restart at the letter's first
// position, so a letter never shares a visual row with another).
// Header + own flow-packed rows per letter (rows restart per letter).
for (const range of ranges) {
items.push({
kind: "letter-header",
key: `lh-${range.letter}`,
letter: range.letter,
});
const rowsInGroup = Math.ceil((range.end - range.start) / cols);
for (let r = 0; r < rowsInGroup; r++) {
const rowStart = range.start + r * cols;
const rowEnd = Math.min(rowStart + cols, range.end);
for (const row of packFlowRows(
range.start,
range.end,
rowWidth,
cardHeight,
gap,
ratioAt,
)) {
items.push({
kind: "row",
key: `row-${range.letter}-${r}`,
rowIndex: r,
startPosition: rowStart,
endPosition: rowEnd,
key: `row-${row.start}`,
startPosition: row.start,
endPosition: row.end,
letters: [range.letter],
});
}
}
} else {
// Flat — rows are aligned to absolute positions.
const totalRows = Math.ceil(total / cols);
for (let r = 0; r < totalRows; r++) {
const rowStart = r * cols;
const rowEnd = Math.min(rowStart + cols, total);
// Flat — flow-pack the whole list; rows stay contiguous position runs.
for (const row of packFlowRows(
0,
total,
rowWidth,
cardHeight,
gap,
ratioAt,
)) {
items.push({
kind: "row",
key: `row-flat-${r}`,
rowIndex: r,
startPosition: rowStart,
endPosition: rowEnd,
letters: lettersInRange(ranges, rowStart, rowEnd),
key: `row-${row.start}`,
startPosition: row.start,
endPosition: row.end,
letters: lettersInRange(ranges, row.start, row.end),
});
}
}
@@ -343,20 +395,20 @@ export function useGalleryVirtualItems(opts: Options) {
}
if (opts.groupBy.value !== "letter") {
// Flat mode — for each letter range, jump to the row that holds
// its first position.
let firstRowIdx = -1;
for (let i = 0; i < items.length; i++) {
if (items[i].kind === "row") {
firstRowIdx = i;
break;
}
}
if (firstRowIdx >= 0) {
const cols = Math.max(1, opts.columns.value);
for (const range of letterRanges.value) {
if (map.has(range.letter)) continue;
map.set(range.letter, firstRowIdx + Math.floor(range.start / cols));
// Map each letter to the variable-size row containing its first
// position. Both rows and ranges are ascending → one forward walk.
const ranges = letterRanges.value; // sorted by start
let li = 0;
for (let i = 0; i < items.length && li < ranges.length; i++) {
const it = items[i];
if (it.kind !== "row") continue;
while (
li < ranges.length &&
ranges[li].start >= it.startPosition &&
ranges[li].start < it.endPosition
) {
if (!map.has(ranges[li].letter)) map.set(ranges[li].letter, i);
li++;
}
}
}

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { packFlowRows } from "./index";
// Card width = H * ratio. H=100, GAP=12 → a square card is 100px wide.
const SQUARE = () => 1;
const H = 100;
const GAP = 12;
describe("packFlowRows", () => {
it("fills a row until the next card would overflow, then wraps", () => {
// Row 340: 100+12+100+12+100=324 fits, a 4th overflows → 3 per row.
const rows = packFlowRows(0, 7, 340, H, GAP, SQUARE);
expect(rows).toEqual([
{ start: 0, end: 3 },
{ start: 3, end: 6 },
{ start: 6, end: 7 },
]);
});
it("packs more, narrower cards per row when covers are portrait", () => {
// Portrait 0.5 → 50px wide; all 5 fit in 340.
const rows = packFlowRows(0, 5, 340, H, GAP, () => 0.5);
expect(rows).toEqual([{ start: 0, end: 5 }]);
});
it("gives an over-wide card its own row instead of an empty one", () => {
// ratio 4 → 400px wide > row 340 → each alone.
const rows = packFlowRows(0, 2, 340, H, GAP, () => 4);
expect(rows).toEqual([
{ start: 0, end: 1 },
{ start: 1, end: 2 },
]);
});
it("mixes widths within a row by running natural width", () => {
// widths 50,200,50,50: first 3 = 324 fit, 4th wraps.
const ratioAt = (p: number) => (p === 1 ? 2 : 0.5);
const rows = packFlowRows(0, 4, 340, H, GAP, ratioAt);
expect(rows).toEqual([
{ start: 0, end: 3 },
{ start: 3, end: 4 },
]);
});
it("returns nothing for an empty range", () => {
expect(packFlowRows(5, 5, 340, H, GAP, SQUARE)).toEqual([]);
});
});

View File

@@ -22,7 +22,7 @@ export type GalleryItem =
| {
kind: "row";
key: string;
rowIndex: number;
/** A contiguous, variable-length run of positions (flow-packed). */
startPosition: number;
endPosition: number; // exclusive
/** Letters covered by this row's position range (from server's

View File

@@ -45,6 +45,9 @@ export function useResponsiveColumns(
const min = options.min ?? 1;
const columns = ref<number>(min);
// Observed content width minus `inset` — px available to a row of cards,
// for consumers that flow-pack by width rather than a fixed column count.
const usableWidth = ref<number>(0);
let observer: ResizeObserver | null = null;
// Last observed width — kept so a change in a reactive option (card
// width / inset flipping at a breakpoint) can recompute without waiting
@@ -58,6 +61,7 @@ export function useResponsiveColumns(
const inset = resolve(options.inset, 0);
const usable = width - inset;
if (usable <= 0) return;
if (usable !== usableWidth.value) usableWidth.value = usable;
// CSS auto-fill semantics: floor((containerWidth + gap) / (cardWidth + gap))
const next = Math.max(min, Math.floor((usable + gap) / (cardWidth + gap)));
if (next !== columns.value) columns.value = next;
@@ -101,5 +105,5 @@ export function useResponsiveColumns(
onBeforeUnmount(detach);
return { columns };
return { columns, usableWidth };
}