mirror of
https://github.com/rommapp/romm.git
synced 2026-06-28 06:46:00 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -32,7 +32,7 @@ defineProps<Props>();
|
||||
:alt="romName"
|
||||
width="100%"
|
||||
aspect-ratio="2/3"
|
||||
cover
|
||||
contain
|
||||
/>
|
||||
<RChip
|
||||
color="success"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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-
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
55
frontend/src/v2/composables/useGalleryCoverRatios/index.ts
Normal file
55
frontend/src/v2/composables/useGalleryCoverRatios/index.ts
Normal 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 };
|
||||
}
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user