mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 09:34:50 +00:00
* 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
880 lines
25 KiB
Vue
880 lines
25 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
CheckIcon,
|
|
EditIcon,
|
|
ExcitedRinthbot,
|
|
EyeIcon,
|
|
LogInIcon,
|
|
RotateCounterClockwiseIcon,
|
|
SpinnerIcon,
|
|
} from '@modrinth/assets'
|
|
import {
|
|
ButtonStyled,
|
|
commonMessages,
|
|
ConfirmModal,
|
|
defineMessages,
|
|
injectNotificationManager,
|
|
SkinPreviewRenderer,
|
|
useVIntl,
|
|
} from '@modrinth/ui'
|
|
import { arrayBufferToBase64 } from '@modrinth/utils'
|
|
import { type DragDropEvent, getCurrentWebview } from '@tauri-apps/api/webview'
|
|
import { computedAsync } from '@vueuse/core'
|
|
import type { Ref } from 'vue'
|
|
import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
|
|
|
import type AccountsCard from '@/components/ui/AccountsCard.vue'
|
|
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
|
|
import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.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 type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts'
|
|
import {
|
|
equip_skin,
|
|
filterDefaultSkins,
|
|
filterSavedSkins,
|
|
flush_pending_skin_change,
|
|
flush_pending_skin_change_for_profile,
|
|
get_available_capes,
|
|
get_available_skins,
|
|
get_dragged_skin_data,
|
|
get_normalized_skin_texture,
|
|
normalize_skin_texture,
|
|
remove_custom_skin,
|
|
} from '@/helpers/skins.ts'
|
|
import { handleSevereError } from '@/store/error'
|
|
import { useTheming } from '@/store/state'
|
|
|
|
type UnlistenFn = () => void
|
|
type VirtualSkinSectionListExpose = {
|
|
getAddSkinButtonElement: () => HTMLElement | null | undefined
|
|
}
|
|
|
|
const PENDING_SKIN_REFRESH_DELAY_MS = 11_000
|
|
const messages = defineMessages({
|
|
skinSelectorTitle: {
|
|
id: 'app.skins.title',
|
|
defaultMessage: 'Skin selector',
|
|
},
|
|
defaultSkinsSection: {
|
|
id: 'app.skins.section.default-skins',
|
|
defaultMessage: 'Default skins',
|
|
},
|
|
mineconEarth2017Section: {
|
|
id: 'app.skins.section.minecon-earth-2017',
|
|
defaultMessage: 'MINECON Earth 2017',
|
|
},
|
|
buildersAndBiomesSection: {
|
|
id: 'app.skins.section.builders-and-biomes',
|
|
defaultMessage: 'Builders & Biomes',
|
|
},
|
|
stridingHeroSection: {
|
|
id: 'app.skins.section.striding-hero',
|
|
defaultMessage: 'Striding Hero',
|
|
},
|
|
theGardenAwakensSection: {
|
|
id: 'app.skins.section.the-garden-awakens',
|
|
defaultMessage: 'The Garden Awakens',
|
|
},
|
|
chaseTheSkiesSection: {
|
|
id: 'app.skins.section.chase-the-skies',
|
|
defaultMessage: 'Chase the Skies',
|
|
},
|
|
theCopperAgeSection: {
|
|
id: 'app.skins.section.the-copper-age',
|
|
defaultMessage: 'The Copper Age',
|
|
},
|
|
mountsOfMayhemSection: {
|
|
id: 'app.skins.section.mounts-of-mayhem',
|
|
defaultMessage: 'Mounts of Mayhem',
|
|
},
|
|
tinyTakeoverSection: {
|
|
id: 'app.skins.section.tiny-takeover',
|
|
defaultMessage: 'Tiny Takeover',
|
|
},
|
|
rateLimitTitle: {
|
|
id: 'app.skins.rate-limit.title',
|
|
defaultMessage: 'Slow down!',
|
|
},
|
|
rateLimitText: {
|
|
id: 'app.skins.rate-limit.text',
|
|
defaultMessage:
|
|
"You're changing your skin too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again.",
|
|
},
|
|
droppedFileErrorTitle: {
|
|
id: 'app.skins.dropped-file-error.title',
|
|
defaultMessage: 'Error processing file',
|
|
},
|
|
droppedFileErrorText: {
|
|
id: 'app.skins.dropped-file-error.text',
|
|
defaultMessage: 'Failed to read the dropped file.',
|
|
},
|
|
deleteSkinTitle: {
|
|
id: 'app.skins.delete-modal.title',
|
|
defaultMessage: 'Are you sure you want to delete this skin?',
|
|
},
|
|
deleteSkinDescription: {
|
|
id: 'app.skins.delete-modal.description',
|
|
defaultMessage: 'This will permanently delete the selected skin. This action cannot be undone.',
|
|
},
|
|
previewingBadge: {
|
|
id: 'app.skins.previewing-badge',
|
|
defaultMessage: 'Previewing',
|
|
},
|
|
applyButton: {
|
|
id: 'app.skins.apply-button',
|
|
defaultMessage: 'Apply',
|
|
},
|
|
editSkinButton: {
|
|
id: 'app.skins.preview.edit-button',
|
|
defaultMessage: 'Edit skin',
|
|
},
|
|
excitedRinthbotAlt: {
|
|
id: 'app.skins.sign-in.rinthbot-alt',
|
|
defaultMessage: 'Excited Modrinth Bot',
|
|
},
|
|
signInTitle: {
|
|
id: 'app.skins.sign-in.title',
|
|
defaultMessage: 'Please sign in',
|
|
},
|
|
signInDescription: {
|
|
id: 'app.skins.sign-in.description',
|
|
defaultMessage:
|
|
'Please sign into your Minecraft account to use the skin management features of the Modrinth app.',
|
|
},
|
|
signInButton: {
|
|
id: 'app.skins.sign-in.button',
|
|
defaultMessage: 'Sign In',
|
|
},
|
|
})
|
|
|
|
const editSkinModal = useTemplateRef('editSkinModal')
|
|
const addSkinFileInput = useTemplateRef<HTMLInputElement>('addSkinFileInput')
|
|
const skinSectionList = useTemplateRef<VirtualSkinSectionListExpose>('skinSectionList')
|
|
|
|
const { formatMessage } = useVIntl()
|
|
const notifications = injectNotificationManager()
|
|
const { addNotification, handleError } = notifications
|
|
|
|
const themeStore = useTheming()
|
|
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 isApplyingSkin = ref(false)
|
|
|
|
const originalSelectedSkin = ref<Skin | null>(null)
|
|
|
|
const savedSkins = computed(() => {
|
|
try {
|
|
return filterSavedSkins(skins.value)
|
|
} catch (error) {
|
|
handleError(error as Error)
|
|
return []
|
|
}
|
|
})
|
|
const defaultSkins = computed(() => filterDefaultSkins(skins.value))
|
|
const defaultSkinSections = computed(() => {
|
|
const sections = new Map<string, Skin[]>()
|
|
|
|
for (const skin of defaultSkins.value) {
|
|
const sectionTitle = getDefaultSkinSectionTitle(skin.section)
|
|
const sectionSkins = sections.get(sectionTitle)
|
|
|
|
if (sectionSkins) {
|
|
sectionSkins.push(skin)
|
|
} else {
|
|
sections.set(sectionTitle, [skin])
|
|
}
|
|
}
|
|
|
|
return Array.from(sections, ([title, skins]) => ({ title, skins }))
|
|
})
|
|
|
|
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 undefined
|
|
})
|
|
|
|
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(() => (themeStore.hideNametagSkinsPage ? undefined : username.value))
|
|
const hasPendingSkinChange = computed(
|
|
() => !skinsMatch(selectedSkin.value, originalSelectedSkin.value),
|
|
)
|
|
|
|
let userCheckInterval: number | null = null
|
|
let pendingSkinRefreshTimeout: number | null = null
|
|
let unlistenAddSkinDragDrop: UnlistenFn | null = null
|
|
let isUnmounted = false
|
|
|
|
const isDraggingSkinFile = ref(false)
|
|
const isAddSkinButtonDragActive = ref(false)
|
|
|
|
const deleteSkinModal = ref()
|
|
const skinToDelete = ref<Skin | null>(null)
|
|
|
|
function confirmDeleteSkin(skin: Skin) {
|
|
skinToDelete.value = skin
|
|
deleteSkinModal.value?.show()
|
|
}
|
|
|
|
async function deleteSkin() {
|
|
const deletedSkin = skinToDelete.value
|
|
if (!deletedSkin) return
|
|
|
|
try {
|
|
await remove_custom_skin(deletedSkin)
|
|
removeLocalSkin(deletedSkin)
|
|
} catch (error) {
|
|
handleError(error as Error)
|
|
} finally {
|
|
skinToDelete.value = null
|
|
}
|
|
}
|
|
|
|
async function loadCapes() {
|
|
try {
|
|
capes.value = (await get_available_capes()) ?? []
|
|
} 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
function skinsMatch(a?: Skin | null, b?: Skin | null) {
|
|
return (
|
|
a?.source === b?.source &&
|
|
a?.texture_key === b?.texture_key &&
|
|
a?.variant === b?.variant &&
|
|
(a?.cape_id ?? null) === (b?.cape_id ?? null)
|
|
)
|
|
}
|
|
|
|
function isSkinSelected(skin: Skin) {
|
|
return skinsMatch(selectedSkin.value, skin)
|
|
}
|
|
|
|
function isSkinActive(skin: Skin) {
|
|
return hasPendingSkinChange.value && skinsMatch(originalSelectedSkin.value, skin)
|
|
}
|
|
|
|
function getErrorMessage(error: unknown) {
|
|
return error instanceof Error ? error.message : String(error)
|
|
}
|
|
|
|
function isMinecraftSkinRateLimitError(error: unknown) {
|
|
const message = getErrorMessage(error)
|
|
return message.includes('429 Too Many Requests') || message.includes('client error (429')
|
|
}
|
|
|
|
function getDefaultSkinSectionTitle(section?: string) {
|
|
switch (section) {
|
|
case 'MINECON Earth 2017':
|
|
return formatMessage(messages.mineconEarth2017Section)
|
|
case 'Builders & Biomes':
|
|
return formatMessage(messages.buildersAndBiomesSection)
|
|
case 'Striding Hero':
|
|
return formatMessage(messages.stridingHeroSection)
|
|
case 'The Garden Awakens':
|
|
return formatMessage(messages.theGardenAwakensSection)
|
|
case 'Chase the Skies':
|
|
return formatMessage(messages.chaseTheSkiesSection)
|
|
case 'The Copper Age':
|
|
return formatMessage(messages.theCopperAgeSection)
|
|
case 'Mounts of Mayhem':
|
|
return formatMessage(messages.mountsOfMayhemSection)
|
|
case 'Tiny Takeover':
|
|
return formatMessage(messages.tinyTakeoverSection)
|
|
case 'Default skins':
|
|
return formatMessage(messages.defaultSkinsSection)
|
|
default:
|
|
return section ?? formatMessage(messages.defaultSkinsSection)
|
|
}
|
|
}
|
|
|
|
function changeSkin(newSkin: Skin) {
|
|
selectedSkin.value = newSkin
|
|
}
|
|
|
|
function resetSelectedSkin() {
|
|
selectedSkin.value =
|
|
skins.value.find((skin) => skinsMatch(skin, originalSelectedSkin.value)) ??
|
|
originalSelectedSkin.value
|
|
}
|
|
|
|
function removeLocalSkin(deletedSkin: Skin) {
|
|
const nextSkins = skins.value.filter((skin) => !skinsMatch(skin, deletedSkin))
|
|
skins.value = nextSkins
|
|
|
|
if (selectedSkin.value && skinsMatch(selectedSkin.value, deletedSkin)) {
|
|
selectedSkin.value =
|
|
nextSkins.find((skin) => skinsMatch(skin, originalSelectedSkin.value)) ??
|
|
nextSkins.find((skin) => skin.is_equipped) ??
|
|
null
|
|
}
|
|
|
|
if (originalSelectedSkin.value && skinsMatch(originalSelectedSkin.value, deletedSkin)) {
|
|
originalSelectedSkin.value = nextSkins.find((skin) => skin.is_equipped) ?? null
|
|
}
|
|
|
|
generateSkinPreviews(skins.value, capes.value)
|
|
}
|
|
|
|
function setLocallyEquippedSkin(skinToApply: Skin) {
|
|
skins.value = skins.value.map((skin) => ({
|
|
...skin,
|
|
is_equipped: skinsMatch(skin, skinToApply),
|
|
}))
|
|
originalSelectedSkin.value =
|
|
skins.value.find((skin) => skinsMatch(skin, skinToApply)) ?? skinToApply
|
|
selectedSkin.value = originalSelectedSkin.value
|
|
void accountsCard.value?.setEquippedSkin(originalSelectedSkin.value)
|
|
}
|
|
|
|
function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin) {
|
|
let foundSkin = false
|
|
const replacesSelectedSkin =
|
|
selectedSkin.value?.texture_key === savedSkin.texture_key ||
|
|
(previousSkin ? skinsMatch(selectedSkin.value, previousSkin) : false)
|
|
const replacesOriginalSkin =
|
|
originalSelectedSkin.value?.texture_key === savedSkin.texture_key ||
|
|
(previousSkin ? skinsMatch(originalSelectedSkin.value, previousSkin) : false)
|
|
|
|
skins.value = skins.value.map((skin) => {
|
|
const isUpdatedSkin = skin.texture_key === savedSkin.texture_key
|
|
const isPreviousSkin = previousSkin && skinsMatch(skin, previousSkin)
|
|
|
|
if (isUpdatedSkin || isPreviousSkin) {
|
|
foundSkin = true
|
|
return {
|
|
...savedSkin,
|
|
is_equipped: applied || savedSkin.is_equipped,
|
|
}
|
|
}
|
|
|
|
return {
|
|
...skin,
|
|
is_equipped: applied ? false : skin.is_equipped,
|
|
}
|
|
})
|
|
|
|
if (!foundSkin) {
|
|
skins.value.unshift({
|
|
...savedSkin,
|
|
is_equipped: applied || savedSkin.is_equipped,
|
|
})
|
|
}
|
|
|
|
if (applied) {
|
|
const locallyEquippedSkin =
|
|
skins.value.find((skin) => skin.texture_key === savedSkin.texture_key) ?? savedSkin
|
|
|
|
originalSelectedSkin.value = locallyEquippedSkin
|
|
selectedSkin.value = locallyEquippedSkin
|
|
void accountsCard.value?.setEquippedSkin(locallyEquippedSkin)
|
|
} else {
|
|
const locallySavedSkin =
|
|
skins.value.find((skin) => skin.texture_key === savedSkin.texture_key) ?? savedSkin
|
|
|
|
if (replacesSelectedSkin) {
|
|
selectedSkin.value = locallySavedSkin
|
|
}
|
|
|
|
if (replacesOriginalSkin) {
|
|
originalSelectedSkin.value = locallySavedSkin
|
|
}
|
|
}
|
|
|
|
generateSkinPreviews(skins.value, capes.value)
|
|
}
|
|
|
|
function schedulePendingSkinRefresh() {
|
|
if (pendingSkinRefreshTimeout !== null) {
|
|
window.clearTimeout(pendingSkinRefreshTimeout)
|
|
}
|
|
|
|
const pendingProfileId = currentUserId.value
|
|
|
|
pendingSkinRefreshTimeout = window.setTimeout(async () => {
|
|
pendingSkinRefreshTimeout = null
|
|
|
|
if (isUnmounted) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
if (pendingProfileId) {
|
|
await flush_pending_skin_change_for_profile(pendingProfileId)
|
|
} else {
|
|
await flush_pending_skin_change()
|
|
}
|
|
} catch (error) {
|
|
handleError(error as Error)
|
|
schedulePendingSkinRefresh()
|
|
return
|
|
}
|
|
|
|
if (accountsCard.value) {
|
|
await accountsCard.value.refreshValues()
|
|
}
|
|
|
|
await loadCapes()
|
|
await loadSkins()
|
|
}, PENDING_SKIN_REFRESH_DELAY_MS)
|
|
}
|
|
|
|
async function applySelectedSkin() {
|
|
const skinToApply = selectedSkin.value
|
|
if (!skinToApply || !hasPendingSkinChange.value || isApplyingSkin.value) return
|
|
|
|
isApplyingSkin.value = true
|
|
try {
|
|
await equip_skin(skinToApply)
|
|
setLocallyEquippedSkin(skinToApply)
|
|
schedulePendingSkinRefresh()
|
|
} catch (error) {
|
|
if (isMinecraftSkinRateLimitError(error)) {
|
|
notifications.addNotification({
|
|
type: 'error',
|
|
title: formatMessage(messages.rateLimitTitle),
|
|
text: formatMessage(messages.rateLimitText),
|
|
})
|
|
} else {
|
|
handleError(error as Error)
|
|
}
|
|
} finally {
|
|
isApplyingSkin.value = false
|
|
}
|
|
}
|
|
|
|
async function onSkinSaved(options: { applied: boolean; skin?: Skin; previousSkin?: Skin }) {
|
|
if (options.skin) {
|
|
updateLocalSkin(options.skin, options.applied, options.previousSkin)
|
|
}
|
|
|
|
if (!options.skin) {
|
|
await loadCapes()
|
|
await loadSkins()
|
|
}
|
|
|
|
if (options.applied) {
|
|
schedulePendingSkinRefresh()
|
|
}
|
|
}
|
|
|
|
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 openAddSkinFileBrowser() {
|
|
addSkinFileInput.value?.click()
|
|
}
|
|
|
|
async function onAddSkinFileInputChange(e: Event) {
|
|
const files = (e.target as HTMLInputElement).files
|
|
const file = files?.[0]
|
|
|
|
if (!file) {
|
|
return
|
|
}
|
|
|
|
await processSkinFileBuffer(await file.arrayBuffer())
|
|
|
|
if (addSkinFileInput.value) {
|
|
addSkinFileInput.value.value = ''
|
|
}
|
|
}
|
|
|
|
function isSkinImagePath(path: string) {
|
|
return path.toLowerCase().endsWith('.png')
|
|
}
|
|
|
|
function isSkinFileDrag(event: DragEvent) {
|
|
const items = Array.from(event.dataTransfer?.items ?? [])
|
|
const files = Array.from(event.dataTransfer?.files ?? [])
|
|
|
|
return (
|
|
items.some((item) => item.kind === 'file' && item.type === 'image/png') ||
|
|
files.some((file) => file.type === 'image/png' || isSkinImagePath(file.name))
|
|
)
|
|
}
|
|
|
|
function isPositionOverAddSkinButton(position: { x: number; y: number }) {
|
|
const element = skinSectionList.value?.getAddSkinButtonElement()
|
|
|
|
if (!element) {
|
|
return false
|
|
}
|
|
|
|
const { x, y } = position
|
|
const rect = element.getBoundingClientRect()
|
|
|
|
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom
|
|
}
|
|
|
|
async function handleAddSkinNativeDragDrop(event: { payload: DragDropEvent }) {
|
|
const payload = event.payload
|
|
|
|
if (payload.type === 'leave') {
|
|
isDraggingSkinFile.value = false
|
|
isAddSkinButtonDragActive.value = false
|
|
return
|
|
}
|
|
|
|
if (payload.type === 'enter') {
|
|
isDraggingSkinFile.value = payload.paths.some(isSkinImagePath)
|
|
}
|
|
|
|
if (payload.type === 'enter' || payload.type === 'over') {
|
|
isAddSkinButtonDragActive.value =
|
|
isDraggingSkinFile.value && isPositionOverAddSkinButton(payload.position)
|
|
return
|
|
}
|
|
|
|
const hasSkinPath = payload.paths.some(isSkinImagePath)
|
|
const shouldUpload =
|
|
(isDraggingSkinFile.value || hasSkinPath) && isPositionOverAddSkinButton(payload.position)
|
|
|
|
isDraggingSkinFile.value = false
|
|
isAddSkinButtonDragActive.value = false
|
|
|
|
if (!shouldUpload) {
|
|
return
|
|
}
|
|
|
|
const skinPath = payload.paths.find(isSkinImagePath)
|
|
|
|
if (!skinPath) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
const data = await get_dragged_skin_data(skinPath)
|
|
await processSkinFileBuffer(data)
|
|
} catch (error) {
|
|
addNotification({
|
|
title: formatMessage(messages.droppedFileErrorTitle),
|
|
text: error instanceof Error ? error.message : formatMessage(messages.droppedFileErrorText),
|
|
type: 'error',
|
|
})
|
|
}
|
|
}
|
|
|
|
function onAddSkinDragOver(event: DragEvent) {
|
|
if (!isSkinFileDrag(event)) {
|
|
return
|
|
}
|
|
|
|
isAddSkinButtonDragActive.value = true
|
|
}
|
|
|
|
function onAddSkinDragLeave() {
|
|
isAddSkinButtonDragActive.value = false
|
|
}
|
|
|
|
async function onAddSkinDrop(event: DragEvent) {
|
|
isAddSkinButtonDragActive.value = false
|
|
|
|
const file = Array.from(event.dataTransfer?.files ?? []).find(
|
|
(file) => file.type === 'image/png' || isSkinImagePath(file.name),
|
|
)
|
|
|
|
if (!file) {
|
|
return
|
|
}
|
|
|
|
await processSkinFileBuffer(await file.arrayBuffer())
|
|
}
|
|
|
|
async function setupAddSkinDragDropListener() {
|
|
try {
|
|
const unlisten = await getCurrentWebview().onDragDropEvent(handleAddSkinNativeDragDrop)
|
|
|
|
if (isUnmounted) {
|
|
unlisten()
|
|
return
|
|
}
|
|
|
|
unlistenAddSkinDragDrop = unlisten
|
|
} catch (error) {
|
|
handleError(error as Error)
|
|
}
|
|
}
|
|
|
|
async function processSkinFileBuffer(buffer: Uint8Array | ArrayBuffer) {
|
|
const fakeEvent = new MouseEvent('click')
|
|
const originalSkinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(buffer)
|
|
try {
|
|
const skinTextureNormalized = await normalize_skin_texture(originalSkinTexUrl)
|
|
const skinTexUrl: SkinTextureUrl = {
|
|
original: originalSkinTexUrl,
|
|
normalized: `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized),
|
|
}
|
|
editSkinModal.value?.showNew(fakeEvent, skinTexUrl)
|
|
} catch (error) {
|
|
handleError(error as Error)
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => selectedSkin.value?.cape_id,
|
|
() => {},
|
|
)
|
|
|
|
onMounted(() => {
|
|
userCheckInterval = window.setInterval(checkUserChanges, 250)
|
|
void setupAddSkinDragDropListener()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
isUnmounted = true
|
|
|
|
if (userCheckInterval !== null) {
|
|
window.clearInterval(userCheckInterval)
|
|
}
|
|
|
|
if (pendingSkinRefreshTimeout !== null) {
|
|
window.clearTimeout(pendingSkinRefreshTimeout)
|
|
pendingSkinRefreshTimeout = null
|
|
}
|
|
|
|
if (unlistenAddSkinDragDrop) {
|
|
unlistenAddSkinDragDrop()
|
|
unlistenAddSkinDragDrop = null
|
|
}
|
|
})
|
|
|
|
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(), loadCurrentUser()])
|
|
await loadSkins()
|
|
</script>
|
|
|
|
<template>
|
|
<EditSkinModal
|
|
ref="editSkinModal"
|
|
:capes="capes"
|
|
@saved="onSkinSaved"
|
|
@deleted="() => loadSkins()"
|
|
/>
|
|
<input
|
|
ref="addSkinFileInput"
|
|
type="file"
|
|
accept="image/png"
|
|
class="hidden"
|
|
@change="onAddSkinFileInputChange"
|
|
/>
|
|
<ConfirmModal
|
|
ref="deleteSkinModal"
|
|
:title="formatMessage(messages.deleteSkinTitle)"
|
|
:description="formatMessage(messages.deleteSkinDescription)"
|
|
:proceed-label="formatMessage(commonMessages.deleteLabel)"
|
|
@proceed="deleteSkin"
|
|
/>
|
|
|
|
<div v-if="currentUser" class="skin-layout box-border min-h-full p-4">
|
|
<div class="sticky top-6 self-start p-2 pt-0">
|
|
<h1 class="m-0 text-2xl font-bold flex items-center gap-2">
|
|
{{ formatMessage(messages.skinSelectorTitle) }}
|
|
</h1>
|
|
<div
|
|
class="ml-5 mt-4 flex h-[calc(80vh-1rem)] items-center justify-center max-[700px]:h-[calc(50vh-1rem)]"
|
|
>
|
|
<SkinPreviewRenderer
|
|
:cape-src="capeTexture"
|
|
:texture-src="skinTexture || ''"
|
|
:variant="skinVariant"
|
|
:nametag="skinNametag"
|
|
:initial-rotation="Math.PI / 8"
|
|
>
|
|
<template v-if="hasPendingSkinChange" #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" />
|
|
{{ formatMessage(messages.previewingBadge) }}
|
|
</div>
|
|
</template>
|
|
<template #subtitle>
|
|
<div
|
|
v-if="hasPendingSkinChange"
|
|
class="flex max-w-[calc(100vw-2rem)] flex-wrap items-center justify-center gap-2 px-2"
|
|
>
|
|
<button
|
|
class="flex h-10 min-w-0 cursor-pointer 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 transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
|
|
:disabled="isApplyingSkin"
|
|
@click="resetSelectedSkin"
|
|
>
|
|
<RotateCounterClockwiseIcon />
|
|
{{ formatMessage(commonMessages.resetButton) }}
|
|
</button>
|
|
<button
|
|
class="flex h-10 min-w-0 cursor-pointer 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 transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
|
|
:disabled="isApplyingSkin"
|
|
@click="applySelectedSkin"
|
|
>
|
|
<SpinnerIcon v-if="isApplyingSkin" class="animate-spin" />
|
|
<CheckIcon v-else />
|
|
{{ formatMessage(messages.applyButton) }}
|
|
</button>
|
|
</div>
|
|
<button
|
|
v-else
|
|
class="flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
|
|
:disabled="!selectedSkin"
|
|
@click="(e: MouseEvent) => selectedSkin && editSkinModal?.show(e, selectedSkin)"
|
|
>
|
|
<EditIcon />
|
|
{{ formatMessage(messages.editSkinButton) }}
|
|
</button>
|
|
</template>
|
|
</SkinPreviewRenderer>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="pt-2">
|
|
<VirtualSkinSectionList
|
|
ref="skinSectionList"
|
|
:saved-skins="savedSkins"
|
|
:default-skin-sections="defaultSkinSections"
|
|
:get-baked-skin-textures="getBakedSkinTextures"
|
|
:is-skin-selected="isSkinSelected"
|
|
:is-skin-active="isSkinActive"
|
|
:is-add-skin-button-drag-active="isAddSkinButtonDragActive"
|
|
@select="changeSkin"
|
|
@edit="(skin, event) => editSkinModal?.show(event, skin)"
|
|
@delete="confirmDeleteSkin"
|
|
@add-skin="openAddSkinFileBrowser"
|
|
@add-skin-dragenter="onAddSkinDragOver"
|
|
@add-skin-dragover="onAddSkinDragOver"
|
|
@add-skin-dragleave="onAddSkinDragLeave"
|
|
@add-skin-drop="onAddSkinDrop"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="box-border flex min-h-full items-center justify-center pt-[25%]">
|
|
<div
|
|
class="relative mx-auto flex w-full max-w-xl flex-col gap-5 rounded-lg bg-bg-raised p-7 shadow-lg"
|
|
>
|
|
<img
|
|
:src="ExcitedRinthbot"
|
|
:alt="formatMessage(messages.excitedRinthbotAlt)"
|
|
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">{{ formatMessage(messages.signInTitle) }}</h1>
|
|
<p class="text-lg m-0">
|
|
{{ formatMessage(messages.signInDescription) }}
|
|
</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" />
|
|
{{ formatMessage(messages.signInButton) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
.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;
|
|
}
|
|
}
|
|
</style>
|