fix: skins QA problems + flow change (#6216)

* fix: skins backend bugs + apply flow

* fix: caching structure

* feat: collapse already duplicated skins + fix moj api spam

* fix: doc

* fix: flatten migrations

* feat: remove default cape/cape override concept

* fix: fmt + lint

* feat: remove SelectCapeModal for inline cape list

* feat: qa

* feat: virtualisation of skins sections + fix texture/model cache

* fix: lint

* fix: virt bugs + renderer fixes

* fix: qa bugs

* fix: doc

* fix: re-add click impulse anim from prototypes + re-add interact anim length cap

* fix: regressions

* devex: split up SkinPreviewrenderer

* fix: lint

* fix: introduce dynamic mode in virtual-scroll.ts

* feat: qa

* fix: nametag bug + remove minecon skin pack suffix

* feat: pain (literally)

* feat: user agent on moj reqs

* feat: impl per account flush queue for operations

* fix: breadcrumb

* chore: i18n pass

* fix: lint + prep + check

* fix: misalignments
This commit is contained in:
Calum H.
2026-05-27 22:22:24 +00:00
committed by GitHub
parent 64edf2ddeb
commit 84b91f32f8
55 changed files with 5651 additions and 2138 deletions
@@ -0,0 +1,102 @@
import { EditIcon, TrashIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import SkinButton from '../../components/skin/SkinButton.vue'
const frontImage = `data:image/svg+xml,${encodeURIComponent(`
<svg width="114" height="176" viewBox="0 0 114 176" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="43" y="4" width="28" height="30" fill="#5A3828"/>
<rect x="36" y="18" width="42" height="38" fill="#B97A57"/>
<rect x="43" y="28" width="8" height="6" fill="#1B1B20"/>
<rect x="63" y="28" width="8" height="6" fill="#1B1B20"/>
<rect x="50" y="40" width="14" height="5" fill="#684432"/>
<rect x="28" y="57" width="58" height="60" fill="#2693C7"/>
<rect x="18" y="63" width="18" height="54" fill="#35A8D8"/>
<rect x="78" y="63" width="18" height="54" fill="#1E7FAC"/>
<rect x="22" y="117" width="14" height="43" fill="#B97A57"/>
<rect x="78" y="117" width="14" height="43" fill="#A96A4D"/>
<rect x="36" y="117" width="18" height="55" fill="#263F5E"/>
<rect x="60" y="117" width="18" height="55" fill="#1D334F"/>
</svg>
)}`)}`
const backImage = `data:image/svg+xml,${encodeURIComponent(`
<svg width="114" height="176" viewBox="0 0 114 176" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="36" y="10" width="42" height="46" fill="#5A3828"/>
<rect x="28" y="57" width="58" height="60" fill="#1E7FAC"/>
<rect x="18" y="63" width="18" height="54" fill="#2693C7"/>
<rect x="78" y="63" width="18" height="54" fill="#19759E"/>
<rect x="22" y="117" width="14" height="43" fill="#A96A4D"/>
<rect x="78" y="117" width="14" height="43" fill="#A96A4D"/>
<rect x="36" y="117" width="18" height="55" fill="#1D334F"/>
<rect x="60" y="117" width="18" height="55" fill="#162B45"/>
</svg>
)}`)}`
const meta = {
title: 'Skin/SkinButton',
component: SkinButton,
args: {
forwardImageSrc: frontImage,
backwardImageSrc: backImage,
selected: false,
active: false,
tooltip: 'Steve',
},
render: (args) => ({
components: { SkinButton },
setup() {
return { args }
},
template: /* html */ `
<div class="w-[156px]">
<SkinButton v-bind="args" />
</div>
`,
}),
} satisfies Meta<typeof SkinButton>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Selected: Story = {
args: {
selected: true,
},
}
export const ActiveUnselected: Story = {
args: {
active: true,
},
}
export const WithActions: Story = {
render: (args) => ({
components: { ButtonStyled, EditIcon, SkinButton, TrashIcon },
setup() {
return { args }
},
template: /* html */ `
<div class="w-[156px]">
<SkinButton v-bind="args">
<template #overlay-buttons>
<ButtonStyled color="brand">
<button class="pointer-events-auto">
<EditIcon /> Edit
</button>
</ButtonStyled>
<ButtonStyled circular color="red">
<button class="pointer-events-auto" aria-label="Delete skin">
<TrashIcon />
</button>
</ButtonStyled>
</template>
</SkinButton>
</div>
`,
}),
}
@@ -0,0 +1,50 @@
import { PlusIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import SkinLikeTextButton from '../../components/skin/SkinLikeTextButton.vue'
const meta = {
title: 'Skin/SkinLikeTextButton',
component: SkinLikeTextButton,
args: {
selected: false,
tooltip: 'Add a skin',
dragActive: false,
dropzone: false,
},
render: (args) => ({
components: { PlusIcon, SkinLikeTextButton },
setup() {
return { args }
},
template: /* html */ `
<div class="w-[156px]">
<SkinLikeTextButton v-bind="args">
<template #icon>
<PlusIcon class="size-8" />
</template>
Add skin
<template #subtitle>Drag and drop</template>
</SkinLikeTextButton>
</div>
`,
}),
} satisfies Meta<typeof SkinLikeTextButton>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Selected: Story = {
args: {
selected: true,
},
}
export const DragActive: Story = {
args: {
dragActive: true,
dropzone: true,
},
}
@@ -0,0 +1,273 @@
import { CheckIcon, EyeIcon, RotateCounterClockwiseIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import SkinPreviewRenderer from '../../components/skin/SkinPreviewRenderer.vue'
const steveSkin =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAE5klEQVR4Xu1aMWsUQRgVVJCAoIIgglaJBG2UGIKCOU0hJHZKijRBsAnaWSjaiNjESgtTpT0bmxQWNvkJ+U9n3ube8ubNt7e5xOzdxX3wmNmZb/fmvflmdtm9M2dqcOfmxR44N325KFnn8ecXDwbSrzdxoODO7auleNTdgHfP5hKeKgNUsJff1zqZeBJ9fr2Jg4vW9Ef5Xxiga9+XAkRWLYFTYYDOuGYDxIMU6psf2r6tL54OA3wPUEIkBFeVfr2Jg4r2DRGk2OWZSwnZ7tcbO2hqU9j0tQtJyjMLlGqCnqPX0WtEcSh9PI0jGiBIIVpX8V7yvEg04/z6aPPxNA4V4QNm2/ar5d7vD+u9P1/eFOWvt2tF2/2ZK1ksyGO2aZ0msM3H0zg4IB2oziSEQjCEUzyJvkhUdE3WGc9jH0/j0IGxxC0M4iACQoHnj76WolEHcIwYtOGc6Fo0NDJoLAzwGaIgJWf89eO7BTUDlBTlM6/7gxqCYx9P4+BAUHLmeQ//8XKxfKDBre390nzBpRtTSQxvecwEvaYb4Mc+nsbBpzYV7yaghGiYwJKiaZCey+vpk+Gt61MJ2e7jGTke3vvaUy4sLBScnZ0t6PEZ9vbK5aHGog19fn2nXy7Dzk6vZLebLNHiN44LHxCF0wiPz7AvUsWrCSdhgO5H+B0PHxo+oKNkwEkbMLW7mxkwVhlwoktgX3S0BI6cAU/mt3sgfhzl04c/S7KNJan957a2EurMYFCl+P7Aq+LBpF2FiuCSbPv0KaWfUwcKPKwB2g9isOc3NwuWgvpCmZbeHsUnIr2N7S4ehOiNjQPSAI2rwyCBnh3eD0LI2Y8fC6Je/nC/9DaPdxPcrEwQ2T3ImNAAPacOgwRWGaB1iiHrBHl8IoyiVISnuJPi1QRlHVwgj6M27SNdUDLjZoDOfmRAtva7B5scyQ1Vy4h6juvN4Ab4DEeG6LGndGlAX4gb4PGeIaX4/rHe4nTH1zYVrfHF5luHyABfBoP6KYr0FNZZ9dgo3s+NRKkRkQFad70ZKKxKYJ0BGKSKKTJAZ5TZ0Bfk8aEBEh8ZoEaoASg9xvVm8PRW4UqPIylIxVG4i2OMxyUGiHjE6Ky6OBqgRniM682wsrLSA31zczLOqaIiM1Sgx5YGiGgVD6pYFxeZ4P2uNwMebzudTiJqdXU1E4oYj0Vdl4gapplDIyjMY3T5aAwyscoAzQw3QGNdbwY+41OcC6TwKA51NYDUZcSSM+rxvtcwhu0qxkWCfmv0GNfbokWLFi1atJgc4AVoFf3xmU+dSUyLFi1atGjRokVT8FdpQ39c1XeE+/S3QB4+dnADhv68bgbwTc+hX3GNGm7AUTOAj7qaARNpwLEyYL+uGTCWS0Df/vpLTu/TfsZkHzODbwS6JDKOGocRyWzwPjD5wqsGDDJB20YNNwClG6D9dQaUH0uqBLsho4YLdJHer22FAXXf9+uoWdI3xt8bVPGfGOjieRy1aR/p3/b9Q4d/8FCiz8fTOKoE+2xrv8arGL3f687vt8Kxui1GAuuWgNINGCRc+1j38TQOF4gyMiAyB4xS3YW7AUofT+Pw9PeZ1vZomURr3EUOygofT+Pgl2QKrKJ/bifrxFcJHxsD+LlcRQ3z/4LIABfsx9rm42kc0f8GVCCFR3GoQ0jVPuBZEfX5eIbFX3srPNN8aUvJAAAAAElFTkSuQmCC'
function svgDataUrl(svg: string) {
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
const greenCape = svgDataUrl(
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="32"><rect width="64" height="32" fill="#1f8f2f"/><rect x="1" y="1" width="10" height="16" fill="#59d957"/><rect x="3" y="4" width="6" height="10" fill="#11551d"/></svg>',
)
const pinkCape = svgDataUrl(
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="32"><rect width="64" height="32" fill="#d94fa3"/><rect x="1" y="1" width="10" height="16" fill="#ff9bd4"/><rect x="4" y="3" width="3" height="12" fill="#fff0fa"/></svg>',
)
const meta = {
title: 'Skin/SkinPreviewRenderer',
component: SkinPreviewRenderer,
args: {
textureSrc: steveSkin,
variant: 'CLASSIC',
initialRotation: Math.PI / 8,
},
render: (args) => ({
components: { CheckIcon, EyeIcon, RotateCounterClockwiseIcon, SkinPreviewRenderer },
setup() {
return { args }
},
template: /* html */ `
<div class="h-[80vh] min-h-[32rem] max-h-[48rem] w-[22rem]">
<SkinPreviewRenderer v-bind="args" nametag="Steve">
<template #nametag-badge>
<div class="flex items-center justify-center gap-1.5 rounded-full border border-solid border-brand-blue bg-bg-blue px-3 py-1 text-base font-semibold leading-6 text-brand-blue">
<EyeIcon class="size-5 shrink-0" /> Previewing
</div>
</template>
<template #subtitle>
<div class="flex max-w-40 flex-wrap items-center justify-center gap-2 px-2">
<button class="flex h-10 items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 text-contrast shadow-md">
<RotateCounterClockwiseIcon class="size-5" /> Reset
</button>
<button class="flex h-10 items-center justify-center gap-2 rounded-[14px] border-0 bg-brand px-4 py-2.5 text-base font-semibold leading-5 text-[rgba(0,0,0,0.9)] shadow-md">
<CheckIcon class="size-5" /> Apply
</button>
</div>
</template>
</SkinPreviewRenderer>
</div>
`,
}),
} satisfies Meta<typeof SkinPreviewRenderer>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const RepeatInteract: Story = {
render: (args) => ({
components: { SkinPreviewRenderer },
setup() {
return { args }
},
template: /* html */ `
<div class="h-[80vh] min-h-[32rem] max-h-[48rem] w-[22rem]">
<SkinPreviewRenderer v-bind="args" />
</div>
`,
}),
}
export const DamageFlash: Story = {
render: (args) => ({
components: { SkinPreviewRenderer },
setup() {
type SkinPreviewRendererExpose = {
playClickInteraction: () => void
}
const preview = ref<SkinPreviewRendererExpose | null>(null)
function triggerDamageFlash() {
for (let i = 0; i < 5; i++) {
preview.value?.playClickInteraction()
}
}
return { args, preview, triggerDamageFlash }
},
template: /* html */ `
<div class="flex items-start gap-6">
<div class="h-[80vh] min-h-[32rem] max-h-[48rem] w-[22rem]">
<SkinPreviewRenderer ref="preview" v-bind="args" />
</div>
<button class="rounded-lg border-0 bg-surface-4 px-4 py-2 text-primary" @click="triggerDamageFlash">
Trigger damage flash
</button>
</div>
`,
}),
}
export const AnimationControls: Story = {
render: (args) => ({
components: { SkinPreviewRenderer },
setup() {
type SkinPreviewRendererExpose = {
getAvailableAnimations: () => string[]
getCurrentAnimation: () => string
playAnimation: (name: string) => boolean
stopAnimations: () => void
}
const preview = ref<SkinPreviewRendererExpose | null>(null)
const availableAnimations = ref<string[]>([])
const currentAnimation = ref('')
function refreshAnimations() {
availableAnimations.value = preview.value?.getAvailableAnimations() ?? []
currentAnimation.value = preview.value?.getCurrentAnimation() ?? ''
}
function playAnimation(name: string) {
preview.value?.playAnimation(name)
currentAnimation.value = name
}
return {
args,
availableAnimations,
currentAnimation,
playAnimation,
preview,
refreshAnimations,
}
},
template: /* html */ `
<div class="flex items-start gap-6">
<div class="h-[80vh] min-h-[32rem] max-h-[48rem] w-[22rem]">
<SkinPreviewRenderer ref="preview" v-bind="args" />
</div>
<div class="flex max-w-[18rem] flex-col gap-2">
<button class="rounded-lg border-0 bg-surface-4 px-4 py-2 text-primary" @click="refreshAnimations">
Refresh animations
</button>
<div class="text-sm text-secondary">Current: {{ currentAnimation || 'none' }}</div>
<button
v-for="name in availableAnimations"
:key="name"
class="rounded-lg border-0 bg-surface-4 px-4 py-2 text-left text-primary"
@click="playAnimation(name)"
>
{{ name }}
</button>
</div>
</div>
`,
}),
}
export const ResponsiveFit: Story = {
args: {
lockFit: false,
},
}
export const WrappedSubtitle: Story = {
render: (args) => ({
components: { CheckIcon, RotateCounterClockwiseIcon, SkinPreviewRenderer },
setup() {
return { args }
},
template: /* html */ `
<div class="h-[42rem] w-[15rem]">
<SkinPreviewRenderer v-bind="args" nametag="Steve">
<template #subtitle>
<div class="flex max-w-[calc(100vw-2rem)] flex-wrap items-center justify-center gap-2 px-2">
<button class="flex h-10 items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 text-contrast shadow-md">
<RotateCounterClockwiseIcon class="size-5" /> Reset
</button>
<button class="flex h-10 items-center justify-center gap-2 rounded-[14px] border-0 bg-brand px-4 py-2.5 text-base font-semibold leading-5 text-[rgba(0,0,0,0.9)] shadow-md">
<CheckIcon class="size-5" /> Apply
</button>
</div>
</template>
</SkinPreviewRenderer>
</div>
`,
}),
}
export const LayerSeparation: Story = {
args: {
fitZoom: 1.5,
framing: 'modal',
initialRotation: Math.PI / 10,
},
render: (args) => ({
components: { SkinPreviewRenderer },
setup() {
return { args }
},
template: /* html */ `
<div class="h-[28rem] w-[18rem]">
<SkinPreviewRenderer v-bind="args" />
</div>
`,
}),
}
export const FramingModes: Story = {
render: () => ({
components: { CheckIcon, SkinPreviewRenderer },
setup() {
return { steveSkin }
},
template: /* html */ `
<div class="flex flex-wrap items-start gap-8">
<div class="h-[32rem] w-[18rem]">
<SkinPreviewRenderer :texture-src="steveSkin" variant="CLASSIC" framing="page" nametag="Steve">
<template #subtitle>
<button class="flex h-10 items-center justify-center gap-2 rounded-[14px] border-0 bg-brand px-4 py-2.5 text-base font-semibold leading-5 text-[rgba(0,0,0,0.9)] shadow-md">
<CheckIcon class="size-5" /> Apply
</button>
</template>
</SkinPreviewRenderer>
</div>
<div class="h-[25rem] w-[16rem]">
<SkinPreviewRenderer :texture-src="steveSkin" variant="CLASSIC" framing="modal" />
</div>
</div>
`,
}),
}
export const CapeSwitching: Story = {
render: () => ({
components: { SkinPreviewRenderer },
setup() {
const capeSrc = ref<string | undefined>()
return { capeSrc, greenCape, pinkCape, steveSkin }
},
template: /* html */ `
<div class="flex items-start gap-6">
<div class="h-[25rem] w-[16rem]">
<SkinPreviewRenderer
:texture-src="steveSkin"
:cape-src="capeSrc"
variant="CLASSIC"
framing="modal"
:initial-rotation="Math.PI / 8"
/>
</div>
<div class="flex flex-col gap-2">
<button class="rounded-lg border-0 bg-surface-4 px-4 py-2 text-primary" @click="capeSrc = undefined">
None
</button>
<button class="rounded-lg border-0 bg-surface-4 px-4 py-2 text-primary" @click="capeSrc = greenCape">
Green cape
</button>
<button class="rounded-lg border-0 bg-surface-4 px-4 py-2 text-primary" @click="capeSrc = pinkCape">
Pink cape
</button>
</div>
</div>
`,
}),
}