Merge pull request #4792 from rommapp/fix/platform-icon-manifest

fix(frontend): only request platform icons that ship
This commit is contained in:
Georges-Antoine Assi
2026-09-25 22:05:07 -04:00
committed by GitHub
43 changed files with 366 additions and 290 deletions
@@ -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.
---
+1 -1
View File
@@ -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
View File
@@ -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`.
+54
View File
@@ -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>
@@ -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",
]);
});
});
@@ -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);
-1
View File
@@ -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: () => ({
+2 -2
View File
@@ -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.
+5
View File
@@ -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;
}
+26
View File
@@ -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
);
}
+2 -2
View File
@@ -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 });
}
});
});
+4 -1
View File
@@ -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 },
);
+2
View File
@@ -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 -1
View File
@@ -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)),