523 lines
15 KiB
Vue
523 lines
15 KiB
Vue
<script setup lang="ts">
|
|
import type AccountsCard from '@/components/ui/AccountsCard.vue'
|
|
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
|
|
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
|
|
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
|
|
import { trackEvent } from '@/helpers/analytics'
|
|
import { get_default_user, login as login_flow, users } from '@/helpers/auth'
|
|
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
|
|
import { generateSkinPreviews, skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
|
|
import { get as getSettings } from '@/helpers/settings.ts'
|
|
import type { Cape, Skin } from '@/helpers/skins.ts'
|
|
import {
|
|
equip_skin,
|
|
filterDefaultSkins,
|
|
filterSavedSkins,
|
|
get_available_capes,
|
|
get_available_skins,
|
|
get_normalized_skin_texture,
|
|
normalize_skin_texture,
|
|
remove_custom_skin,
|
|
set_default_cape,
|
|
} from '@/helpers/skins.ts'
|
|
import { handleSevereError } from '@/store/error'
|
|
import {
|
|
EditIcon,
|
|
ExcitedRinthbot,
|
|
LogInIcon,
|
|
PlusIcon,
|
|
SpinnerIcon,
|
|
TrashIcon,
|
|
UpdatedIcon,
|
|
} from '@modrinth/assets'
|
|
import {
|
|
Button,
|
|
ButtonStyled,
|
|
ConfirmModal,
|
|
injectNotificationManager,
|
|
SkinButton,
|
|
SkinLikeTextButton,
|
|
SkinPreviewRenderer,
|
|
} from '@modrinth/ui'
|
|
import { arrayBufferToBase64 } from '@modrinth/utils'
|
|
import { computedAsync } from '@vueuse/core'
|
|
import type { Ref } from 'vue'
|
|
import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
|
const editSkinModal = useTemplateRef('editSkinModal')
|
|
const selectCapeModal = useTemplateRef('selectCapeModal')
|
|
const uploadSkinModal = useTemplateRef('uploadSkinModal')
|
|
|
|
const notifications = injectNotificationManager()
|
|
const { handleError } = notifications
|
|
|
|
const settings = ref(await getSettings())
|
|
const skins = ref<Skin[]>([])
|
|
const capes = ref<Cape[]>([])
|
|
|
|
const accountsCard = inject('accountsCard') as Ref<typeof AccountsCard>
|
|
const currentUser = ref(undefined)
|
|
const currentUserId = ref<string | undefined>(undefined)
|
|
|
|
const username = computed(() => currentUser.value?.profile?.name ?? undefined)
|
|
const selectedSkin = ref<Skin | null>(null)
|
|
const defaultCape = ref<Cape>()
|
|
|
|
const originalSelectedSkin = ref<Skin | null>(null)
|
|
const originalDefaultCape = ref<Cape>()
|
|
|
|
const savedSkins = computed(() => filterSavedSkins(skins.value))
|
|
const defaultSkins = computed(() => filterDefaultSkins(skins.value))
|
|
|
|
const currentCape = computed(() => {
|
|
if (selectedSkin.value?.cape_id) {
|
|
const overrideCape = capes.value.find((c) => c.id === selectedSkin.value?.cape_id)
|
|
if (overrideCape) {
|
|
return overrideCape
|
|
}
|
|
}
|
|
return defaultCape.value
|
|
})
|
|
|
|
const skinTexture = computedAsync(async () => {
|
|
if (selectedSkin.value?.texture) {
|
|
return await get_normalized_skin_texture(selectedSkin.value)
|
|
} else {
|
|
return ''
|
|
}
|
|
})
|
|
const capeTexture = computed(() => currentCape.value?.texture)
|
|
const skinVariant = computed(() => selectedSkin.value?.variant)
|
|
const skinNametag = computed(() =>
|
|
settings.value.hide_nametag_skins_page ? undefined : username.value,
|
|
)
|
|
|
|
let userCheckInterval: number | null = null
|
|
|
|
const deleteSkinModal = ref()
|
|
const skinToDelete = ref<Skin | null>(null)
|
|
|
|
function confirmDeleteSkin(skin: Skin) {
|
|
skinToDelete.value = skin
|
|
deleteSkinModal.value?.show()
|
|
}
|
|
|
|
async function deleteSkin() {
|
|
if (!skinToDelete.value) return
|
|
await remove_custom_skin(skinToDelete.value).catch(handleError)
|
|
await loadSkins()
|
|
skinToDelete.value = null
|
|
}
|
|
|
|
async function loadCapes() {
|
|
try {
|
|
capes.value = (await get_available_capes()) ?? []
|
|
defaultCape.value = capes.value.find((c) => c.is_equipped)
|
|
originalDefaultCape.value = defaultCape.value
|
|
} catch (error) {
|
|
if (currentUser.value && error instanceof Error) {
|
|
handleError(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadSkins() {
|
|
try {
|
|
skins.value = (await get_available_skins()) ?? []
|
|
generateSkinPreviews(skins.value, capes.value)
|
|
selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null
|
|
originalSelectedSkin.value = selectedSkin.value
|
|
} catch (error) {
|
|
if (currentUser.value && error instanceof Error) {
|
|
handleError(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function changeSkin(newSkin: Skin) {
|
|
const previousSkin = selectedSkin.value
|
|
const previousSkinsList = [...skins.value]
|
|
|
|
skins.value = skins.value.map((skin) => {
|
|
return {
|
|
...skin,
|
|
is_equipped: skin.texture_key === newSkin.texture_key,
|
|
}
|
|
})
|
|
|
|
selectedSkin.value = skins.value.find((s) => s.texture_key === newSkin.texture_key) || null
|
|
|
|
try {
|
|
await equip_skin(newSkin)
|
|
if (accountsCard.value) {
|
|
await accountsCard.value.refreshValues()
|
|
}
|
|
} catch (error) {
|
|
selectedSkin.value = previousSkin
|
|
skins.value = previousSkinsList
|
|
|
|
if ((error as { message?: string })?.message?.includes('429 Too Many Requests')) {
|
|
notifications.addNotification({
|
|
type: 'error',
|
|
title: 'Slow down!',
|
|
text: "You're changing your skin too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again.",
|
|
})
|
|
} else {
|
|
handleError(error as Error)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handleCapeSelected(cape: Cape | undefined) {
|
|
const previousDefaultCape = defaultCape.value
|
|
const previousCapesList = [...capes.value]
|
|
|
|
capes.value = capes.value.map((c) => ({
|
|
...c,
|
|
is_equipped: cape ? c.id === cape.id : false,
|
|
}))
|
|
|
|
defaultCape.value = cape ? capes.value.find((c) => c.id === cape.id) : undefined
|
|
|
|
try {
|
|
await set_default_cape(cape)
|
|
} catch (error) {
|
|
defaultCape.value = previousDefaultCape
|
|
capes.value = previousCapesList
|
|
|
|
if ((error as { message?: string })?.message?.includes('429 Too Many Requests')) {
|
|
notifications.addNotification({
|
|
type: 'error',
|
|
title: 'Slow down!',
|
|
text: "You're changing your cape too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again.",
|
|
})
|
|
} else {
|
|
handleError(error as Error)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function onSkinSaved() {
|
|
await Promise.all([loadCapes(), loadSkins()])
|
|
}
|
|
|
|
async function loadCurrentUser() {
|
|
try {
|
|
const defaultId = await get_default_user()
|
|
currentUserId.value = defaultId
|
|
|
|
const allAccounts = await users()
|
|
currentUser.value = allAccounts.find((acc) => acc.profile.id === defaultId)
|
|
} catch (e) {
|
|
handleError(e as Error)
|
|
currentUser.value = undefined
|
|
currentUserId.value = undefined
|
|
}
|
|
}
|
|
|
|
function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
|
|
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
|
return skinBlobUrlMap.get(key)
|
|
}
|
|
|
|
async function login() {
|
|
accountsCard.value.setLoginDisabled(true)
|
|
const loggedIn = await login_flow().catch(handleSevereError)
|
|
|
|
if (loggedIn && accountsCard) {
|
|
await accountsCard.value.refreshValues()
|
|
}
|
|
|
|
trackEvent('AccountLogIn')
|
|
accountsCard.value.setLoginDisabled(false)
|
|
}
|
|
|
|
function openUploadSkinModal(e: MouseEvent) {
|
|
uploadSkinModal.value?.show(e)
|
|
}
|
|
|
|
function onSkinFileUploaded(buffer: ArrayBuffer) {
|
|
const fakeEvent = new MouseEvent('click')
|
|
normalize_skin_texture(`data:image/png;base64,` + arrayBufferToBase64(buffer)).then(
|
|
(skinTextureNormalized: Uint8Array) => {
|
|
const skinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized)
|
|
if (editSkinModal.value && editSkinModal.value.shouldRestoreModal) {
|
|
editSkinModal.value.restoreWithNewTexture(skinTexUrl)
|
|
} else {
|
|
editSkinModal.value?.showNew(fakeEvent, skinTexUrl)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
function onUploadCanceled() {
|
|
editSkinModal.value?.restoreModal()
|
|
}
|
|
|
|
watch(
|
|
() => selectedSkin.value?.cape_id,
|
|
() => {},
|
|
)
|
|
|
|
onMounted(() => {
|
|
userCheckInterval = window.setInterval(checkUserChanges, 250)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (userCheckInterval !== null) {
|
|
window.clearInterval(userCheckInterval)
|
|
}
|
|
})
|
|
|
|
async function checkUserChanges() {
|
|
try {
|
|
const defaultId = await get_default_user()
|
|
if (defaultId !== currentUserId.value) {
|
|
await loadCurrentUser()
|
|
await loadCapes()
|
|
await loadSkins()
|
|
}
|
|
} catch (error) {
|
|
if (currentUser.value && error instanceof Error) {
|
|
handleError(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
await Promise.all([loadCapes(), loadSkins(), loadCurrentUser()])
|
|
</script>
|
|
|
|
<template>
|
|
<EditSkinModal
|
|
ref="editSkinModal"
|
|
:capes="capes"
|
|
:default-cape="defaultCape"
|
|
@saved="onSkinSaved"
|
|
@deleted="() => loadSkins()"
|
|
@open-upload-modal="openUploadSkinModal"
|
|
/>
|
|
<SelectCapeModal ref="selectCapeModal" :capes="capes" @select="handleCapeSelected" />
|
|
<UploadSkinModal
|
|
ref="uploadSkinModal"
|
|
@uploaded="onSkinFileUploaded"
|
|
@canceled="onUploadCanceled"
|
|
/>
|
|
<ConfirmModal
|
|
ref="deleteSkinModal"
|
|
title="Are you sure you want to delete this skin?"
|
|
description="This will permanently delete the selected skin. This action cannot be undone."
|
|
proceed-label="Delete"
|
|
@proceed="deleteSkin"
|
|
/>
|
|
|
|
<div v-if="currentUser" class="p-4 skin-layout">
|
|
<div class="preview-panel">
|
|
<h1 class="m-0 text-2xl font-bold flex items-center gap-2">
|
|
Skins
|
|
<span class="text-sm font-bold px-2 bg-brand-highlight text-brand rounded-full">Beta</span>
|
|
</h1>
|
|
<div class="preview-container">
|
|
<SkinPreviewRenderer
|
|
:cape-src="capeTexture"
|
|
:texture-src="skinTexture || ''"
|
|
:variant="skinVariant"
|
|
:nametag="skinNametag"
|
|
:initial-rotation="Math.PI / 8"
|
|
>
|
|
<template #subtitle>
|
|
<ButtonStyled :disabled="!!selectedSkin?.cape_id">
|
|
<button
|
|
v-tooltip="
|
|
selectedSkin?.cape_id
|
|
? 'The equipped skin is overriding the default cape.'
|
|
: undefined
|
|
"
|
|
:disabled="!!selectedSkin?.cape_id"
|
|
@click="
|
|
(e: MouseEvent) =>
|
|
selectCapeModal?.show(
|
|
e,
|
|
selectedSkin?.texture_key,
|
|
currentCape,
|
|
skinTexture,
|
|
skinVariant,
|
|
)
|
|
"
|
|
>
|
|
<UpdatedIcon />
|
|
Change cape
|
|
</button>
|
|
</ButtonStyled>
|
|
</template>
|
|
</SkinPreviewRenderer>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="skins-container">
|
|
<section class="flex flex-col gap-2 mt-1">
|
|
<h2 class="text-lg font-bold m-0 text-primary">Saved skins</h2>
|
|
<div class="skin-card-grid">
|
|
<SkinLikeTextButton class="skin-card" @click="openUploadSkinModal">
|
|
<template #icon>
|
|
<PlusIcon class="size-8" />
|
|
</template>
|
|
<span>Add a skin</span>
|
|
</SkinLikeTextButton>
|
|
|
|
<SkinButton
|
|
v-for="skin in savedSkins"
|
|
:key="`saved-skin-${skin.texture_key}`"
|
|
class="skin-card"
|
|
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
|
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
|
|
:selected="selectedSkin === skin"
|
|
@select="changeSkin(skin)"
|
|
>
|
|
<template #overlay-buttons>
|
|
<Button
|
|
color="green"
|
|
aria-label="Edit skin"
|
|
class="pointer-events-auto"
|
|
@click.stop="(e: MouseEvent) => editSkinModal?.show(e, skin)"
|
|
>
|
|
<EditIcon /> Edit
|
|
</Button>
|
|
<Button
|
|
v-show="!skin.is_equipped"
|
|
v-tooltip="'Delete skin'"
|
|
aria-label="Delete skin"
|
|
color="red"
|
|
class="!rounded-[100%] pointer-events-auto"
|
|
icon-only
|
|
@click.stop="() => confirmDeleteSkin(skin)"
|
|
>
|
|
<TrashIcon />
|
|
</Button>
|
|
</template>
|
|
</SkinButton>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="flex flex-col gap-2 mt-6">
|
|
<h2 class="text-lg font-bold m-0 text-primary">Default skins</h2>
|
|
<div class="skin-card-grid">
|
|
<SkinButton
|
|
v-for="skin in defaultSkins"
|
|
:key="`default-skin-${skin.texture_key}`"
|
|
class="skin-card"
|
|
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
|
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
|
|
:selected="selectedSkin === skin"
|
|
:tooltip="skin.name"
|
|
@select="changeSkin(skin)"
|
|
/>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="flex items-center justify-center min-h-[50vh] pt-[25%]">
|
|
<div
|
|
class="bg-bg-raised rounded-lg p-7 flex flex-col gap-5 shadow-md relative max-w-xl w-full mx-auto"
|
|
>
|
|
<img
|
|
:src="ExcitedRinthbot"
|
|
alt="Excited Modrinth Bot"
|
|
class="absolute -top-28 right-8 md:right-20 h-28 w-auto"
|
|
/>
|
|
<div
|
|
class="absolute top-0 left-0 w-full h-[1px] opacity-40 bg-gradient-to-r from-transparent via-green-500 to-transparent"
|
|
style="
|
|
background: linear-gradient(
|
|
to right,
|
|
transparent 2rem,
|
|
var(--color-green) calc(100% - 13rem),
|
|
var(--color-green) calc(100% - 5rem),
|
|
transparent calc(100% - 2rem)
|
|
);
|
|
"
|
|
></div>
|
|
|
|
<div class="flex flex-col gap-5">
|
|
<h1 class="text-3xl font-extrabold m-0">Please sign-in</h1>
|
|
<p class="text-lg m-0">
|
|
Please sign into your Minecraft account to use the skin management features of the
|
|
Modrinth app.
|
|
</p>
|
|
<ButtonStyled v-show="accountsCard" color="brand" :disabled="accountsCard.loginDisabled">
|
|
<button :disabled="accountsCard.loginDisabled" @click="login">
|
|
<LogInIcon v-if="!accountsCard.loginDisabled" />
|
|
<SpinnerIcon v-else class="animate-spin" />
|
|
Sign In
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
$skin-card-width: 155px;
|
|
$skin-card-gap: 4px;
|
|
|
|
.skin-layout {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) minmax(0, 2.5fr);
|
|
gap: 2.5rem;
|
|
|
|
@media (max-width: 700px) {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
.preview-panel {
|
|
top: 1.5rem;
|
|
position: sticky;
|
|
align-self: start;
|
|
padding: 0.5rem;
|
|
padding-top: 0;
|
|
}
|
|
|
|
.preview-container {
|
|
height: 80vh;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin-left: calc((2.5rem / 2));
|
|
|
|
@media (max-width: 700px) {
|
|
height: 50vh;
|
|
}
|
|
}
|
|
|
|
.skins-container {
|
|
padding-top: 0.5rem;
|
|
}
|
|
|
|
.skin-card-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: $skin-card-gap;
|
|
width: 100%;
|
|
|
|
@media (min-width: 1300px) {
|
|
grid-template-columns: repeat(4, 1fr);
|
|
}
|
|
|
|
@media (min-width: 1750px) {
|
|
grid-template-columns: repeat(5, 1fr);
|
|
}
|
|
|
|
@media (min-width: 2050px) {
|
|
grid-template-columns: repeat(6, 1fr);
|
|
}
|
|
}
|
|
|
|
.skin-card {
|
|
aspect-ratio: 0.95;
|
|
border-radius: 10px;
|
|
box-sizing: border-box;
|
|
width: 100%;
|
|
min-width: 0;
|
|
}
|
|
</style>
|