feat: Editing?

This commit is contained in:
Calum H.
2025-05-15 19:27:48 +01:00
committed by Alejandro González
parent cd4b2eb566
commit 87c07064ad
3 changed files with 182 additions and 234 deletions

View File

@@ -1,226 +1,56 @@
<script setup lang="ts">
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import {
Card,
Chips,
FileInput,
SkinPreviewRenderer,
CapeButton,
CapeLikeTextButton,
Button, RadioButtons, ButtonStyled
} from '@modrinth/ui'
import {
add_and_equip_custom_skin,
type Cape,
determineModelType,
equip_skin, remove_custom_skin,
type Skin,
type SkinModel
} from '@/helpers/skins.ts'
import {handleError} from '@/store/notifications'
import {computed, ref, useTemplateRef, watch} from 'vue'
import {CheckCircleIcon, InfoIcon, UploadIcon, ChevronRightIcon, CheckIcon, SaveIcon, XIcon, TrashIcon } from "@modrinth/assets";
const modal = useTemplateRef('modal')
const mode = ref<'new' | 'edit'>('new')
const currentSkin = ref<Skin | null>(null)
const textureBlob = ref<Uint8Array | null>(null)
const variant = ref<SkinModel>('CLASSIC')
const selectedCape = ref<Cape | undefined>(undefined)
const tempSkinUrl = ref<string>('/src/assets/skins/steve.png')
const props = defineProps<{ capes?: Cape[] }>()
const firstThreeCapes = computed(() => props.capes?.slice(0, 3) ?? [])
const localPreviewUrl = ref<string | null>(null)
const fileName = ref<string | null>(null)
watch(textureBlob, async (blob, prev) => {
if (prev && localPreviewUrl.value) URL.revokeObjectURL(localPreviewUrl.value)
localPreviewUrl.value = blob ? URL.createObjectURL(new Blob([blob])) : null
if (blob && variant.value === 'UNKNOWN' && localPreviewUrl.value) {
try {
variant.value = await determineModelType(localPreviewUrl.value)
} catch (err) {
handleError(err)
}
}
})
const previewSkin = computed(() => {
if (localPreviewUrl.value) return localPreviewUrl.value
if (currentSkin.value) return currentSkin.value.texture
if (mode.value === 'new') return tempSkinUrl.value
return ''
})
const emit = defineEmits<{
(e: 'saved', editedSkin: Skin | null, oldSkin: Skin): void
(e: 'deleted', deletedSkin: Skin): void
}>()
const disableSave = computed(() => mode.value === 'new' && !textureBlob.value)
function resetState() {
mode.value = 'new'
currentSkin.value = null
textureBlob.value = null
fileName.value = null
if (localPreviewUrl.value) {
URL.revokeObjectURL(localPreviewUrl.value)
localPreviewUrl.value = null
}
variant.value = 'CLASSIC'
selectedCape.value = undefined
}
function show(e: MouseEvent, skin?: Skin) {
mode.value = skin ? 'edit' : 'new'
currentSkin.value = skin ?? null
textureBlob.value = null
if (!skin) {
tempSkinUrl.value = Math.random() <= 0.05
? '/src/assets/skins/herobrine.png'
: '/src/assets/skins/steve.png'
}
variant.value = skin?.variant ?? 'CLASSIC'
selectedCape.value = skin?.cape_id ? props.capes?.find(c => c.id === skin.cape_id) : undefined
modal.value?.show(e)
}
function hide() {
modal.value?.hide()
setTimeout(resetState, 100);
}
async function onTextureSelected(files: FileList | null) {
if (!files?.length) return
const file = files[0]
const buf = await file.arrayBuffer()
textureBlob.value = new Uint8Array(buf)
fileName.value = file.name
}
function changeVariant(newVariant: SkinModel) {
variant.value = newVariant
}
function selectCape(cape: Cape | undefined) {
selectedCape.value = cape
}
function viewMoreCapes(e: MouseEvent) {
// TODO: later
}
async function handleDelete() {
try {
await remove_custom_skin(currentSkin.value!)
emit('deleted', currentSkin.value!)
hide();
} catch (err) {
handleError(err);
}
}
async function save() {
try {
let edited: Skin | null = null;
if (mode.value === 'new') {
if (!textureBlob.value) throw new Error('Please upload a skin texture first.')
await add_and_equip_custom_skin(textureBlob.value, variant.value, selectedCape.value)
} else if (currentSkin.value) {
edited = {
...currentSkin.value,
variant: variant.value,
cape_id: selectedCape.value?.id,
is_equipped: true
}
if (textureBlob.value) {
await add_and_equip_custom_skin(textureBlob.value, variant.value, selectedCape.value)
} else {
await equip_skin(edited);
}
}
emit('saved', edited, currentSkin.value!)
hide()
} catch (err) {
handleError(err)
}
}
defineExpose({
show,
hide,
save,
onTextureSelected,
changeVariant,
selectCape,
viewMoreCapes,
})
</script>
<template>
<ModalWrapper ref="modal" @on-modal-hide="resetState">
<template #title>
<span class="text-lg font-extrabold text-contrast">{{
mode === 'edit' ? 'Edit skin' : 'New skin'
}}</span>
<span class="text-lg font-extrabold text-contrast">
{{ mode === 'edit' ? 'Edit skin' : 'New skin' }}
</span>
</template>
<div class="flex flex-col md:flex-row gap-6">
<div class="max-h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
<!-- Blame Three.js for forcing 1:1 canvas ratios... -->
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] min-w-[16rem] flex-shrink-0 p-0 m-0">
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
<SkinPreviewRenderer
slim-model-src="/src/assets/models/slim_player.gltf"
wide-model-src="/src/assets/models/classic_player.gltf"
:variant="variant"
:texture-src="previewSkin"
:scale="1.4"
:fov="50"
:scale="1.4" :fov="50"
class="h-full w-full"
/>
</div>
</div>
<div class="min-h-[16rem] flex flex-col gap-4 w-full">
<div class="flex flex-col gap-4 w-full">
<section>
<h2 class="text-base font-semibold mb-2">Texture</h2>
<Card class="!bg-bg p-4 relative flex flex-col items-center gap-4">
<Card class="!bg-bg p-4 flex flex-col items-center gap-4">
<FileInput
:max-size="8000"
accept="image/png"
:prompt="mode === 'edit'? 'Replace skin' : 'Upload a skin'"
:prompt="mode === 'edit' ? 'Replace skin' : 'Upload a skin'"
class="btn btn-primary"
:aria-label="mode === 'edit'? 'Replace skin' : 'Upload a skin'"
@change="onTextureSelected"
>
<UploadIcon aria-hidden="true" />
</FileInput>
<div class="flex items-center gap-2 mx-auto" v-if="fileName || mode === 'new'">
<InfoIcon v-if="!fileName" aria-hidden="true" class="text-brand-blue" />
<CheckCircleIcon v-else aria-hidden="true" class="text-brand-green" />
<small class="max-w-64 truncate block" v-tooltip="fileName ?? undefined">
{{ fileName || 'No skin has been uploaded yet.' }}
</small>
</div>
<div class="flex items-center gap-2" v-if="fileName || mode === 'new'">
<InfoIcon v-if="!fileName" class="text-brand-blue" />
<CheckCircleIcon v-else class="text-brand-green" />
<small class="truncate" v-tooltip="fileName">{{ fileName || 'No skin uploaded yet.' }}</small>
</div>
</Card>
</section>
<section>
<h2 class="text-base font-semibold mb-2">Arm style</h2>
<RadioButtons
v-model="variant"
:items="['CLASSIC', 'SLIM']">
<RadioButtons v-model="variant" :items="['CLASSIC','SLIM']">
<template #default="{ item }">
{{ item === 'CLASSIC' ? 'Wide' : 'Slim' }}
</template>
</RadioButtons>
</section>
<section>
<h2 class="text-base font-semibold mb-2">Cape</h2>
<div class="flex gap-2">
@@ -233,34 +63,173 @@ defineExpose({
:selected="selectedCape?.id === cape.id"
@select="selectCape(cape)"
/>
<CapeLikeTextButton tooltip="View more capes" v-if="(capes?.length || 0) > 3">
<template #icon>
<ChevronRightIcon />
</template>
<CapeLikeTextButton v-if="capes?.length ?? 0 > 3" tooltip="View more capes" @click="viewMoreCapes">
<template #icon><ChevronRightIcon/></template>
<span>More</span>
</CapeLikeTextButton>
</div>
</section>
</div>
</div>
<div class="flex flex-row gap-2 mt-6">
<div class="flex gap-2 mt-6">
<ButtonStyled color="brand" :disabled="disableSave">
<Button @click="save" v-tooltip="disableSave && 'You need to upload a skin first!'" :disabled="disableSave">
<CheckIcon v-if="mode === 'new'"/>
<SaveIcon v-else />
{{ (mode === 'edit' ? 'Save skin' : 'Add skin') }}
<Button @click="save" :disabled="disableSave" v-tooltip="disableSave && 'Upload a skin first!'">
<CheckIcon v-if="mode === 'new'" /><SaveIcon v-else />
{{ mode === 'new' ? 'Add skin' : 'Save skin' }}
</Button>
</ButtonStyled>
<Button @click="hide">
<XIcon />
Cancel
</Button>
<ButtonStyled color="red" v-if="mode === 'edit'">
<Button @click="handleDelete">
<TrashIcon />
Delete
</Button>
<Button @click="hide"><XIcon/>Cancel</Button>
<ButtonStyled color="red" v-if="mode==='edit'">
<Button @click="handleDelete"><TrashIcon/>Delete</Button>
</ButtonStyled>
</div>
</ModalWrapper>
</template>
<script setup lang="ts">
import { ref, computed, watch, useTemplateRef } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import {
Card, FileInput, SkinPreviewRenderer,
Button, RadioButtons, CapeButton, CapeLikeTextButton, ButtonStyled
} from '@modrinth/ui'
import {
add_and_equip_custom_skin,
remove_custom_skin,
equip_skin,
unequip_skin,
get_available_skins,
type Skin, type Cape, type SkinModel
} from '@/helpers/skins.ts'
import { handleError } from '@/store/notifications'
import {
CheckCircleIcon, InfoIcon, UploadIcon,
CheckIcon, SaveIcon, XIcon, TrashIcon, ChevronRightIcon
} from '@modrinth/assets'
const modal = useTemplateRef('modal')
const mode = ref<'new'|'edit'>('new')
const currentSkin = ref<Skin|null>(null)
const fileUploadTextureBlob = ref<Uint8Array|null>(null)
const fileName = ref<string|null>(null)
watch(fileUploadTextureBlob, () => {
if (fileName.value === null && fileUploadTextureBlob.value) {
fileName.value = 'New upload'
}
})
const variant = ref<SkinModel>('CLASSIC')
const selectedCape = ref<Cape|undefined>(undefined)
const props = defineProps<{ capes?: Cape[] }>()
const firstThreeCapes = computed(() => props.capes?.slice(0,3) ?? [])
const localPreviewUrl = ref<string|null>(null)
watch(fileUploadTextureBlob, (blob, prev) => {
if (prev && localPreviewUrl.value) URL.revokeObjectURL(localPreviewUrl.value)
if (blob) localPreviewUrl.value = URL.createObjectURL(new Blob([blob]))
else localPreviewUrl.value = null
})
const previewSkin = computed(() => {
if (localPreviewUrl.value) return localPreviewUrl.value
if (currentSkin.value) return currentSkin.value.texture
return '/src/assets/skins/steve.png'
})
const disableSave = computed(() =>
mode.value==='new' && !fileUploadTextureBlob.value
)
function resetState() {
mode.value = 'new'
currentSkin.value = null
fileUploadTextureBlob.value = null
fileName.value = null
variant.value = 'CLASSIC'
selectedCape.value = undefined
if (localPreviewUrl.value) {
URL.revokeObjectURL(localPreviewUrl.value)
localPreviewUrl.value = null
}
}
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
}
modal.value?.show(e)
}
function hide() {
modal.value?.hide()
setTimeout(resetState, 100)
}
async function onTextureSelected(files: FileList|null) {
if (!files?.length) return
const file = files[0]
fileName.value = file.name
const buf = await file.arrayBuffer()
fileUploadTextureBlob.value = new Uint8Array(buf)
}
function changeVariant(v: SkinModel){ variant.value = v }
function selectCape(c: Cape|undefined){ selectedCape.value = c }
function viewMoreCapes(){
// TODO: later
}
async function handleDelete() {
try {
if (currentSkin.value?.is_equipped) {
await unequip_skin()
}
await remove_custom_skin(currentSkin.value!)
emit('deleted', currentSkin.value!)
hide()
} catch (err) {
handleError(err)
}
}
async function save() {
try {
let blob: Uint8Array
if (fileUploadTextureBlob.value) {
blob = fileUploadTextureBlob.value
} else {
const url = currentSkin.value!.texture
const resp = await fetch(url)
const buf = await resp.arrayBuffer()
blob = new Uint8Array(buf)
}
if (mode.value === 'new') {
await add_and_equip_custom_skin(blob, variant.value, selectedCape.value)
emit('saved')
} else {
await remove_custom_skin(currentSkin.value!)
await add_and_equip_custom_skin(blob, variant.value, selectedCape.value)
emit('saved')
}
hide()
} catch (err) {
handleError(err)
}
}
const emit = defineEmits<{
(e:'saved'):void
(e:'deleted'):void
}>()
defineExpose({ show, hide })
</script>

View File

@@ -52,6 +52,7 @@ async function loadCapes() {
}
async function loadSkins() {
skins.value = [];
skins.value = (await get_available_skins().catch(handleError)) ?? []
selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null;
}
@@ -63,29 +64,6 @@ async function changeSkin(newSkin: Skin) {
await loadSkins();
}
async function handleSkinSaved(newSkin: Skin | null, oldSkin: Skin | null) {
if (oldSkin) {
await remove_custom_skin(oldSkin).catch(handleError)
}
await loadSkins()
selectedSkin.value =
skins.value.find(s => s.texture_key === newSkin?.texture_key) ??
skins.value.find(s => s.is_equipped) ??
skins.value[0] ??
null
}
async function handleSkinDeleted(deletedSkin: Skin) {
await loadSkins();
if (selectedSkin.value?.texture_key === deletedSkin.texture_key) {
selectedSkin.value =
skins.value.find(s => s.is_equipped) ??
skins.value[0] ??
null
}
}
async function handleCapeSelected(cape: Cape | undefined) {
currentCape.value = cape;
await set_default_cape(currentCape.value).catch(handleError);
@@ -98,8 +76,8 @@ async function handleCapeSelected(cape: Cape | undefined) {
<EditSkinModal
ref="editSkinModal"
:capes="capes"
@saved="handleSkinSaved"
@deleted="handleSkinDeleted"
@saved="() => loadSkins()"
@deleted="() => loadSkins()"
/>
<SelectCapeModal ref="selectCapeModal" :capes="capes" @select="handleCapeSelected"/>
<div class="p-6 grid grid-cols-[300px_1fr] xl:grid-cols-[3fr_5fr] gap-6">

View File

@@ -161,7 +161,7 @@ const isDragging = ref(false)
const previousX = ref(0)
const onPointerDown = (event: PointerEvent) => {
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
isDragging.value = true
previousX.value = event.clientX
}
@@ -174,8 +174,8 @@ const onPointerMove = (event: PointerEvent) => {
}
const onPointerUp = (event: PointerEvent) => {
isDragging.value = false
;(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId)
isDragging.value = false;
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId)
}
const radialTexture = createRadialTexture(512)
@@ -196,4 +196,5 @@ function createRadialTexture(size: number): THREE.CanvasTexture {
}
applyTextureToScene(scene.value, texture.value)
loadModel(selectedModelSrc.value)
</script>