mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 09:34:50 +00:00
* feat: refactor button components
* fix: qa
* fix: storybook
* a11y: pass
* fix: build
* fix: rename default ->md
* fix: lint
* docs: buttons.md
* refactor part 1
* refactor part 2
* fix: undo refactor for fresh restart
* refactor
* Revert "refactor"
This reverts commit 96d65902d7.
* refactor: part 1
* fix: qa
* fix: qa
* fix: qa
* fix: qa
* fix: qa
* fix: qa
* fix: qa
* fix: qa
* fix: remove text-contrast
* fix: qa
* fix: v-tooltip
* fix: lint
* fix: split broken
* fix: passkey qa
* fix: qa
* fix: qa
* fix: prepr
* fix: splitbutton
* improve button group seam
---------
Signed-off-by: Calum H. <calum@modrinth.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
541 lines
14 KiB
Vue
541 lines
14 KiB
Vue
<template>
|
|
<NewModal ref="modal" :on-hide="handleModalHide">
|
|
<template #title>
|
|
<span class="text-lg font-extrabold text-contrast">
|
|
{{ formatMessage(mode === 'edit' ? messages.editSkinTitle : messages.addSkinTitle) }}
|
|
</span>
|
|
</template>
|
|
|
|
<div class="flex flex-col md:flex-row gap-6">
|
|
<div class="h-[25rem] w-[16rem] min-w-[16rem] flex-shrink-0 md:self-center">
|
|
<SkinPreviewRenderer
|
|
:variant="variant"
|
|
:texture-src="previewSkin || ''"
|
|
:ears-texture-src="uploadedTextureUrl?.original ?? currentSkin?.texture"
|
|
:cape-src="selectedCapeTexture"
|
|
framing="modal"
|
|
:initial-rotation="Math.PI / 8"
|
|
class="h-full w-full"
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-4 w-full min-h-[20rem]">
|
|
<section v-if="mode === 'edit' && canEditTextureAndModel">
|
|
<h2 class="text-base font-semibold mb-2">{{ formatMessage(messages.textureSection) }}</h2>
|
|
<Button @click="openTextureFileBrowser">
|
|
<UploadIcon /> {{ formatMessage(messages.replaceTextureButton) }}
|
|
</Button>
|
|
<input
|
|
ref="textureFileInput"
|
|
type="file"
|
|
accept="image/png"
|
|
class="hidden"
|
|
@change="onTextureFileInputChange"
|
|
/>
|
|
</section>
|
|
|
|
<section v-if="canEditTextureAndModel">
|
|
<h2 class="text-base font-semibold mb-2">
|
|
{{ formatMessage(messages.armStyleSection) }}
|
|
</h2>
|
|
<RadioButtons v-model="variant" :items="['CLASSIC', 'SLIM']">
|
|
<template #default="{ item }">
|
|
{{
|
|
formatMessage(item === 'CLASSIC' ? messages.wideArmStyle : messages.slimArmStyle)
|
|
}}
|
|
</template>
|
|
</RadioButtons>
|
|
</section>
|
|
|
|
<section>
|
|
<h2 class="text-base font-semibold mb-2">{{ formatMessage(messages.capeSection) }}</h2>
|
|
<div class="relative w-fit max-w-full">
|
|
<Transition
|
|
enter-active-class="transition-all duration-200 ease-out"
|
|
enter-from-class="opacity-0 max-h-0"
|
|
enter-to-class="opacity-100 max-h-6"
|
|
leave-active-class="transition-all duration-200 ease-in"
|
|
leave-from-class="opacity-100 max-h-6"
|
|
leave-to-class="opacity-0 max-h-0"
|
|
>
|
|
<div
|
|
v-if="showCapeTopFade"
|
|
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-6 bg-gradient-to-b from-bg-raised to-transparent"
|
|
/>
|
|
</Transition>
|
|
|
|
<div
|
|
ref="capeListRef"
|
|
class="grid grid-cols-[repeat(4,max-content)] auto-rows-max gap-2 overflow-y-auto pr-1"
|
|
:style="{ maxHeight: capeListMaxHeight }"
|
|
@scroll="checkCapeScrollState"
|
|
>
|
|
<CapeLikeTextButton
|
|
:tooltip="formatMessage(messages.noCapeTooltip)"
|
|
:highlighted="!selectedCape"
|
|
@click="selectCape(undefined)"
|
|
>
|
|
<template #icon><XIcon /></template>
|
|
<span>{{ formatMessage(messages.noneCapeOption) }}</span>
|
|
</CapeLikeTextButton>
|
|
|
|
<CapeButton
|
|
v-for="cape in sortedCapes"
|
|
:id="cape.id"
|
|
:key="cape.id"
|
|
:texture="cape.texture"
|
|
:name="cape.name || formatMessage(messages.capeFallbackName)"
|
|
:selected="selectedCape?.id === cape.id"
|
|
@select="selectCape(cape)"
|
|
/>
|
|
</div>
|
|
|
|
<Transition
|
|
enter-active-class="transition-all duration-200 ease-out"
|
|
enter-from-class="opacity-0 max-h-0"
|
|
enter-to-class="opacity-100 max-h-6"
|
|
leave-active-class="transition-all duration-200 ease-in"
|
|
leave-from-class="opacity-100 max-h-6"
|
|
leave-to-class="opacity-0 max-h-0"
|
|
>
|
|
<div
|
|
v-if="showCapeBottomFade"
|
|
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-6 bg-gradient-to-t from-bg-raised to-transparent"
|
|
/>
|
|
</Transition>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<template #actions>
|
|
<div class="flex gap-2 justify-end">
|
|
<Button type="outlined" :disabled="isSaving" @click="hide">
|
|
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
|
|
</Button>
|
|
<Button
|
|
v-tooltip="saveTooltip"
|
|
type="colored"
|
|
color="brand"
|
|
:disabled="disableSave || isSaving"
|
|
@click="save"
|
|
>
|
|
<SpinnerIcon v-if="isSaving" class="animate-spin" />
|
|
<CheckIcon v-else-if="mode === 'new'" />
|
|
<SaveIcon v-else />
|
|
{{ formatMessage(mode === 'new' ? messages.addSkinButton : messages.saveSkinButton) }}
|
|
</Button>
|
|
</div>
|
|
</template>
|
|
</NewModal>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { CheckIcon, SaveIcon, SpinnerIcon, UploadIcon, XIcon } from '@modrinth/assets'
|
|
import {
|
|
Button,
|
|
CapeButton,
|
|
CapeLikeTextButton,
|
|
commonMessages,
|
|
defineMessages,
|
|
injectNotificationManager,
|
|
NewModal,
|
|
RadioButtons,
|
|
SkinPreviewRenderer,
|
|
useScrollIndicator,
|
|
useVIntl,
|
|
} from '@modrinth/ui'
|
|
import { arrayBufferToBase64 } from '@modrinth/utils'
|
|
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
|
|
|
|
import {
|
|
type Cape,
|
|
determineModelType,
|
|
equip_skin,
|
|
get_normalized_skin_texture,
|
|
normalize_skin_texture,
|
|
save_custom_skin,
|
|
type Skin,
|
|
type SkinModel,
|
|
type SkinTextureUrl,
|
|
} from '@/helpers/skins.ts'
|
|
|
|
const CAPE_LIST_MAX_HEIGHT = 334
|
|
const messages = defineMessages({
|
|
editSkinTitle: {
|
|
id: 'app.skins.modal.edit-title',
|
|
defaultMessage: 'Editing skin',
|
|
},
|
|
addSkinTitle: {
|
|
id: 'app.skins.modal.add-title',
|
|
defaultMessage: 'Adding a skin',
|
|
},
|
|
textureSection: {
|
|
id: 'app.skins.modal.texture-section',
|
|
defaultMessage: 'Texture',
|
|
},
|
|
replaceTextureButton: {
|
|
id: 'app.skins.modal.replace-texture-button',
|
|
defaultMessage: 'Replace texture',
|
|
},
|
|
armStyleSection: {
|
|
id: 'app.skins.modal.arm-style-section',
|
|
defaultMessage: 'Arm style',
|
|
},
|
|
wideArmStyle: {
|
|
id: 'app.skins.modal.arm-style-wide',
|
|
defaultMessage: 'Wide',
|
|
},
|
|
slimArmStyle: {
|
|
id: 'app.skins.modal.arm-style-slim',
|
|
defaultMessage: 'Slim',
|
|
},
|
|
capeSection: {
|
|
id: 'app.skins.modal.cape-section',
|
|
defaultMessage: 'Cape',
|
|
},
|
|
noCapeTooltip: {
|
|
id: 'app.skins.modal.no-cape-tooltip',
|
|
defaultMessage: 'No cape',
|
|
},
|
|
noneCapeOption: {
|
|
id: 'app.skins.modal.none-cape-option',
|
|
defaultMessage: 'None',
|
|
},
|
|
capeFallbackName: {
|
|
id: 'app.skins.modal.cape-fallback-name',
|
|
defaultMessage: 'Cape',
|
|
},
|
|
savingTooltip: {
|
|
id: 'app.skins.modal.saving-tooltip',
|
|
defaultMessage: 'Saving...',
|
|
},
|
|
uploadSkinFirstTooltip: {
|
|
id: 'app.skins.modal.upload-skin-first-tooltip',
|
|
defaultMessage: 'Upload a skin first!',
|
|
},
|
|
makeEditFirstTooltip: {
|
|
id: 'app.skins.modal.make-edit-first-tooltip',
|
|
defaultMessage: 'Make an edit to the skin first!',
|
|
},
|
|
addSkinButton: {
|
|
id: 'app.skins.modal.add-skin-button',
|
|
defaultMessage: 'Add skin',
|
|
},
|
|
saveSkinButton: {
|
|
id: 'app.skins.modal.save-skin-button',
|
|
defaultMessage: 'Save skin',
|
|
},
|
|
})
|
|
|
|
const { formatMessage } = useVIntl()
|
|
const { handleError } = injectNotificationManager()
|
|
|
|
const modal = useTemplateRef('modal')
|
|
const textureFileInput = useTemplateRef<HTMLInputElement>('textureFileInput')
|
|
const capeListRef = ref<HTMLElement | null>(null)
|
|
const capeListMaxHeight = ref(`${CAPE_LIST_MAX_HEIGHT}px`)
|
|
const mode = ref<'new' | 'edit'>('new')
|
|
const currentSkin = ref<Skin | null>(null)
|
|
const isSaving = ref(false)
|
|
|
|
const uploadedTextureUrl = ref<SkinTextureUrl | null>(null)
|
|
const previewSkin = ref<string>('')
|
|
|
|
const variant = ref<SkinModel>('CLASSIC')
|
|
const selectedCape = ref<Cape | undefined>(undefined)
|
|
const props = defineProps<{ capes?: Cape[] }>()
|
|
|
|
const selectedCapeTexture = computed(() => selectedCape.value?.texture)
|
|
const canEditTextureAndModel = computed(() => currentSkin.value?.source !== 'default')
|
|
const {
|
|
showTopFade: showCapeTopFade,
|
|
showBottomFade: showCapeBottomFade,
|
|
checkScrollState: checkCapeScrollState,
|
|
forceCheck: forceCapeScrollCheck,
|
|
} = useScrollIndicator(capeListRef)
|
|
|
|
let capeListLayoutFrame: number | null = null
|
|
function updateCapeListLayout() {
|
|
const capeList = capeListRef.value
|
|
const modalContent = capeList?.closest('[data-modal-content]') as HTMLElement | null
|
|
|
|
if (!capeList || !modalContent) {
|
|
capeListMaxHeight.value = `${CAPE_LIST_MAX_HEIGHT}px`
|
|
forceCapeScrollCheck()
|
|
return
|
|
}
|
|
|
|
const availableHeight =
|
|
modalContent.getBoundingClientRect().bottom - capeList.getBoundingClientRect().top
|
|
|
|
capeListMaxHeight.value = `${Math.min(
|
|
CAPE_LIST_MAX_HEIGHT,
|
|
Math.max(0, Math.floor(availableHeight)),
|
|
)}px`
|
|
|
|
nextTick(() => forceCapeScrollCheck())
|
|
}
|
|
|
|
function refreshCapeListLayout() {
|
|
if (capeListLayoutFrame !== null) {
|
|
cancelAnimationFrame(capeListLayoutFrame)
|
|
}
|
|
|
|
capeListLayoutFrame = requestAnimationFrame(() => {
|
|
capeListLayoutFrame = null
|
|
updateCapeListLayout()
|
|
})
|
|
}
|
|
|
|
const sortedCapes = computed(() => {
|
|
return [...(props.capes || [])].sort((a, b) => {
|
|
const nameA = (a.name || '').toLowerCase()
|
|
const nameB = (b.name || '').toLowerCase()
|
|
return nameA.localeCompare(nameB)
|
|
})
|
|
})
|
|
|
|
async function loadPreviewSkin() {
|
|
if (uploadedTextureUrl.value) {
|
|
previewSkin.value = uploadedTextureUrl.value.normalized
|
|
} else if (currentSkin.value) {
|
|
try {
|
|
previewSkin.value = await get_normalized_skin_texture(currentSkin.value)
|
|
} catch (error) {
|
|
console.error('Failed to load skin texture:', error)
|
|
previewSkin.value = '/src/assets/skins/steve.png'
|
|
}
|
|
} else {
|
|
previewSkin.value = '/src/assets/skins/steve.png'
|
|
}
|
|
}
|
|
|
|
const hasEdits = computed(() => {
|
|
if (mode.value !== 'edit') return true
|
|
if (uploadedTextureUrl.value) return true
|
|
if (!currentSkin.value) return false
|
|
if (variant.value !== currentSkin.value.variant) return true
|
|
if ((selectedCape.value?.id || null) !== (currentSkin.value.cape_id || null)) return true
|
|
return false
|
|
})
|
|
|
|
const disableSave = computed(
|
|
() =>
|
|
(mode.value === 'new' && !uploadedTextureUrl.value) ||
|
|
(mode.value === 'edit' && !hasEdits.value),
|
|
)
|
|
|
|
const saveTooltip = computed(() => {
|
|
if (isSaving.value) return formatMessage(messages.savingTooltip)
|
|
if (mode.value === 'new' && !uploadedTextureUrl.value) {
|
|
return formatMessage(messages.uploadSkinFirstTooltip)
|
|
}
|
|
if (mode.value === 'edit' && !hasEdits.value) {
|
|
return formatMessage(messages.makeEditFirstTooltip)
|
|
}
|
|
return undefined
|
|
})
|
|
|
|
function resetState() {
|
|
mode.value = 'new'
|
|
currentSkin.value = null
|
|
uploadedTextureUrl.value = null
|
|
previewSkin.value = ''
|
|
variant.value = 'CLASSIC'
|
|
selectedCape.value = undefined
|
|
isSaving.value = false
|
|
}
|
|
|
|
function handleModalHide() {
|
|
setTimeout(() => resetState(), 250)
|
|
}
|
|
|
|
async function show(e: MouseEvent, skin?: Skin) {
|
|
mode.value = skin ? 'edit' : 'new'
|
|
currentSkin.value = skin ?? null
|
|
if (skin) {
|
|
variant.value = skin.variant
|
|
selectedCape.value = props.capes?.find((c) => c.id === skin.cape_id)
|
|
} else {
|
|
variant.value = 'CLASSIC'
|
|
selectedCape.value = undefined
|
|
}
|
|
|
|
await loadPreviewSkin()
|
|
|
|
modal.value?.show(e)
|
|
nextTick(() => refreshCapeListLayout())
|
|
}
|
|
|
|
async function showNew(e: MouseEvent, skinTextureUrl: SkinTextureUrl) {
|
|
mode.value = 'new'
|
|
currentSkin.value = null
|
|
uploadedTextureUrl.value = skinTextureUrl
|
|
variant.value = await determineModelType(skinTextureUrl.original)
|
|
selectedCape.value = undefined
|
|
|
|
await loadPreviewSkin()
|
|
|
|
modal.value?.show(e)
|
|
nextTick(() => refreshCapeListLayout())
|
|
}
|
|
|
|
async function setUploadedTexture(skinTextureUrl: SkinTextureUrl) {
|
|
uploadedTextureUrl.value = skinTextureUrl
|
|
await loadPreviewSkin()
|
|
nextTick(() => refreshCapeListLayout())
|
|
}
|
|
|
|
function hide() {
|
|
modal.value?.hide()
|
|
}
|
|
|
|
function selectCape(cape: Cape | undefined) {
|
|
selectedCape.value = cape
|
|
}
|
|
|
|
function openTextureFileBrowser() {
|
|
textureFileInput.value?.click()
|
|
}
|
|
|
|
async function onTextureFileInputChange(e: Event) {
|
|
const files = (e.target as HTMLInputElement).files
|
|
const file = files?.[0]
|
|
|
|
if (!file) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
const originalSkinTexUrl = `data:image/png;base64,${arrayBufferToBase64(
|
|
await file.arrayBuffer(),
|
|
)}`
|
|
const skinTextureNormalized = await normalize_skin_texture(originalSkinTexUrl)
|
|
await setUploadedTexture({
|
|
original: originalSkinTexUrl,
|
|
normalized: `data:image/png;base64,${arrayBufferToBase64(skinTextureNormalized)}`,
|
|
})
|
|
} catch (error) {
|
|
handleError(error)
|
|
} finally {
|
|
if (textureFileInput.value) {
|
|
textureFileInput.value.value = ''
|
|
}
|
|
}
|
|
}
|
|
|
|
async function save() {
|
|
isSaving.value = true
|
|
|
|
try {
|
|
let textureUrl: string
|
|
|
|
if (uploadedTextureUrl.value) {
|
|
textureUrl = uploadedTextureUrl.value.original
|
|
} else {
|
|
textureUrl = currentSkin.value!.texture
|
|
}
|
|
|
|
const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer())
|
|
|
|
if (mode.value === 'new') {
|
|
const addedSkin = await save_custom_skin(
|
|
{
|
|
texture_key: '',
|
|
variant: variant.value,
|
|
cape_id: selectedCape.value?.id,
|
|
texture: textureUrl,
|
|
source: 'custom',
|
|
is_equipped: false,
|
|
},
|
|
bytes,
|
|
variant.value,
|
|
selectedCape.value,
|
|
true,
|
|
)
|
|
emit('saved', {
|
|
applied: false,
|
|
skin: addedSkin,
|
|
})
|
|
} else {
|
|
const updatedSkin = await save_custom_skin(
|
|
currentSkin.value!,
|
|
bytes,
|
|
variant.value,
|
|
selectedCape.value,
|
|
!!uploadedTextureUrl.value && textureUrl !== currentSkin.value?.texture,
|
|
)
|
|
|
|
if (currentSkin.value?.is_equipped) {
|
|
await equip_skin(updatedSkin)
|
|
}
|
|
|
|
emit('saved', {
|
|
applied: !!currentSkin.value?.is_equipped,
|
|
skin: updatedSkin,
|
|
previousSkin: currentSkin.value!,
|
|
})
|
|
}
|
|
|
|
hide()
|
|
} catch (err) {
|
|
handleError(err)
|
|
} finally {
|
|
isSaving.value = false
|
|
}
|
|
}
|
|
|
|
watch([uploadedTextureUrl, currentSkin], async () => {
|
|
await loadPreviewSkin()
|
|
refreshCapeListLayout()
|
|
})
|
|
|
|
watch(
|
|
() => props.capes,
|
|
() => {
|
|
nextTick(() => refreshCapeListLayout())
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
watch(
|
|
capeListRef,
|
|
(capeList, _, onCleanup) => {
|
|
if (!capeList) return
|
|
|
|
const modalContent = capeList.closest('[data-modal-content]')
|
|
const resizeObserver = new ResizeObserver(() => refreshCapeListLayout())
|
|
|
|
if (modalContent instanceof HTMLElement) {
|
|
resizeObserver.observe(modalContent)
|
|
}
|
|
|
|
window.addEventListener('resize', refreshCapeListLayout, { passive: true })
|
|
refreshCapeListLayout()
|
|
|
|
onCleanup(() => {
|
|
resizeObserver.disconnect()
|
|
window.removeEventListener('resize', refreshCapeListLayout)
|
|
|
|
if (capeListLayoutFrame !== null) {
|
|
cancelAnimationFrame(capeListLayoutFrame)
|
|
capeListLayoutFrame = null
|
|
}
|
|
})
|
|
},
|
|
{ flush: 'post' },
|
|
)
|
|
|
|
const emit = defineEmits<{
|
|
(event: 'saved', options: { applied: boolean; skin?: Skin; previousSkin?: Skin }): void
|
|
(event: 'deleted', skin: Skin): void
|
|
}>()
|
|
|
|
defineExpose({
|
|
show,
|
|
showNew,
|
|
hide,
|
|
})
|
|
</script>
|