feat: Editing?
This commit is contained in:
committed by
Alejandro González
parent
cd4b2eb566
commit
87c07064ad
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user