mirror of
https://github.com/rommapp/romm.git
synced 2026-10-12 02:21:58 +00:00
Merge pull request #4792 from rommapp/fix/platform-icon-manifest
fix(frontend): only request platform icons that ship
This commit is contained in:
@@ -107,7 +107,7 @@ import { useCan } from "@/v2/composables/useCan";
|
||||
- Every primitive ships at least one story with controls and at least one variant per theme.
|
||||
- A new interactive primitive that warrants gamepad navigation ships a `play()` interaction.
|
||||
- Modified primitive: existing story must still render and its interactions still pass.
|
||||
- `npm run test` runs Vitest **and** every `/lib` story's `play()` via `composeStories`. Don't duplicate coverage between Vitest (pure logic) and Storybook `play()` (components).
|
||||
- `npm run test` runs Vitest **and** every `/lib` and `components/shared` story's `play()` via `composeStories`. Don't duplicate coverage between Vitest (pure logic) and Storybook `play()` (components).
|
||||
- **Responsive QA:** `.storybook/rommViewports.ts` + viewport globals in `preview.ts`; see `frontend-v2-input` for `data-bp` vs iframe width.
|
||||
|
||||
---
|
||||
|
||||
@@ -145,7 +145,7 @@ Run from `frontend/`:
|
||||
1. `npm run typecheck`: zero errors (`vue-tsc --noEmit`).
|
||||
2. `npm run typecheck:scripts`: zero errors (`tsc -p tsconfig.node.json`, covers `scripts/`).
|
||||
3. `npm run lint` _(if present)_ / ESLint clean. Trunk also runs ESLint + Prettier in CI.
|
||||
4. `npm run test`: zero failures (Vitest + happy-dom; runs unit tests **and** every `/lib` story's `play()` via `composeStories`).
|
||||
4. `npm run test`: zero failures (Vitest + happy-dom; runs unit tests **and** every `/lib` and `components/shared` story's `play()` via `composeStories`).
|
||||
5. `npm run build`: zero failures (CI sanity check).
|
||||
|
||||
**If you touched the backend API:** start the backend, run `npm run generate`, then re-`typecheck`.
|
||||
|
||||
+1
-1
@@ -183,7 +183,7 @@ Component docs and visual QA for `frontend/src/v2/` (port 6006):
|
||||
|
||||
```sh
|
||||
npm run storybook
|
||||
npm run storybook:test # composeStories + play() + a11y (v2 /lib stories)
|
||||
npm run storybook:test # composeStories + play() + a11y (v2 /lib and components/shared stories)
|
||||
```
|
||||
|
||||
For responsive layouts, use the viewport toolbar presets from `.storybook/rommViewports.ts`.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Exposes the icons under assets/platforms as `virtual:platform-icons`, so
|
||||
// the app can skip requests for icons that were never shipped.
|
||||
import { readdirSync } from "node:fs";
|
||||
import { dirname, extname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
const VIRTUAL_ID = "virtual:platform-icons";
|
||||
const RESOLVED_ID = `\0${VIRTUAL_ID}`;
|
||||
const ICON_FILE = /\.(svg|ico)$/i;
|
||||
|
||||
/** Lowercase slug to shipped filename; `.svg` wins over `.ico` when both ship. */
|
||||
export function listPlatformIcons(dir: string): Map<string, string> {
|
||||
const icons = new Map<string, string>();
|
||||
const files = readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && ICON_FILE.test(entry.name))
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
for (const file of files) {
|
||||
const ext = extname(file);
|
||||
const slug = file.slice(0, -ext.length).toLowerCase();
|
||||
if (ext.toLowerCase() === ".svg" || !icons.has(slug)) icons.set(slug, file);
|
||||
}
|
||||
return icons;
|
||||
}
|
||||
|
||||
export function platformIconManifest(): Plugin {
|
||||
const iconDir = fileURLToPath(
|
||||
new URL("../assets/platforms", import.meta.url),
|
||||
);
|
||||
return {
|
||||
name: "romm:platform-icon-manifest",
|
||||
resolveId(id) {
|
||||
return id === VIRTUAL_ID ? RESOLVED_ID : undefined;
|
||||
},
|
||||
load(id) {
|
||||
if (id !== RESOLVED_ID) return undefined;
|
||||
const entries = [...listPlatformIcons(iconDir)];
|
||||
return `export default new Map(${JSON.stringify(entries)});`;
|
||||
},
|
||||
// An icon added or removed while the dev server runs would otherwise
|
||||
// stay invisible until a restart.
|
||||
configureServer(server) {
|
||||
const refresh = (file: string) => {
|
||||
if (dirname(file) !== iconDir || !ICON_FILE.test(file)) return;
|
||||
const mod = server.moduleGraph.getModuleById(RESOLVED_ID);
|
||||
if (mod) server.moduleGraph.invalidateModule(mod);
|
||||
server.ws.send({ type: "full-reload" });
|
||||
};
|
||||
server.watcher.on("add", refresh);
|
||||
server.watcher.on("unlink", refresh);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
RCollapsible,
|
||||
REmptyState,
|
||||
RIcon,
|
||||
RPlatformIcon,
|
||||
RSliderBtnGroup,
|
||||
RTag,
|
||||
RTextField,
|
||||
@@ -31,6 +30,7 @@ import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { SetupLibraryInfo } from "@/services/api/setup";
|
||||
import type { Platform } from "@/stores/platforms";
|
||||
import PlatformIcon from "@/v2/components/shared/PlatformIcon.vue";
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
@@ -392,7 +392,7 @@ const detectedPlatformCount = computed(
|
||||
class="r-setup-platforms__item"
|
||||
data-state="detected"
|
||||
>
|
||||
<RPlatformIcon
|
||||
<PlatformIcon
|
||||
:slug="platform.slug"
|
||||
:fs-slug="platform.fs_slug"
|
||||
:name="platform.name"
|
||||
@@ -527,7 +527,7 @@ const detectedPlatformCount = computed(
|
||||
(v) => togglePlatform(platform.fs_slug, v)
|
||||
"
|
||||
/>
|
||||
<RPlatformIcon
|
||||
<PlatformIcon
|
||||
:slug="platform.slug"
|
||||
:fs-slug="platform.fs_slug"
|
||||
:name="platform.name"
|
||||
@@ -626,7 +626,7 @@ const detectedPlatformCount = computed(
|
||||
(v) => togglePlatform(platform.fs_slug, v)
|
||||
"
|
||||
/>
|
||||
<RPlatformIcon
|
||||
<PlatformIcon
|
||||
:slug="platform.slug"
|
||||
:fs-slug="platform.fs_slug"
|
||||
:name="platform.name"
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
RChip,
|
||||
RIcon,
|
||||
RMarquee,
|
||||
RPlatformIcon,
|
||||
RSkeletonBlock,
|
||||
RTooltip,
|
||||
} from "@v2/lib";
|
||||
@@ -32,6 +31,7 @@ import ProviderBadges from "@/v2/components/Gallery/ProviderBadges.vue";
|
||||
import GameActionBtn from "@/v2/components/GameActions/GameActionBtn.vue";
|
||||
import GameCard from "@/v2/components/GameCard/GameCard.vue";
|
||||
import SiblingBadge from "@/v2/components/GameCard/SiblingBadge.vue";
|
||||
import PlatformIcon from "@/v2/components/shared/PlatformIcon.vue";
|
||||
import { useBackgroundArt } from "@/v2/composables/useBackgroundArt";
|
||||
import { useBreakpoint } from "@/v2/composables/useBreakpoint";
|
||||
import { useGallerySelectionInput } from "@/v2/composables/useGallerySelectionInput";
|
||||
@@ -395,7 +395,7 @@ function onRowPointerDown(e: PointerEvent) {
|
||||
</div>
|
||||
<div class="r-list-compact__facts">
|
||||
<template v-if="platformName">
|
||||
<RPlatformIcon
|
||||
<PlatformIcon
|
||||
class="game-list-row__facts-icon"
|
||||
:slug="rom.platform_slug"
|
||||
:fs-slug="rom.platform_fs_slug"
|
||||
@@ -558,7 +558,7 @@ function onRowPointerDown(e: PointerEvent) {
|
||||
v-if="showPlatformColumn"
|
||||
class="game-list-row__cell game-list-row__platform"
|
||||
>
|
||||
<RPlatformIcon
|
||||
<PlatformIcon
|
||||
v-if="platformMeta?.slug"
|
||||
:slug="platformMeta.slug"
|
||||
:size="24"
|
||||
|
||||
@@ -29,7 +29,7 @@ function mountRow(props: Record<string, unknown> = {}) {
|
||||
RCheckbox: true,
|
||||
RChip: true,
|
||||
RIcon: true,
|
||||
RPlatformIcon: true,
|
||||
PlatformIcon: true,
|
||||
RTooltip: true,
|
||||
SiblingBadge: true,
|
||||
},
|
||||
|
||||
@@ -63,7 +63,7 @@ function mountHydratedRow() {
|
||||
GameCard: true,
|
||||
RCheckbox: true,
|
||||
RChip: true,
|
||||
RPlatformIcon: true,
|
||||
PlatformIcon: true,
|
||||
RTooltip: true,
|
||||
SiblingBadge: true,
|
||||
},
|
||||
|
||||
@@ -12,12 +12,13 @@
|
||||
//
|
||||
// All admin actions are forwarded as events; permission gating lives
|
||||
// on the parent so the bar stays in sync with `useCan`.
|
||||
import { RBtn, RChip, RIcon, RPlatformIcon, RTabNav } from "@v2/lib";
|
||||
import { RBtn, RChip, RIcon, RTabNav } from "@v2/lib";
|
||||
import type { RTabNavItem } from "@v2/lib";
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { Platform } from "@/stores/platforms";
|
||||
import InfoPanel from "@/v2/components/Gallery/InfoPanel.vue";
|
||||
import PlatformIcon from "@/v2/components/shared/PlatformIcon.vue";
|
||||
import Stat from "@/v2/components/shared/Stat.vue";
|
||||
import { useBreakpoint } from "@/v2/composables/useBreakpoint";
|
||||
import {
|
||||
@@ -92,7 +93,7 @@ const iconSize = computed(() => (xs.value ? 116 : 148));
|
||||
class="r-v2-plat__panel-icon"
|
||||
:style="{ viewTransitionName: `platform-icon-${platform.id}` }"
|
||||
>
|
||||
<RPlatformIcon
|
||||
<PlatformIcon
|
||||
:slug="platform.slug"
|
||||
:fs-slug="platform.fs_slug"
|
||||
:alt="platform.display_name"
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
RAvatar,
|
||||
RBtn,
|
||||
RDialog,
|
||||
RPlatformIcon,
|
||||
RSelect,
|
||||
RSwitch,
|
||||
RTooltip,
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { Platform } from "@/stores/platforms";
|
||||
import PlatformIcon from "@/v2/components/shared/PlatformIcon.vue";
|
||||
import { useScanProviders } from "@/v2/composables/useScanProviders";
|
||||
import { useScanTrigger } from "@/v2/composables/useScanTrigger";
|
||||
import { useSnackbar } from "@/v2/composables/useSnackbar";
|
||||
@@ -140,7 +140,7 @@ function onScan() {
|
||||
scan-launching surfaces read as siblings. -->
|
||||
<div class="r-v2-scan-plat__head">
|
||||
<div class="r-v2-scan-plat__icon">
|
||||
<RPlatformIcon
|
||||
<PlatformIcon
|
||||
:slug="platform.slug"
|
||||
:fs-slug="platform.fs_slug"
|
||||
:alt="platform.display_name"
|
||||
|
||||
@@ -47,6 +47,7 @@ import CardFlags from "@/v2/components/GameCard/CardFlags.vue";
|
||||
import SiblingBadge from "@/v2/components/GameCard/SiblingBadge.vue";
|
||||
import CoverArtPip from "@/v2/components/shared/CoverArtPip.vue";
|
||||
import GameCover from "@/v2/components/shared/GameCover.vue";
|
||||
import PlatformIcon from "@/v2/components/shared/PlatformIcon.vue";
|
||||
import { useBackgroundArt } from "@/v2/composables/useBackgroundArt";
|
||||
import { useCoverArt } from "@/v2/composables/useCoverArt";
|
||||
import { useGallerySelectionInput } from "@/v2/composables/useGallerySelectionInput";
|
||||
@@ -56,7 +57,6 @@ import {
|
||||
} from "@/v2/composables/useGameActions";
|
||||
import { useViewTransition } from "@/v2/composables/useViewTransition";
|
||||
import RCheckbox from "@/v2/lib/forms/RCheckbox/RCheckbox.vue";
|
||||
import RPlatformIcon from "@/v2/lib/media/RPlatformIcon/RPlatformIcon.vue";
|
||||
import RBtn from "@/v2/lib/primitives/RBtn/RBtn.vue";
|
||||
import RTooltip from "@/v2/lib/structural/RTooltip/RTooltip.vue";
|
||||
import storeGallerySelection from "@/v2/stores/gallerySelection";
|
||||
@@ -457,7 +457,7 @@ function onStaticKeydown(e: KeyboardEvent) {
|
||||
"
|
||||
@click="onPlatformClick"
|
||||
>
|
||||
<RPlatformIcon
|
||||
<PlatformIcon
|
||||
:slug="rom.platform_slug"
|
||||
:fs-slug="rom.platform_fs_slug"
|
||||
:alt="platformShort"
|
||||
@@ -650,7 +650,7 @@ function onStaticKeydown(e: KeyboardEvent) {
|
||||
/* Shrink-wrap around the platform icon. RBtn's size classes try to
|
||||
impose a fixed `height` / `width: var(--r-btn-rest-h)` — we have
|
||||
to defeat those so the badge always fits whatever `:size` the
|
||||
RPlatformIcon was given (icon + 3px padding all around). */
|
||||
PlatformIcon was given (icon + 3px padding all around). */
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
min-width: 0 !important;
|
||||
|
||||
@@ -9,13 +9,14 @@
|
||||
//
|
||||
// Metadata-provider links live in the Metadata tab, not the header.
|
||||
// Genre/franchise belong in the Overview tab info grid.
|
||||
import { RIcon, RPlatformIcon, RTag, RTooltip } from "@v2/lib";
|
||||
import { RIcon, RTag, RTooltip } from "@v2/lib";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { DetailedRom } from "@/stores/roms";
|
||||
import GameActions from "@/v2/components/GameActions/GameActions.vue";
|
||||
import MainSiblingToggle from "@/v2/components/GameDetails/MainSiblingToggle.vue";
|
||||
import PrevNextNav from "@/v2/components/GameDetails/PrevNextNav.vue";
|
||||
import VersionSwitcher from "@/v2/components/GameDetails/VersionSwitcher.vue";
|
||||
import PlatformIcon from "@/v2/components/shared/PlatformIcon.vue";
|
||||
import { useBreakpoint } from "@/v2/composables/useBreakpoint";
|
||||
import { useGameActions } from "@/v2/composables/useGameActions";
|
||||
import { searchLocation } from "@/v2/utils/searchLocation";
|
||||
@@ -56,7 +57,7 @@ const actions = useGameActions(() => props.rom);
|
||||
class="r-v2-det-header__platform"
|
||||
:aria-label="t('platform.browse-platform', { platform: platformLabel })"
|
||||
>
|
||||
<RPlatformIcon
|
||||
<PlatformIcon
|
||||
:slug="rom.platform_slug"
|
||||
:fs-slug="rom.platform_fs_slug"
|
||||
:alt="platformLabel"
|
||||
|
||||
@@ -119,7 +119,7 @@ describe("PatcherTab", () => {
|
||||
RDropzone: true,
|
||||
RExpandTransition: { template: "<div><slot /></div>" },
|
||||
RIcon: true,
|
||||
RPlatformIcon: true,
|
||||
PlatformIcon: true,
|
||||
RSelect,
|
||||
RTextField: true,
|
||||
RTooltip: true,
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
RDropzone,
|
||||
RExpandTransition,
|
||||
RIcon,
|
||||
RPlatformIcon,
|
||||
RSelect,
|
||||
RTextField,
|
||||
RTooltip,
|
||||
@@ -29,6 +28,7 @@ import storeScanning from "@/stores/scanning";
|
||||
import storeUpload from "@/stores/upload";
|
||||
import { formatBytes } from "@/utils";
|
||||
import MissingFSBadge from "@/v2/components/shared/MissingFSBadge.vue";
|
||||
import PlatformIcon from "@/v2/components/shared/PlatformIcon.vue";
|
||||
import PlatformSelect from "@/v2/components/shared/PlatformSelect.vue";
|
||||
import { useCan } from "@/v2/composables/useCan";
|
||||
import { useSnackbar } from "@/v2/composables/useSnackbar";
|
||||
@@ -375,7 +375,7 @@ const applyLabel = computed(() => {
|
||||
</div>
|
||||
|
||||
<div class="r-v2-patch__rom-info">
|
||||
<RPlatformIcon
|
||||
<PlatformIcon
|
||||
:slug="rom.platform_slug"
|
||||
:fs-slug="rom.platform_fs_slug"
|
||||
:name="rom.platform_display_name"
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
// PlatformListRow — single row of the Platforms list-mode index.
|
||||
//
|
||||
// Anatomy mirrors GameListRow: thumb (RPlatformIcon) + name stack on the
|
||||
// Anatomy mirrors GameListRow: thumb (PlatformIcon) + name stack on the
|
||||
// left, game count column on the right. Click navigates to /platform/<id>
|
||||
// with the same shared-element morph as PlatformTile so switching between
|
||||
// grid and list modes lands on the same destination animation.
|
||||
import { RPlatformIcon } from "@v2/lib";
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
import PlatformIcon from "@/v2/components/shared/PlatformIcon.vue";
|
||||
import { useBreakpoint } from "@/v2/composables/useBreakpoint";
|
||||
import { usePlatformPlayable } from "@/v2/composables/usePlatformPlayable";
|
||||
import { useStaggeredEntrance } from "@/v2/composables/useStaggeredEntrance";
|
||||
@@ -104,7 +104,7 @@ function onRowClick(e: MouseEvent) {
|
||||
@animationend.self="endEntrance"
|
||||
>
|
||||
<div ref="iconEl" class="plat-list-row__thumb" :style="morphStyle">
|
||||
<RPlatformIcon
|
||||
<PlatformIcon
|
||||
:slug="slug"
|
||||
:fs-slug="fsSlug"
|
||||
:alt="displayName"
|
||||
@@ -153,7 +153,7 @@ function onRowClick(e: MouseEvent) {
|
||||
>
|
||||
<div class="plat-list-row__cell plat-list-row__title">
|
||||
<div ref="iconEl" class="plat-list-row__thumb" :style="morphStyle">
|
||||
<RPlatformIcon
|
||||
<PlatformIcon
|
||||
:slug="slug"
|
||||
:fs-slug="fsSlug"
|
||||
:alt="displayName"
|
||||
|
||||
@@ -13,8 +13,8 @@ vi.mock("vue-router", async (importOriginal) => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@v2/lib", () => ({
|
||||
RPlatformIcon: defineComponent({ template: "<i />" }),
|
||||
vi.mock("@/v2/components/shared/PlatformIcon.vue", () => ({
|
||||
default: defineComponent({ template: "<i />" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/v2/composables/usePlatformPlayable", () => ({
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
// PlatformTile — platform card used by the Home dashboard row (variant="row",
|
||||
// 150px fixed) and the /platforms grid (variant="grid"). Feature composite
|
||||
// around RPlatformIcon and the shared Tile chrome; not a primitive.
|
||||
import { RPlatformIcon } from "@v2/lib";
|
||||
// around PlatformIcon and the shared Tile chrome; not a primitive.
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
import PlatformIcon from "@/v2/components/shared/PlatformIcon.vue";
|
||||
import Tile from "@/v2/components/shared/Tile.vue";
|
||||
import { usePlatformPlayable } from "@/v2/composables/usePlatformPlayable";
|
||||
import {
|
||||
@@ -19,9 +19,9 @@ defineOptions({ inheritAttrs: false });
|
||||
type Variant = "row" | "grid";
|
||||
|
||||
interface Props {
|
||||
/** Platform slug used to locate /assets/platforms/<slug>.{svg,ico} */
|
||||
/** Platform slug used to pick the shipped icon. */
|
||||
slug: string;
|
||||
/** Filesystem slug (tried first — matches v1's fallback chain). */
|
||||
/** Filesystem slug, used when no icon ships for `slug`. */
|
||||
fsSlug?: string;
|
||||
displayName: string;
|
||||
romCount?: number | null;
|
||||
@@ -44,7 +44,7 @@ const href = computed(() => props.to ?? `/platform/${props.id ?? ""}`);
|
||||
const { t } = useI18n();
|
||||
|
||||
// Shared-element morph between the platform tile icon and the
|
||||
// RPlatformIcon shown in the Platform view's InfoPanel cover slot.
|
||||
// PlatformIcon shown in the Platform view's InfoPanel cover slot.
|
||||
const router = useRouter();
|
||||
const iconEl = ref<HTMLElement | null>(null);
|
||||
const { morphTransition } = useViewTransition();
|
||||
@@ -87,7 +87,7 @@ const { emulator, mode, streamLabel } = usePlatformPlayable(() => props.slug);
|
||||
>
|
||||
<template #icon>
|
||||
<div ref="iconEl" class="plat-tile__icon-inner" :style="morphStyle">
|
||||
<RPlatformIcon
|
||||
<PlatformIcon
|
||||
:slug="slug"
|
||||
:fs-slug="fsSlug"
|
||||
:alt="displayName"
|
||||
|
||||
@@ -27,7 +27,7 @@ function mountPlatform(overrides: Partial<ScanningPlatform> = {}) {
|
||||
RCollapsible: {
|
||||
template: "<div><slot /><slot name='header-append' /></div>",
|
||||
},
|
||||
RPlatformIcon: true,
|
||||
PlatformIcon: true,
|
||||
RTag: {
|
||||
props: ["text"],
|
||||
template: "<span class='r-tag'>{{ text }}</span>",
|
||||
|
||||
@@ -9,12 +9,13 @@
|
||||
// overflow. The scroller stays in the loop anyway because its
|
||||
// transform-based row positioning avoids reflowing existing rows
|
||||
// as new ones stream in during a live scan.
|
||||
import { RCollapsible, RPlatformIcon, RTag, RVirtualScroller } from "@v2/lib";
|
||||
import { RCollapsible, RTag, RVirtualScroller } from "@v2/lib";
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { SimpleRom } from "@/stores/roms";
|
||||
import type { ScanningPlatform } from "@/stores/scanning";
|
||||
import ScanPlatformRow from "@/v2/components/Scan/ScanPlatformRow.vue";
|
||||
import PlatformIcon from "@/v2/components/shared/PlatformIcon.vue";
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
@@ -64,7 +65,7 @@ function getItemKey(item: unknown) {
|
||||
@update:model-value="(v) => $emit('update:open', v)"
|
||||
>
|
||||
<template #header-prepend>
|
||||
<RPlatformIcon
|
||||
<PlatformIcon
|
||||
v-if="platform.slug"
|
||||
:key="platform.slug"
|
||||
:slug="platform.slug"
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
// mode so several platforms can be toggled without the menu closing each
|
||||
// time; the activator shows a count and the picked platforms render below as
|
||||
// a removable list (icon + name), not pills.
|
||||
import { RBtn, RPlatformIcon } from "@v2/lib";
|
||||
import { RBtn } from "@v2/lib";
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { Platform } from "@/stores/platforms";
|
||||
import PlatformIcon from "@/v2/components/shared/PlatformIcon.vue";
|
||||
import PlatformSelect from "@/v2/components/shared/PlatformSelect.vue";
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
@@ -74,7 +75,7 @@ function remove(id: number) {
|
||||
|
||||
<ul v-if="selected.length" class="r-v2-hidplat__list">
|
||||
<li v-for="p in selected" :key="p.id" class="r-v2-hidplat__row">
|
||||
<RPlatformIcon
|
||||
<PlatformIcon
|
||||
:slug="p.slug"
|
||||
:fs-slug="p.fs_slug"
|
||||
:name="p.display_name"
|
||||
|
||||
@@ -64,7 +64,7 @@ function mountSection(
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
RIcon: true,
|
||||
RPlatformIcon: true,
|
||||
PlatformIcon: true,
|
||||
RProgressLinear: true,
|
||||
RSliderBtnGroup: true,
|
||||
RTextField: true,
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
import {
|
||||
REmptyState,
|
||||
RIcon,
|
||||
RPlatformIcon,
|
||||
RProgressLinear,
|
||||
RSliderBtnGroup,
|
||||
RTextField,
|
||||
@@ -29,6 +28,7 @@ import type { RegionBreakdownItem } from "@/__generated__/models/RegionBreakdown
|
||||
import storeHeartbeat from "@/stores/heartbeat";
|
||||
import storePlatforms from "@/stores/platforms";
|
||||
import { formatBytes, regionToEmoji } from "@/utils";
|
||||
import PlatformIcon from "@/v2/components/shared/PlatformIcon.vue";
|
||||
import {
|
||||
pendingMorphName,
|
||||
useViewTransition,
|
||||
@@ -237,7 +237,7 @@ function onRowClick(e: MouseEvent, platformId: number): void {
|
||||
"
|
||||
@click="onRowClick($event, platform.id)"
|
||||
>
|
||||
<RPlatformIcon
|
||||
<PlatformIcon
|
||||
:slug="platform.slug"
|
||||
:name="platform.name"
|
||||
:fs-slug="platform.fs_slug"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import CachedPlatformIcon from "./CachedPlatformIcon.vue";
|
||||
|
||||
describe("CachedPlatformIcon", () => {
|
||||
it("uses the shipped icon, then the default when it fails to load", async () => {
|
||||
const wrapper = mount(CachedPlatformIcon, { props: { slug: "saturn" } });
|
||||
expect(wrapper.find("img").attributes("src")).toBe(
|
||||
"/assets/platforms/saturn.ico",
|
||||
);
|
||||
|
||||
await wrapper.find("img").trigger("error");
|
||||
|
||||
expect(wrapper.find("img").attributes("src")).toBe(
|
||||
"/assets/platforms/default.ico",
|
||||
);
|
||||
});
|
||||
|
||||
it("goes straight to the default when nothing ships", () => {
|
||||
const wrapper = mount(CachedPlatformIcon, {
|
||||
props: { slug: "not-a-platform" },
|
||||
});
|
||||
|
||||
expect(wrapper.find("img").attributes("src")).toBe(
|
||||
"/assets/platforms/default.ico",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
// CachedPlatformIcon — reads the platform icon from the in-memory
|
||||
// blob cache populated by `prefetchPlatformIcons(...)`. When the
|
||||
// cache has a hit, renders a plain `<img>` with the blob URL (zero
|
||||
// network).
|
||||
//
|
||||
// Self-contained fallback chain, mirroring v1's PlatformIcon:
|
||||
// cached blob → /assets/platforms/{slug}.svg → .ico → default.ico
|
||||
// The terminal step is always `default.ico` (the same bundled glyph
|
||||
// every other platform-icon surface in the app falls back to), so
|
||||
// users never see the browser's broken-image icon or a Material icon
|
||||
// pretending to be a platform.
|
||||
// Prefers the blob cache filled by `prefetchPlatformIcons(...)` (zero
|
||||
// network), then the shipped icon, then `default.ico`.
|
||||
import { RImg } from "@v2/lib";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import {
|
||||
getCachedPlatformIcon,
|
||||
invalidatePlatformIcon,
|
||||
} from "@/v2/composables/usePlatformIconCache";
|
||||
import {
|
||||
DEFAULT_PLATFORM_ICON,
|
||||
platformIconUrl,
|
||||
} from "@/v2/utils/platformIcons";
|
||||
|
||||
interface Props {
|
||||
slug: string;
|
||||
@@ -24,42 +19,29 @@ interface Props {
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), { name: "", size: 40 });
|
||||
|
||||
const cached = computed(() => getCachedPlatformIcon(props.slug));
|
||||
|
||||
// 0 = try cached blob first, then the canonical .svg
|
||||
// 1 = .ico fallback
|
||||
// 2 = terminal default.ico — we stop advancing here so the browser
|
||||
// never paints its broken-image glyph in the unlikely case the
|
||||
// bundled fallback itself can't be loaded.
|
||||
const step = ref(0);
|
||||
// Callers sometimes hand us an undefined slug (e.g. VSelect's `#selection`
|
||||
// slot while the model value matches no item).
|
||||
const cached = computed(() =>
|
||||
props.slug ? getCachedPlatformIcon(props.slug) : undefined,
|
||||
);
|
||||
const failed = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.slug,
|
||||
() => {
|
||||
step.value = 0;
|
||||
failed.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
const src = computed<string>(() => {
|
||||
// Defensive: callers sometimes hand us `undefined` (e.g. VSelect's
|
||||
// `#selection` slot rendering a model value that doesn't match any
|
||||
// item, or a parent passing a transient empty slug while it
|
||||
// hydrates). Skip straight to the terminal fallback rather than
|
||||
// crashing on `.toLowerCase()`.
|
||||
if (!props.slug) return "/assets/platforms/default.ico";
|
||||
const slug = props.slug.toLowerCase();
|
||||
if (step.value === 0) {
|
||||
return cached.value ?? `/assets/platforms/${slug}.svg`;
|
||||
}
|
||||
if (step.value === 1) return `/assets/platforms/${slug}.ico`;
|
||||
return "/assets/platforms/default.ico";
|
||||
if (failed.value) return DEFAULT_PLATFORM_ICON;
|
||||
return cached.value ?? platformIconUrl(props.slug);
|
||||
});
|
||||
|
||||
function onError() {
|
||||
// The cached blob failed to decode — drop it so the reactive
|
||||
// re-render advances cleanly through the rest of the chain.
|
||||
if (step.value === 0 && cached.value) invalidatePlatformIcon(props.slug);
|
||||
if (step.value < 2) step.value += 1;
|
||||
// A blob that fails to decode is dropped so the shipped URL gets a turn.
|
||||
if (cached.value) invalidatePlatformIcon(props.slug);
|
||||
else failed.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
+18
-18
@@ -1,9 +1,9 @@
|
||||
import type { Meta, StoryObj } from "@storybook/vue3-vite";
|
||||
import RPlatformIcon from "./RPlatformIcon.vue";
|
||||
import PlatformIcon from "./PlatformIcon.vue";
|
||||
|
||||
const meta: Meta<typeof RPlatformIcon> = {
|
||||
title: "Media/RPlatformIcon",
|
||||
component: RPlatformIcon,
|
||||
const meta: Meta<typeof PlatformIcon> = {
|
||||
title: "Media/PlatformIcon",
|
||||
component: PlatformIcon,
|
||||
argTypes: {
|
||||
name: { control: "text" },
|
||||
src: { control: "text" },
|
||||
@@ -15,20 +15,20 @@ const meta: Meta<typeof RPlatformIcon> = {
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof RPlatformIcon>;
|
||||
type Story = StoryObj<typeof PlatformIcon>;
|
||||
|
||||
export const Known: Story = { args: { name: "snes", size: 40 } };
|
||||
export const Unknown: Story = { args: { name: "does-not-exist", size: 40 } };
|
||||
export const Row: Story = {
|
||||
render: () => ({
|
||||
components: { RPlatformIcon },
|
||||
components: { PlatformIcon },
|
||||
template: `
|
||||
<div style="display:flex;gap:.5rem;align-items:center">
|
||||
<RPlatformIcon name="snes" />
|
||||
<RPlatformIcon name="nes" />
|
||||
<RPlatformIcon name="gba" />
|
||||
<RPlatformIcon name="ps1" />
|
||||
<RPlatformIcon name="mystery" />
|
||||
<PlatformIcon name="snes" />
|
||||
<PlatformIcon name="nes" />
|
||||
<PlatformIcon name="gba" />
|
||||
<PlatformIcon name="ps1" />
|
||||
<PlatformIcon name="mystery" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
@@ -43,23 +43,23 @@ export const Row: Story = {
|
||||
export const SizeLadder: Story = {
|
||||
name: "Size ladder",
|
||||
render: () => ({
|
||||
components: { RPlatformIcon },
|
||||
components: { PlatformIcon },
|
||||
template: `
|
||||
<div style="display:flex;gap:14px;align-items:center;font:11px/1.2 sans-serif;color:var(--r-color-fg-muted)">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:4px">
|
||||
<RPlatformIcon name="snes" :size="16" />
|
||||
<PlatformIcon name="snes" :size="16" />
|
||||
<span>16</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:4px">
|
||||
<RPlatformIcon name="snes" :size="22" />
|
||||
<PlatformIcon name="snes" :size="22" />
|
||||
<span>22</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:4px">
|
||||
<RPlatformIcon name="snes" :size="32" />
|
||||
<PlatformIcon name="snes" :size="32" />
|
||||
<span>32</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:4px">
|
||||
<RPlatformIcon name="snes" :size="48" />
|
||||
<PlatformIcon name="snes" :size="48" />
|
||||
<span>48</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,11 +81,11 @@ export const SizeLadder: Story = {
|
||||
export const InsideFlexParent: Story = {
|
||||
name: "Inside indefinite-extent flex parent",
|
||||
render: () => ({
|
||||
components: { RPlatformIcon },
|
||||
components: { PlatformIcon },
|
||||
template: `
|
||||
<div style="display:flex;align-items:center;gap:6px;padding:6px;border:1px dashed var(--r-color-border);border-radius:6px">
|
||||
<span style="font:11px sans-serif;color:var(--r-color-fg-muted)">indefinite flex parent →</span>
|
||||
<RPlatformIcon name="snes" :size="22" />
|
||||
<PlatformIcon name="snes" :size="22" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
@@ -0,0 +1,66 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { nextTick } from "vue";
|
||||
import PlatformIcon from "./PlatformIcon.vue";
|
||||
|
||||
type Props = Partial<InstanceType<typeof PlatformIcon>["$props"]>;
|
||||
|
||||
/** The src before and after one load error, collapsed when unchanged. */
|
||||
async function loadChain(props: Props): Promise<string[]> {
|
||||
const wrapper = mount(PlatformIcon, {
|
||||
props: { showTooltip: false, ...props },
|
||||
});
|
||||
const first = wrapper.find("img").attributes("src");
|
||||
await wrapper.find("img").trigger("error");
|
||||
await nextTick();
|
||||
const second = wrapper.find("img").attributes("src");
|
||||
wrapper.unmount();
|
||||
return [...new Set([first, second])].filter((src) => src !== undefined);
|
||||
}
|
||||
|
||||
describe("PlatformIcon source", () => {
|
||||
it("prefers the shipped svg for the canonical slug", async () => {
|
||||
await expect(
|
||||
loadChain({ slug: "dc", fsSlug: "dreamcast" }),
|
||||
).resolves.toEqual([
|
||||
"/assets/platforms/dc.svg",
|
||||
"/assets/platforms/default.ico",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the shipped ico when no svg ships", async () => {
|
||||
await expect(loadChain({ slug: "saturn" })).resolves.toEqual([
|
||||
"/assets/platforms/saturn.ico",
|
||||
"/assets/platforms/default.ico",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the filesystem slug when the slug ships nothing", async () => {
|
||||
await expect(
|
||||
loadChain({ slug: "not-a-platform", fsSlug: "genesis" }),
|
||||
).resolves.toEqual([
|
||||
"/assets/platforms/genesis.svg",
|
||||
"/assets/platforms/default.ico",
|
||||
]);
|
||||
});
|
||||
|
||||
it("goes straight to the default when nothing ships", async () => {
|
||||
await expect(
|
||||
loadChain({ slug: "not-a-platform", fsSlug: "also-not-one" }),
|
||||
).resolves.toEqual(["/assets/platforms/default.ico"]);
|
||||
});
|
||||
|
||||
it("matches slugs case-insensitively", async () => {
|
||||
await expect(loadChain({ name: "NES" })).resolves.toEqual([
|
||||
"/assets/platforms/nes.svg",
|
||||
"/assets/platforms/default.ico",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses an explicit src before the manifest", async () => {
|
||||
await expect(loadChain({ src: "/custom/icon.png" })).resolves.toEqual([
|
||||
"/custom/icon.png",
|
||||
"/assets/platforms/default.ico",
|
||||
]);
|
||||
});
|
||||
});
|
||||
+21
-41
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { RTooltip } from "@v2/lib";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import RTooltip from "@/v2/lib/structural/RTooltip/RTooltip.vue";
|
||||
import {
|
||||
DEFAULT_PLATFORM_ICON,
|
||||
platformIconUrl,
|
||||
} from "@/v2/utils/platformIcons";
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
// Platform assets are keyed by canonical slug, so `slug` is tried before the
|
||||
// filesystem folder name.
|
||||
//
|
||||
// Hover tooltip uses RTooltip (v2 glass skin) instead of the native
|
||||
// browser `title=` so the bubble matches the rest of the UI. Disable
|
||||
// with `:show-tooltip="false"` if a parent surface already supplies one.
|
||||
@@ -16,13 +17,13 @@ interface Props {
|
||||
name?: string;
|
||||
/** Alias for `name`. */
|
||||
slug?: string;
|
||||
/** Filesystem slug, tried only after `slug` when the two differ. */
|
||||
/** Filesystem slug, tried only when no icon ships for `slug`. */
|
||||
fsSlug?: string;
|
||||
/** Explicit override. */
|
||||
src?: string;
|
||||
size?: number | string;
|
||||
alt?: string;
|
||||
/** Tooltip text override. Falls back to `alt` then resolved slug. */
|
||||
/** Tooltip text override. Falls back to `alt`. */
|
||||
title?: string;
|
||||
/** Show RTooltip on hover (default `true`). */
|
||||
showTooltip?: boolean;
|
||||
@@ -39,48 +40,30 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
showTooltip: true,
|
||||
});
|
||||
|
||||
const resolvedSlug = computed(
|
||||
() => props.slug ?? props.name ?? props.fsSlug ?? "",
|
||||
const resolvedSrc = computed(
|
||||
() => props.src || platformIconUrl(props.slug ?? props.name, props.fsSlug),
|
||||
);
|
||||
const resolvedFsSlug = computed(() => props.fsSlug ?? resolvedSlug.value);
|
||||
|
||||
// Ordered candidate URL list. We try each one in sequence; `stepIdx` walks
|
||||
// through them until one loads (or we fall back to default.ico).
|
||||
const candidates = computed(() => {
|
||||
if (props.src) return [props.src];
|
||||
const fs = resolvedFsSlug.value.toLowerCase().trim();
|
||||
const s = resolvedSlug.value.toLowerCase().trim();
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const base of [s, fs]) {
|
||||
if (!base || seen.has(base)) continue;
|
||||
seen.add(base);
|
||||
out.push(`/assets/platforms/${base}.svg`, `/assets/platforms/${base}.ico`);
|
||||
}
|
||||
out.push("/assets/platforms/default.ico");
|
||||
return out;
|
||||
// A shipped file can still fail to load (e.g. a stale deploy), so an error
|
||||
// drops to the default glyph once instead of showing a broken image.
|
||||
const failed = ref(false);
|
||||
watch(resolvedSrc, () => {
|
||||
failed.value = false;
|
||||
});
|
||||
|
||||
const stepIdx = ref(0);
|
||||
const currentSrc = computed(() => candidates.value[stepIdx.value] ?? null);
|
||||
|
||||
watch(candidates, () => {
|
||||
stepIdx.value = 0;
|
||||
});
|
||||
const currentSrc = computed(() =>
|
||||
failed.value ? DEFAULT_PLATFORM_ICON : resolvedSrc.value,
|
||||
);
|
||||
|
||||
function onError() {
|
||||
if (stepIdx.value < candidates.value.length - 1) {
|
||||
stepIdx.value += 1;
|
||||
}
|
||||
failed.value = true;
|
||||
}
|
||||
|
||||
const resolvedSize = computed(() =>
|
||||
typeof props.size === "number" ? `${props.size}px` : props.size,
|
||||
);
|
||||
|
||||
const tooltipText = computed(
|
||||
() => props.title ?? props.alt ?? resolvedSlug.value ?? "",
|
||||
);
|
||||
const tooltipText = computed(() => props.title || props.alt);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -90,10 +73,9 @@ const tooltipText = computed(
|
||||
:style="{ width: resolvedSize, height: resolvedSize }"
|
||||
>
|
||||
<img
|
||||
v-if="currentSrc"
|
||||
:key="currentSrc"
|
||||
:src="currentSrc"
|
||||
:alt="alt ?? resolvedSlug ?? ''"
|
||||
:alt="alt"
|
||||
class="r-platform-icon__img"
|
||||
@error="onError"
|
||||
/>
|
||||
@@ -110,9 +92,7 @@ const tooltipText = computed(
|
||||
/* The `size` prop sets the wrapper dimensions directly via inline
|
||||
`width` / `height` (not a CSS var) so they survive contexts where
|
||||
the parent collapses cross-axis (e.g. `line-height: 0` flex parents
|
||||
inside RBtn). `min-width: 0` lets the wrapper still shrink inside
|
||||
flex parents that are genuinely narrower — flex items otherwise
|
||||
refuse to go below their intrinsic width. */
|
||||
inside RBtn). */
|
||||
.r-platform-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -18,12 +18,13 @@
|
||||
// drive rendering from external state (FolderMapping reads from `row`
|
||||
// rather than `items` so the cell still renders during loading or
|
||||
// when the row points at a slug not in the catalogue).
|
||||
import { RAvatar, RIcon, RPlatformIcon, RSelect, RTag } from "@v2/lib";
|
||||
import { RAvatar, RIcon, RSelect, RTag } from "@v2/lib";
|
||||
import { computed, ref, useSlots } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { Platform } from "@/stores/platforms";
|
||||
import { platformCategoryToIcon } from "@/utils";
|
||||
import MissingFSBadge from "@/v2/components/shared/MissingFSBadge.vue";
|
||||
import PlatformIcon from "@/v2/components/shared/PlatformIcon.vue";
|
||||
import {
|
||||
formatPlatformRomCount,
|
||||
promotePlatformsWithGamesFirst,
|
||||
@@ -249,7 +250,7 @@ function showPromoteRomBadge(platform: Platform): boolean {
|
||||
<template #selection="slotProps">
|
||||
<slot name="selection" v-bind="slotProps">
|
||||
<span class="r-v2-platsel__selection">
|
||||
<RPlatformIcon
|
||||
<PlatformIcon
|
||||
v-if="platformForValue(slotProps.item.value)"
|
||||
:slug="platformForValue(slotProps.item.value)!.slug"
|
||||
:fs-slug="platformForValue(slotProps.item.value)!.fs_slug"
|
||||
@@ -266,11 +267,11 @@ function showPromoteRomBadge(platform: Platform): boolean {
|
||||
collapses each selection into a small RTag. PlatformSelect
|
||||
renders just the platform icon inside the chip; RSelect
|
||||
keeps providing the "+N" overflow pill automatically. The
|
||||
tooltip on RPlatformIcon serves the same role the title
|
||||
tooltip on PlatformIcon serves the same role the title
|
||||
text used to (hover the chip → see the platform name). -->
|
||||
<template #chip="slotProps">
|
||||
<slot name="chip" v-bind="slotProps">
|
||||
<RPlatformIcon
|
||||
<PlatformIcon
|
||||
v-if="platformForValue(slotProps.item.value)"
|
||||
:slug="platformForValue(slotProps.item.value)!.slug"
|
||||
:fs-slug="platformForValue(slotProps.item.value)!.fs_slug"
|
||||
@@ -287,7 +288,7 @@ function showPromoteRomBadge(platform: Platform): boolean {
|
||||
<template #item="slotProps">
|
||||
<slot name="item" v-bind="slotProps">
|
||||
<li v-bind="slotProps.props" class="r-v2-platsel__row">
|
||||
<RPlatformIcon
|
||||
<PlatformIcon
|
||||
:key="(slotProps.item.raw as Platform).slug"
|
||||
:slug="(slotProps.item.raw as Platform).slug"
|
||||
:fs-slug="(slotProps.item.raw as Platform).fs_slug"
|
||||
|
||||
@@ -42,21 +42,18 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("prefetchPlatformIcons", () => {
|
||||
it("falls back to .ico when the platform ships no .svg", async () => {
|
||||
it("fetches the shipped .ico when the platform ships no .svg", async () => {
|
||||
respondWith({ "/assets/platforms/saturn.ico": "image/x-icon" });
|
||||
const { prefetchPlatformIcons, getCachedPlatformIcon } = await load();
|
||||
|
||||
prefetchPlatformIcons(["saturn"]);
|
||||
await settle();
|
||||
|
||||
expect(urls()).toEqual([
|
||||
"/assets/platforms/saturn.svg",
|
||||
"/assets/platforms/saturn.ico",
|
||||
]);
|
||||
expect(urls()).toEqual(["/assets/platforms/saturn.ico"]);
|
||||
expect(getCachedPlatformIcon("saturn")).toBe("blob:cached");
|
||||
});
|
||||
|
||||
it("stops at .svg when that variant resolves", async () => {
|
||||
it("fetches only the .svg when both variants ship", async () => {
|
||||
respondWith({ "/assets/platforms/dc.svg": "image/svg+xml" });
|
||||
const { prefetchPlatformIcons, getCachedPlatformIcon } = await load();
|
||||
|
||||
@@ -67,54 +64,34 @@ describe("prefetchPlatformIcons", () => {
|
||||
expect(getCachedPlatformIcon("dc")).toBe("blob:cached");
|
||||
});
|
||||
|
||||
it("ignores a non-image body and keeps probing", async () => {
|
||||
respondWith({
|
||||
"/assets/platforms/dc.svg": "text/html",
|
||||
"/assets/platforms/dc.ico": "image/x-icon",
|
||||
});
|
||||
it("caches nothing when the response is not an image", async () => {
|
||||
respondWith({ "/assets/platforms/dc.svg": "text/html" });
|
||||
const { prefetchPlatformIcons, getCachedPlatformIcon } = await load();
|
||||
|
||||
prefetchPlatformIcons(["dc"]);
|
||||
await settle();
|
||||
|
||||
expect(urls()).toEqual([
|
||||
"/assets/platforms/dc.svg",
|
||||
"/assets/platforms/dc.ico",
|
||||
]);
|
||||
expect(getCachedPlatformIcon("dc")).toBe("blob:cached");
|
||||
expect(getCachedPlatformIcon("dc")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps probing after a candidate fails in transport", async () => {
|
||||
fetchMock.mockImplementation(async (url: string) => {
|
||||
if (url.endsWith(".svg")) throw new Error("connection reset");
|
||||
return {
|
||||
ok: true,
|
||||
blob: async () => new Blob(["x"], { type: "image/x-icon" }),
|
||||
};
|
||||
});
|
||||
it("caches nothing when the fetch fails in transport", async () => {
|
||||
fetchMock.mockRejectedValue(new Error("connection reset"));
|
||||
const { prefetchPlatformIcons, getCachedPlatformIcon } = await load();
|
||||
|
||||
prefetchPlatformIcons(["saturn"]);
|
||||
prefetchPlatformIcons(["dc"]);
|
||||
await settle();
|
||||
|
||||
expect(urls()).toEqual([
|
||||
"/assets/platforms/saturn.svg",
|
||||
"/assets/platforms/saturn.ico",
|
||||
]);
|
||||
expect(getCachedPlatformIcon("saturn")).toBe("blob:cached");
|
||||
expect(getCachedPlatformIcon("dc")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("caches nothing for a platform with no asset at all", async () => {
|
||||
it("does not request a platform that ships no icon", async () => {
|
||||
respondWith({});
|
||||
const { prefetchPlatformIcons, getCachedPlatformIcon } = await load();
|
||||
|
||||
prefetchPlatformIcons(["singe"]);
|
||||
await settle();
|
||||
|
||||
expect(urls()).toEqual([
|
||||
"/assets/platforms/singe.svg",
|
||||
"/assets/platforms/singe.ico",
|
||||
]);
|
||||
expect(urls()).toEqual([]);
|
||||
expect(getCachedPlatformIcon("singe")).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Singleton in-memory cache of platform icon SVGs as blob URLs.
|
||||
// Singleton in-memory cache of platform icons as blob URLs.
|
||||
//
|
||||
// Why: `/assets/platforms/*.svg` is served without explicit cache
|
||||
// Why: `/assets/platforms/*` is served without explicit cache
|
||||
// headers, so the browser revalidates / re-downloads on every
|
||||
// `<img>` mount. In tables and menus that render many platform icons,
|
||||
// this turns into a flood of network requests every time the surface
|
||||
@@ -15,21 +15,22 @@
|
||||
// SSR / non-browser environments: `window` is guarded; `fetch` and
|
||||
// `URL.createObjectURL` are no-ops there.
|
||||
import { reactive } from "vue";
|
||||
import {
|
||||
platformSlugKey,
|
||||
shippedPlatformIconUrl,
|
||||
} from "@/v2/utils/platformIcons";
|
||||
|
||||
const cache = reactive(new Map<string, string>());
|
||||
const inflight = new Set<string>();
|
||||
|
||||
export function getCachedPlatformIcon(slug: string): string | undefined {
|
||||
return cache.get(slug.toLowerCase());
|
||||
return cache.get(platformSlugKey(slug));
|
||||
}
|
||||
|
||||
/** Blob URL for one candidate, or null when it is missing or unreadable. */
|
||||
async function fetchCandidateUrl(
|
||||
key: string,
|
||||
ext: string,
|
||||
): Promise<string | null> {
|
||||
/** Blob URL for `url`, or null when it is missing or unreadable. */
|
||||
async function fetchBlobUrl(url: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`/assets/platforms/${key}.${ext}`);
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
const blob = await res.blob();
|
||||
// The SPA fallback answers with 200 + HTML, which must not reach the
|
||||
@@ -37,24 +38,18 @@ async function fetchCandidateUrl(
|
||||
if (!blob.type || !blob.type.startsWith("image/")) return null;
|
||||
return URL.createObjectURL(blob);
|
||||
} catch {
|
||||
// A transport failure on one extension must not abandon the other.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOne(slug: string): Promise<void> {
|
||||
const key = slug.toLowerCase();
|
||||
if (cache.has(key) || inflight.has(key)) return;
|
||||
const key = platformSlugKey(slug);
|
||||
const url = shippedPlatformIconUrl(key);
|
||||
if (!url || cache.has(key) || inflight.has(key)) return;
|
||||
inflight.add(key);
|
||||
try {
|
||||
// Over half the catalogue ships `.ico` with no `.svg`, so both are tried.
|
||||
for (const ext of ["svg", "ico"]) {
|
||||
const url = await fetchCandidateUrl(key, ext);
|
||||
if (url) {
|
||||
cache.set(key, url);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const blobUrl = await fetchBlobUrl(url);
|
||||
if (blobUrl) cache.set(key, blobUrl);
|
||||
} finally {
|
||||
inflight.delete(key);
|
||||
}
|
||||
@@ -62,12 +57,11 @@ async function fetchOne(slug: string): Promise<void> {
|
||||
|
||||
/**
|
||||
* Drop a slug's cached entry. Called by CachedPlatformIcon when its
|
||||
* `<img>` reports a render error, so the next render falls through
|
||||
* to its own fallback chain (.ico → default.ico) instead of leaving
|
||||
* the broken-image glyph on screen.
|
||||
* `<img>` reports a render error, so the next render falls back to the
|
||||
* shipped URL instead of leaving the broken-image glyph on screen.
|
||||
*/
|
||||
export function invalidatePlatformIcon(slug: string): void {
|
||||
const key = slug.toLowerCase();
|
||||
const key = platformSlugKey(slug);
|
||||
const url = cache.get(key);
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
@@ -67,7 +67,6 @@ export * from "./data/RTable";
|
||||
|
||||
// Media
|
||||
export * from "./media/RBox3D";
|
||||
export * from "./media/RPlatformIcon";
|
||||
|
||||
// (GameCard lives under components/GameCard/ — it's a feature
|
||||
// composite that depends on stores + useGameActions, not a lib primitive.)
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { nextTick } from "vue";
|
||||
import RPlatformIcon from "./RPlatformIcon.vue";
|
||||
|
||||
vi.mock("@/v2/lib/structural/RTooltip/RTooltip.vue", () => ({
|
||||
default: { template: "<span><slot /></span>" },
|
||||
}));
|
||||
|
||||
type Props = Partial<InstanceType<typeof RPlatformIcon>["$props"]>;
|
||||
|
||||
async function loadChain(props: Props): Promise<string[]> {
|
||||
const wrapper = mount(RPlatformIcon, {
|
||||
props: { showTooltip: false, ...props },
|
||||
});
|
||||
|
||||
const seen: string[] = [];
|
||||
for (let step = 0; step < 8; step += 1) {
|
||||
const img = wrapper.find("img");
|
||||
if (!img.exists()) break;
|
||||
const src = img.attributes("src");
|
||||
if (!src || seen[seen.length - 1] === src) break;
|
||||
seen.push(src);
|
||||
await img.trigger("error");
|
||||
await nextTick();
|
||||
}
|
||||
wrapper.unmount();
|
||||
return seen;
|
||||
}
|
||||
|
||||
describe("RPlatformIcon candidate chain", () => {
|
||||
it("probes the canonical slug before the filesystem folder name", async () => {
|
||||
await expect(
|
||||
loadChain({ slug: "dc", fsSlug: "dreamcast" }),
|
||||
).resolves.toEqual([
|
||||
"/assets/platforms/dc.svg",
|
||||
"/assets/platforms/dc.ico",
|
||||
"/assets/platforms/dreamcast.svg",
|
||||
"/assets/platforms/dreamcast.ico",
|
||||
"/assets/platforms/default.ico",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the filesystem slug alone when no slug is given", async () => {
|
||||
await expect(loadChain({ fsSlug: "dreamcast" })).resolves.toEqual([
|
||||
"/assets/platforms/dreamcast.svg",
|
||||
"/assets/platforms/dreamcast.ico",
|
||||
"/assets/platforms/default.ico",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not repeat a slug that already matches the folder name", async () => {
|
||||
await expect(loadChain({ slug: "nes" })).resolves.toEqual([
|
||||
"/assets/platforms/nes.svg",
|
||||
"/assets/platforms/nes.ico",
|
||||
"/assets/platforms/default.ico",
|
||||
]);
|
||||
});
|
||||
|
||||
it("still reads the fsSlug prop as a fallback for slug", async () => {
|
||||
await expect(
|
||||
loadChain({ name: "genesis", fsSlug: "megadrive" }),
|
||||
).resolves.toEqual([
|
||||
"/assets/platforms/genesis.svg",
|
||||
"/assets/platforms/genesis.ico",
|
||||
"/assets/platforms/megadrive.svg",
|
||||
"/assets/platforms/megadrive.ico",
|
||||
"/assets/platforms/default.ico",
|
||||
]);
|
||||
});
|
||||
|
||||
it("short-circuits to an explicit src", async () => {
|
||||
await expect(loadChain({ src: "/custom/icon.png" })).resolves.toEqual([
|
||||
"/custom/icon.png",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { default as RPlatformIcon } from "./RPlatformIcon.vue";
|
||||
@@ -200,7 +200,7 @@ export const IconSizeLadder: Story = {
|
||||
// set as a flag (boolean / empty / `true`) and a default slot is
|
||||
// provided, the slot replaces the icon glyph. Used for composite icon
|
||||
// content like GameCard's platform badge, where the "icon" is an
|
||||
// RPlatformIcon SVG rather than an MDI codepoint.
|
||||
// PlatformIcon SVG rather than an MDI codepoint.
|
||||
export const IconSlotCustom: Story = {
|
||||
name: "Icon-mode · custom slot content",
|
||||
render: () => ({
|
||||
|
||||
@@ -336,7 +336,7 @@ const spinnerSize = computed(() => {
|
||||
<!-- Icon-only mode: when `icon` is a string, render the matching
|
||||
RIcon directly. When `icon` is bare `true`, fall through to
|
||||
the default slot so consumers can drop their own glyph in
|
||||
(e.g. RPlatformIcon on a GameCard badge) — without this
|
||||
(e.g. PlatformIcon on a GameCard badge). Without this
|
||||
branch the slot was silently dropped and the button rendered
|
||||
empty. -->
|
||||
<RIcon
|
||||
@@ -512,7 +512,7 @@ const spinnerSize = computed(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Slot-driven icon-only content (e.g. `<RBtn icon><RPlatformIcon /></RBtn>`).
|
||||
/* Slot-driven icon-only content (e.g. `<RBtn icon><PlatformIcon /></RBtn>`).
|
||||
Separate class from `.r-btn__icon` so the icon-font sizing rule
|
||||
(`.r-btn__icon { font-size: 1.25em }`) doesn't bleed into image-based
|
||||
slot content. Just a centered inline-flex shell. */
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
//
|
||||
// RTooltip wires the listeners onto `$el.parentElement` itself.
|
||||
// Used inside primitives that already have their own root
|
||||
// element (RPlatformIcon, MissingFSBadge, …).
|
||||
// element (PlatformIcon, MissingFSBadge, …).
|
||||
//
|
||||
// Open / close motion mirrors the rest of the lib — a short fade +
|
||||
// subtle scale-up from 0.96, with reduced-motion stripping the scale.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
declare module "virtual:platform-icons" {
|
||||
/** Lowercase slug to the filename shipped in `assets/platforms/`. */
|
||||
const icons: ReadonlyMap<string, string>;
|
||||
export default icons;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import shippedIcons from "virtual:platform-icons";
|
||||
|
||||
const PLATFORM_ICON_DIR = "/assets/platforms";
|
||||
export const DEFAULT_PLATFORM_ICON = `${PLATFORM_ICON_DIR}/default.ico`;
|
||||
|
||||
export function platformSlugKey(slug: string): string {
|
||||
return slug.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** Public URL of the icon shipped for `slug`, or null when none ships. */
|
||||
export function shippedPlatformIconUrl(slug: string): string | null {
|
||||
const file = shippedIcons.get(platformSlugKey(slug));
|
||||
return file ? `${PLATFORM_ICON_DIR}/${file}` : null;
|
||||
}
|
||||
|
||||
/** Icon URL for a platform: `slug` first, then `fsSlug`, then the default. */
|
||||
export function platformIconUrl(
|
||||
slug?: string | null,
|
||||
fsSlug?: string | null,
|
||||
): string {
|
||||
return (
|
||||
(slug && shippedPlatformIconUrl(slug)) ||
|
||||
(fsSlug && shippedPlatformIconUrl(fsSlug)) ||
|
||||
DEFAULT_PLATFORM_ICON
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
RIcon,
|
||||
RList,
|
||||
RListItem,
|
||||
RPlatformIcon,
|
||||
RSkeletonBlock,
|
||||
RTextField,
|
||||
} from "@v2/lib";
|
||||
@@ -15,6 +14,7 @@ import { useI18n } from "vue-i18n";
|
||||
import musicApi, { type MusicTrackFilters } from "@/services/api/music";
|
||||
import useMusicFavorites from "@/stores/musicFavorites";
|
||||
import SoundtrackPanel from "@/v2/components/Soundtrack/Panel.vue";
|
||||
import PlatformIcon from "@/v2/components/shared/PlatformIcon.vue";
|
||||
import { useLoadingPhase } from "@/v2/composables/useLoadingPhase";
|
||||
import { useTrackPager } from "@/v2/composables/useTrackPager";
|
||||
import { panelTracksFromCatalog } from "@/v2/utils/soundtrackTracks";
|
||||
@@ -161,7 +161,7 @@ function onDelete(fileId: number, romId: number) {
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
<RPlatformIcon
|
||||
<PlatformIcon
|
||||
v-else-if="entry.platformSlug"
|
||||
:slug="entry.platformSlug"
|
||||
:alt="entry.label"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { listPlatformIcons } from "../scripts/platformIconManifest";
|
||||
|
||||
describe("listPlatformIcons", () => {
|
||||
it("maps top-level svg and ico files by lowercase slug, svg first", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "platform-icons-"));
|
||||
try {
|
||||
const names = [
|
||||
"snes.svg",
|
||||
"dc.ico",
|
||||
"dc.svg",
|
||||
"Saturn.ICO",
|
||||
"vsmile.png",
|
||||
"ATTRIBUTIONS",
|
||||
];
|
||||
for (const name of names) writeFileSync(join(dir, name), "");
|
||||
mkdirSync(join(dir, "systematic"));
|
||||
writeFileSync(join(dir, "systematic", "nes.svg"), "");
|
||||
|
||||
expect(Object.fromEntries(listPlatformIcons(dir))).toEqual({
|
||||
dc: "dc.svg",
|
||||
saturn: "Saturn.ICO",
|
||||
snes: "snes.svg",
|
||||
});
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -65,7 +65,10 @@ async function checkA11y(
|
||||
}
|
||||
|
||||
const storyModules = import.meta.glob<StoryModule>(
|
||||
"../src/v2/lib/**/*.stories.ts",
|
||||
[
|
||||
"../src/v2/lib/**/*.stories.ts",
|
||||
"../src/v2/components/shared/**/*.stories.ts",
|
||||
],
|
||||
{ eager: true },
|
||||
);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { defineConfig, loadEnv } from "vite";
|
||||
import mkcert from "vite-plugin-mkcert";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import vuetify, { transformAssetUrls } from "vite-plugin-vuetify";
|
||||
import { platformIconManifest } from "./scripts/platformIconManifest";
|
||||
import { playerIsolationHeaders } from "./scripts/playerIsolationHeaders";
|
||||
import { precompress } from "./scripts/precompress";
|
||||
|
||||
@@ -122,6 +123,7 @@ export default defineConfig(({ mode }) => {
|
||||
}),
|
||||
precompress(),
|
||||
playerIsolationHeaders(),
|
||||
platformIconManifest(),
|
||||
httpsMode &&
|
||||
mkcert({
|
||||
savePath: "/app/.vite-plugin-mkcert",
|
||||
|
||||
@@ -2,9 +2,10 @@ import vue from "@vitejs/plugin-vue";
|
||||
import { URL, fileURLToPath } from "node:url";
|
||||
import vuetify from "vite-plugin-vuetify";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { platformIconManifest } from "./scripts/platformIconManifest";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), vuetify({ autoImport: true })],
|
||||
plugins: [vue(), vuetify({ autoImport: true }), platformIconManifest()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
|
||||
Reference in New Issue
Block a user