mirror of
https://github.com/modrinth/code.git
synced 2026-08-28 10:34:53 +00:00
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:
@@ -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>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
Reference in New Issue
Block a user