Files
Starlight_Lancher/apps/app-frontend/src/pages/Skins.vue
Ap_Tx d8a07c69c1
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
change:美西螈图标替换为下界之星,不过仍有素材需要绘画
2026-09-11 21:15:32 +08:00

1219 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import {
CheckIcon,
EditIcon,
EyeIcon,
LogInIcon,
PlusIcon,
RotateCounterClockwiseIcon,
SpinnerIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
ConfirmModal,
defineMessages,
injectNotificationManager,
NavTabs,
useVIntl,
} from '@modrinth/ui'
import SkinPreviewRenderer from '@modrinth/ui/src/components/skin/SkinPreviewRenderer.vue'
import { arrayBufferToBase64 } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { invoke } from '@tauri-apps/api/core'
import type { DragDropEvent } from '@tauri-apps/api/webview'
import { 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 { onBeforeRouteLeave, useRouter } from 'vue-router'
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 { useNetworkStatus } from '@/composables/useNetworkStatus'
import { check_reachable, get_default_user, users } from '@/helpers/auth'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import { 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_normalized_skin_texture,
normalize_skin_texture,
remove_custom_skin,
save_custom_skin,
set_custom_skin_order,
} from '@/helpers/skins.ts'
import { useTheming } from '@/store/state'
async function generateSkinPreviews(skins: Skin[], capes: Cape[]) {
const { generateSkinPreviews: generate } =
await import('@/helpers/rendering/skin-preview-renderer')
await generate(skins, capes)
}
type VirtualSkinSectionListExpose = {
getAddSkinButtonElement: () => HTMLElement | null | undefined
}
const PENDING_SKIN_REFRESH_DELAY_MS = 11_000
const DEFAULT_SKIN_SECTION_SORT_ORDER = ['Default skins']
const messages = defineMessages({
skinSelectorTitle: {
id: 'app.skins.title',
defaultMessage: 'Skin selector',
},
createSkinButton: {
id: 'app.skins.create-skin',
defaultMessage: 'Create skin',
},
modrinthSection: {
id: 'app.skins.section.modrinth',
defaultMessage: 'Modrinth',
},
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',
},
chaosCubedSection: {
id: 'app.skins.section.chaos-cubed',
defaultMessage: 'Chaos Cubed',
},
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.',
},
reorderSkinErrorTitle: {
id: 'app.skins.reorder-error.title',
defaultMessage: 'Failed to reorder skins',
},
reorderSkinErrorText: {
id: 'app.skins.reorder-error.text',
defaultMessage: 'Your skin order could not be saved.',
},
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',
},
axolotlAlt: {
id: 'app.skins.sign-in.axolotl-alt',
defaultMessage: 'Axolotl Launcher',
},
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 Axolotl Launcher.',
},
signInButton: {
id: 'app.skins.sign-in.button',
defaultMessage: 'Sign In',
},
offlineCompatibility: {
id: 'app.skins.offline-account.compatibility',
defaultMessage:
'Offline skins are applied locally with a resource pack. Compatibility is best on Minecraft 1.61.19.2; newer versions are supported on a best-effort basis. The skin is not uploaded to Mojang, may also replace other default-skinned players on your client, and some skin mods or server plugins may override it.',
},
offlineCompatibilityTitle: {
id: 'app.skins.offline-account.compatibility-title',
defaultMessage: 'Offline skin compatibility',
},
thirdPartyManagementTitle: {
id: 'app.skins.third-party-account.title',
defaultMessage: 'Third-party skin management',
},
thirdPartyManagementDescription: {
id: 'app.skins.third-party-account.description',
defaultMessage:
'Skins for this account are managed by its Yggdrasil provider. Open the provider website to change skins or capes.',
},
savedTab: {
id: 'app.skins.tabs.saved',
defaultMessage: 'Saved skins',
},
defaultTab: {
id: 'app.skins.tabs.default',
defaultMessage: 'Official skins',
},
})
const editSkinModal = useTemplateRef('editSkinModal')
const addSkinFileInput = useTemplateRef<HTMLInputElement>('addSkinFileInput')
const skinSectionList = useTemplateRef<VirtualSkinSectionListExpose>('skinSectionList')
const skinPreviewArea = useTemplateRef<HTMLElement>('skinPreviewArea')
const { formatMessage } = useVIntl()
const router = useRouter()
const notifications = injectNotificationManager()
const { addNotification, handleError } = notifications
const themeStore = useTheming()
const skins = ref<Skin[]>([])
const capes = ref<Cape[]>([])
const { browserOffline, offline, setNetworkReachable } = useNetworkStatus()
const accountsCard = inject('accountsCard') as Ref<typeof AccountsCard>
const accountChangeRevision = computed(() => accountsCard.value?.accountChangeRevision)
const currentUser = ref(undefined)
const currentUserId = ref<string | undefined>(undefined)
const currentAccountType = ref<'microsoft' | 'offline' | 'yggdrasil' | 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 authServerQuery = useQuery({
queryKey: ['authServerReachability'],
enabled: computed(() => !browserOffline.value),
queryFn: async () => {
try {
await check_reachable()
setNetworkReachable(true)
return true
} catch (error) {
setNetworkReachable(false)
throw error
}
},
refetchInterval: 5 * 60 * 1000,
retry: false,
refetchOnWindowFocus: false,
})
const defaultSkins = computed(() => filterDefaultSkins(skins.value))
const defaultSkinSections = computed(() => {
const sections = new Map<string, Skin[]>()
for (const skin of defaultSkins.value) {
const section = skin.section ?? 'Default skins'
const sectionSkins = sections.get(section)
if (sectionSkins) {
sectionSkins.push(skin)
} else {
sections.set(section, [skin])
}
}
return Array.from(sections, ([section, skins]) => ({
section,
title: getDefaultSkinSectionTitle(section),
skins,
})).sort(
(a, b) => getDefaultSkinSectionSortIndex(a.section) - getDefaultSkinSectionSortIndex(b.section),
)
})
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 () => {
const skin = selectedSkin.value
if (skin?.texture) {
try {
return await get_normalized_skin_texture(skin)
} catch (error) {
if (skin.texture.startsWith('data:image/')) {
return skin.texture
}
handleError(error as Error)
return ''
}
} else {
return ''
}
})
const capeTexture = computed(() => currentCape.value?.texture)
const skinVariant = computed(() => selectedSkin.value?.variant)
const skinNametag = computed(() => (themeStore.hideNametagSkinsPage ? undefined : username.value))
const isSkinManagementReadOnly = computed(
() =>
currentAccountType.value === 'yggdrasil' ||
(currentAccountType.value !== 'offline' &&
(offline.value || (authServerQuery.isError.value && !authServerQuery.isLoading.value))),
)
const hasPendingSkinChange = computed(
() => !skinsMatch(selectedSkin.value, originalSelectedSkin.value),
)
let userCheckInterval: number | null = null
let pendingSkinRefreshTimeout: number | null = null
let isUnmounted = false
let unlistenNativeDrop: (() => void) | null = null
let skinNavigationRevision = 0
const skinNavigationRevisions = new WeakMap<object, number>()
const isDraggingSkinFile = ref(false)
const isAddSkinButtonDragActive = ref(false)
const deleteSkinModal = ref()
const skinToDelete = ref<Skin | null>(null)
const skinListTab = ref<'saved' | 'default'>('saved')
const skinListTabLinks = computed(() => [
{
href: 'saved',
label: formatMessage(messages.savedTab),
},
{
href: 'default',
label: formatMessage(messages.defaultTab),
},
])
function confirmDeleteSkin(skin: Skin) {
if (isSkinManagementReadOnly.value) return
skinToDelete.value = skin
deleteSkinModal.value?.show()
}
async function deleteSkin() {
if (isSkinManagementReadOnly.value) return
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 {
const loadedSkins = (await get_available_skins()) ?? []
const loadedEquippedSkin = loadedSkins.find((s) => s.is_equipped)
const locallyKnownEquippedSkin =
originalSelectedSkin.value &&
(loadedSkins.find((skin) => skinsMatch(skin, originalSelectedSkin.value)) ??
(originalSelectedSkin.value.texture.startsWith('data:image/')
? originalSelectedSkin.value
: undefined))
const shouldPreserveKnownEquippedSkin =
isSkinManagementReadOnly.value &&
locallyKnownEquippedSkin &&
!skinsMatch(loadedEquippedSkin, locallyKnownEquippedSkin)
skins.value =
shouldPreserveKnownEquippedSkin && locallyKnownEquippedSkin
? mergeEquippedSkin(loadedSkins, locallyKnownEquippedSkin)
: loadedSkins
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 mergeEquippedSkin(list: Skin[], equippedSkin: Skin) {
let foundEquippedSkin = false
const mergedSkins = list.map((skin) => {
const isEquipped = skinsMatch(skin, equippedSkin)
foundEquippedSkin ||= isEquipped
return {
...skin,
is_equipped: isEquipped,
}
})
if (!foundEquippedSkin) {
mergedSkins.unshift({
...equippedSkin,
is_equipped: true,
})
}
return mergedSkins
}
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 skinsMatchIgnoringSource(a?: Skin | null, b?: Skin | null) {
return (
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 'Modrinth':
return formatMessage(messages.modrinthSection)
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 'Chaos Cubed':
return formatMessage(messages.chaosCubedSection)
case 'Default skins':
return formatMessage(messages.defaultSkinsSection)
default:
return section ?? formatMessage(messages.defaultSkinsSection)
}
}
function getDefaultSkinSectionSortIndex(section: string) {
const index = DEFAULT_SKIN_SECTION_SORT_ORDER.indexOf(section)
return index === -1 ? DEFAULT_SKIN_SECTION_SORT_ORDER.length : index
}
function changeSkin(newSkin: Skin) {
if (isSkinManagementReadOnly.value) return
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 insertLocalSkin(savedSkin: Skin) {
const firstNonCustomSkinIndex = skins.value.findIndex((skin) => skin.source !== 'custom')
if (firstNonCustomSkinIndex === -1) {
skins.value = [...skins.value, savedSkin]
return
}
const nextSkins = [...skins.value]
nextSkins.splice(firstNonCustomSkinIndex, 0, savedSkin)
skins.value = nextSkins
}
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) {
insertLocalSkin({
...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)
}
async function reorderSavedSkins(orderedSkins: Skin[]) {
const previousSkins = skins.value
const previousSelectedSkin = selectedSkin.value
const previousOriginalSelectedSkin = originalSelectedSkin.value
const orderedTextureKeys = orderedSkins.map((skin) => skin.texture_key)
const orderedTextureKeySet = new Set(orderedTextureKeys)
const remainingSavedSkins = previousSkins.filter(
(skin) => skin.source !== 'default' && !orderedTextureKeySet.has(skin.texture_key),
)
const defaultSkins = previousSkins.filter((skin) => skin.source === 'default')
const nextSavedSkins = [...orderedSkins, ...remainingSavedSkins]
skins.value = [...nextSavedSkins, ...defaultSkins]
generateSkinPreviews(skins.value, capes.value)
try {
const persistedSavedSkins = await preserveExternalSkins(nextSavedSkins)
if (persistedSavedSkins.some((skin, index) => skin !== nextSavedSkins[index])) {
skins.value = [...persistedSavedSkins, ...defaultSkins]
generateSkinPreviews(skins.value, capes.value)
}
await set_custom_skin_order(
persistedSavedSkins
.filter((skin) => skin.source === 'custom')
.map((skin) => skin.texture_key),
)
} catch (error) {
skins.value = previousSkins
selectedSkin.value = previousSelectedSkin
originalSelectedSkin.value = previousOriginalSelectedSkin
generateSkinPreviews(skins.value, capes.value)
addNotification({
type: 'error',
title: formatMessage(messages.reorderSkinErrorTitle),
text: error instanceof Error ? error.message : formatMessage(messages.reorderSkinErrorText),
})
await loadSkins()
}
}
async function preserveExternalSkins(skinsToPersist: Skin[]) {
const preservedSkins: Skin[] = []
for (const skin of skinsToPersist) {
if (skin.source !== 'custom_external') {
preservedSkins.push(skin)
continue
}
const textureBlob = await normalize_skin_texture(skin.texture)
const capeId = skin.cape_id ? capes.value.find((cape) => cape.id === skin.cape_id) : undefined
const savedSkin = await save_custom_skin(skin, textureBlob, skin.variant, capeId, false)
const preservedSkin: Skin = {
...savedSkin,
source: 'custom',
is_equipped: skin.is_equipped,
}
if (skinsMatchIgnoringSource(selectedSkin.value, skin)) {
selectedSkin.value = preservedSkin
}
if (skinsMatchIgnoringSource(originalSelectedSkin.value, skin)) {
originalSelectedSkin.value = preservedSkin
void accountsCard.value?.setEquippedSkin(preservedSkin)
}
preservedSkins.push(preservedSkin)
}
return preservedSkins
}
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 ||
isSkinManagementReadOnly.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(offline.value)
currentUserId.value = defaultId
const allAccounts = await users(offline.value)
const selectedAccount = allAccounts.find((acc) => acc.profile.id === defaultId)
currentAccountType.value = selectedAccount?.account_type
currentUser.value = selectedAccount
} catch (e) {
handleError(e as Error)
currentUser.value = undefined
currentUserId.value = undefined
currentAccountType.value = undefined
}
}
async function refreshSelectedAccount() {
await loadCurrentUser()
await loadCapes()
await loadSkins()
}
watch(accountChangeRevision, (revision, previousRevision) => {
if (revision === undefined || previousRevision === undefined) return
void refreshSelectedAccount()
})
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() {
if (offline.value) return
accountsCard.value?.login()
}
function openAddSkinFileBrowser() {
if (isSkinManagementReadOnly.value) return
addSkinFileInput.value?.click()
}
async function onAddSkinFileInputChange(e: Event) {
if (isSkinManagementReadOnly.value) return
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 onAddSkinDragOver(event: DragEvent) {
if (isSkinManagementReadOnly.value) return
if (!isSkinFileDrag(event)) {
return
}
event.preventDefault()
isAddSkinButtonDragActive.value = true
}
function onAddSkinDragLeave() {
if (isSkinManagementReadOnly.value) return
isAddSkinButtonDragActive.value = false
}
async function onAddSkinDrop(event: DragEvent) {
if (isSkinManagementReadOnly.value) return
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 processSkinFileBuffer(buffer: Uint8Array | ArrayBuffer) {
if (isSkinManagementReadOnly.value) return
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)
}
}
/** Native drop handler: Tauri blocks HTML5 `drop` for OS-level file drags,
* so we need onDragDropEvent to receive the file. HTML5 dragenter/dragover
* still fire and provide visual feedback — this only handles the drop. */
async function setupNativeDropHandler() {
try {
unlistenNativeDrop = await getCurrentWebview().onDragDropEvent(
(event: { payload: DragDropEvent }) => {
const payload = event.payload
if (payload.type !== 'drop') return
if (isSkinManagementReadOnly.value) return
const pngPath = (payload.paths as string[]).find((p: string) =>
p.toLowerCase().endsWith('.png'),
)
if (!pngPath) return
readDroppedSkinFile(pngPath)
},
)
} catch (error) {
console.warn('Failed to set up native drop handler on skin page', error)
}
}
async function readDroppedSkinFile(path: string) {
try {
const data = await invoke<ArrayBuffer>('plugin:files|file_read_dragged_file', { path })
await processSkinFileBuffer(new Uint8Array(data))
} catch (error) {
handleError(error as Error)
}
}
watch(
() => selectedSkin.value?.cape_id,
() => {},
)
watch(isSkinManagementReadOnly, (readOnly) => {
if (readOnly) {
isDraggingSkinFile.value = false
isAddSkinButtonDragActive.value = false
}
})
const removeNavigationFailureHandler = router.afterEach((to, _from, failure) => {
if (
failure &&
skinNavigationRevisions.get(to) === skinNavigationRevision &&
skinPreviewArea.value
) {
skinPreviewArea.value.style.visibility = ''
}
})
onBeforeRouteLeave(async (to) => {
const previewArea = skinPreviewArea.value
if (!previewArea) return true
skinNavigationRevision += 1
skinNavigationRevisions.set(to, skinNavigationRevision)
previewArea.style.visibility = 'hidden'
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => window.requestAnimationFrame(() => resolve()))
})
return true
})
onMounted(() => {
userCheckInterval = window.setInterval(checkUserChanges, 250)
void setupNativeDropHandler()
})
onUnmounted(() => {
isUnmounted = true
removeNavigationFailureHandler()
if (userCheckInterval !== null) {
window.clearInterval(userCheckInterval)
}
if (unlistenNativeDrop) {
unlistenNativeDrop()
unlistenNativeDrop = null
}
if (pendingSkinRefreshTimeout !== null) {
window.clearTimeout(pendingSkinRefreshTimeout)
pendingSkinRefreshTimeout = null
}
})
async function checkUserChanges() {
try {
const defaultId = await get_default_user(offline.value)
if (defaultId !== currentUserId.value) {
await refreshSelectedAccount()
}
} 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"
/>
<Teleport
v-if="currentUser && currentAccountType === 'offline'"
to="#sidebar-default-teleport-target"
>
<section class="p-4">
<h3 class="m-0 text-base font-semibold text-primary">
{{ formatMessage(messages.offlineCompatibilityTitle) }}
</h3>
<p class="mb-0 mt-2 text-sm leading-6 text-secondary">
{{ formatMessage(messages.offlineCompatibility) }}
</p>
</section>
</Teleport>
<Teleport
v-if="currentUser && currentAccountType === 'yggdrasil'"
to="#sidebar-default-teleport-target"
>
<section class="p-4">
<h3 class="m-0 text-base font-semibold text-primary">
{{ formatMessage(messages.thirdPartyManagementTitle) }}
</h3>
<p class="mb-0 mt-2 text-sm leading-6 text-secondary">
{{ formatMessage(messages.thirdPartyManagementDescription) }}
</p>
</section>
</Teleport>
<div
v-if="currentUser"
data-onboarding-id="skins-page"
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
ref="skinPreviewArea"
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 w-max shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-full bg-bg-blue px-3 py-1 text-sm font-semibold leading-5 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 || isSkinManagementReadOnly"
@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 || isSkinManagementReadOnly"
@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 || isSkinManagementReadOnly"
@click="(e: MouseEvent) => selectedSkin && editSkinModal?.show(e, selectedSkin)"
>
<EditIcon />
{{ formatMessage(messages.editSkinButton) }}
</button>
</template>
</SkinPreviewRenderer>
</div>
</div>
<div class="pt-2">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<NavTabs
:active-index="skinListTab === 'saved' ? 0 : 1"
:links="skinListTabLinks"
mode="local"
@tab-click="
(index: number) => {
skinListTab = index === 0 ? 'saved' : 'default'
}
"
/>
<ButtonStyled color="brand">
<button @click="router.push('/lab/skin-editor')">
<PlusIcon />
{{ formatMessage(messages.createSkinButton) }}
</button>
</ButtonStyled>
</div>
<VirtualSkinSectionList
ref="skinSectionList"
:active-tab="skinListTab"
: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"
:read-only="isSkinManagementReadOnly"
@select="changeSkin"
@edit="(skin, event) => editSkinModal?.show(event, skin)"
@delete="confirmDeleteSkin"
@reorder-saved-skins="reorderSavedSkins"
@add-skin="openAddSkinFileBrowser"
@add-skin-dragenter="onAddSkinDragOver"
@add-skin-dragover="onAddSkinDragOver"
@add-skin-dragleave="onAddSkinDragLeave"
@add-skin-drop="onAddSkinDrop"
/>
</div>
</div>
<div
v-else
data-onboarding-id="skins-page"
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="@/assets/netherstar.png"
:alt="formatMessage(messages.axolotlAlt)"
class="absolute -top-32 right-8 h-36 w-36 object-contain md:right-20"
/>
<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-if="!offline"
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>