mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 12:05:53 +00:00
* feat: prefs frontend * fix: DI issue * fix: lint * feat: appearance settings cleanup + fix settings, remove pinia * fix: sync btn when logged out * fix: prepr * fix: sidebar issue * feat: language coverage + cleanup * feat: cleanup lang settings * fix: fmt * fix: CI * fix: ci * fix: storybook * feat: bring back loader/game version sort --------- Co-authored-by: tdgao <mr.trumgao@gmail.com>
1354 lines
37 KiB
Vue
1354 lines
37 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
CheckIcon,
|
|
EditIcon,
|
|
EyeIcon,
|
|
InfoIcon,
|
|
RotateCounterClockwiseIcon,
|
|
ShirtIcon,
|
|
SpinnerIcon,
|
|
WindowsIcon,
|
|
} from '@modrinth/assets'
|
|
import {
|
|
Button,
|
|
commonMessages,
|
|
ConfirmModal,
|
|
defineMessages,
|
|
injectAuth,
|
|
injectModrinthClient,
|
|
injectNotificationManager,
|
|
SkinPreviewRenderer,
|
|
Toggle,
|
|
useVIntl,
|
|
} from '@modrinth/ui'
|
|
import { arrayBufferToBase64 } from '@modrinth/utils'
|
|
import { useQuery } from '@tanstack/vue-query'
|
|
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 EarsModIcon from '@/assets/skins/ears-mod.png'
|
|
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 { useAppSettings } from '@/composables/use-app-settings.ts'
|
|
import { handleSevereError } from '@/composables/use-error.js'
|
|
import { trackEvent } from '@/helpers/analytics'
|
|
import { check_reachable, get_default_user, login as login_flow, users } from '@/helpers/auth'
|
|
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
|
|
import {
|
|
generateSkinPreviews,
|
|
getSkinPreviewKey,
|
|
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,
|
|
save_custom_skin,
|
|
set_custom_skin_order,
|
|
} from '@/helpers/skins.ts'
|
|
import { hasPride26Badge } from '@/helpers/user-campaigns.ts'
|
|
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
|
|
import { appMessages } from '@/utils/app-messages'
|
|
|
|
useRootBreadcrumb({
|
|
slot: 'root',
|
|
id: 'skins',
|
|
label: 'Skin selector',
|
|
to: '/skins',
|
|
visual: { type: 'icon', component: ShirtIcon },
|
|
})
|
|
|
|
type UnlistenFn = () => void
|
|
type VirtualSkinSectionListExpose = {
|
|
getAddSkinButtonElement: () => HTMLElement | null | undefined
|
|
}
|
|
|
|
const PENDING_SKIN_REFRESH_DELAY_MS = 11_000
|
|
const DEFAULT_SKIN_SECTION_SORT_ORDER = ['Default skins', 'Modrinth Pride']
|
|
const EARS_NOTICE_PLACEHOLDER = '__EARS_MOD_NAME__'
|
|
const messages = defineMessages({
|
|
modrinthPrideSection: {
|
|
id: 'app.skins.section.modrinth-pride',
|
|
defaultMessage: 'Modrinth Pride',
|
|
},
|
|
modrinthPrideTooltip: {
|
|
id: 'app.skins.section.modrinth-pride.tooltip',
|
|
defaultMessage:
|
|
'You received these skins for donating to a Modrinth Pride fundraiser during Pride Month.',
|
|
},
|
|
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',
|
|
},
|
|
demoApplyTooltip: {
|
|
id: 'app.skins.demo.apply-tooltip',
|
|
defaultMessage: 'Sign in to apply skins.',
|
|
},
|
|
editSkinButton: {
|
|
id: 'app.skins.preview.edit-button',
|
|
defaultMessage: 'Edit skin',
|
|
},
|
|
earsFeatureNotice: {
|
|
id: 'app.skins.ears-feature-notice',
|
|
defaultMessage: 'This skin uses features from the {ears} mod',
|
|
},
|
|
toggleEarsFeaturesOff: {
|
|
id: 'app.skins.toggle-ears-features-off',
|
|
defaultMessage: 'Toggle off',
|
|
},
|
|
toggleEarsFeaturesOn: {
|
|
id: 'app.skins.toggle-ears-features-on',
|
|
defaultMessage: 'Toggle on',
|
|
},
|
|
demoTitle: {
|
|
id: 'app.skins.demo.title',
|
|
defaultMessage: 'Editing with a demo account',
|
|
},
|
|
demoDescription: {
|
|
id: 'app.skins.demo.description',
|
|
defaultMessage: 'Sign in to your Minecraft account to save and apply skins!',
|
|
},
|
|
signInButton: {
|
|
id: 'app.skins.sign-in.button',
|
|
defaultMessage: 'Sign in to Microsoft',
|
|
},
|
|
})
|
|
|
|
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 auth = injectAuth()
|
|
const client = injectModrinthClient()
|
|
|
|
const appSettings = useAppSettings()
|
|
const skins = ref<Skin[]>([])
|
|
const capes = ref<Cape[]>([])
|
|
const offline = ref(!navigator.onLine)
|
|
|
|
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 earsFeaturesEnabled = ref(true)
|
|
const selectedSkinHasEarsFeatures = ref(false)
|
|
|
|
const originalSelectedSkin = ref<Skin | null>(null)
|
|
const earsFeatureNoticeParts = computed(() => {
|
|
const notice = formatMessage(messages.earsFeatureNotice, {
|
|
ears: EARS_NOTICE_PLACEHOLDER,
|
|
})
|
|
const placeholderIndex = notice.indexOf(EARS_NOTICE_PLACEHOLDER)
|
|
|
|
if (placeholderIndex === -1) {
|
|
return {
|
|
before: notice,
|
|
after: '',
|
|
hasEarsLink: false,
|
|
}
|
|
}
|
|
|
|
return {
|
|
before: notice.slice(0, placeholderIndex),
|
|
after: notice.slice(placeholderIndex + EARS_NOTICE_PLACEHOLDER.length),
|
|
hasEarsLink: true,
|
|
}
|
|
})
|
|
|
|
const savedSkins = computed(() => {
|
|
try {
|
|
return filterSavedSkins(skins.value)
|
|
} catch (error) {
|
|
handleError(error as Error)
|
|
return []
|
|
}
|
|
})
|
|
const authServerQuery = useQuery({
|
|
queryKey: ['authServerReachability'],
|
|
queryFn: async () => {
|
|
await check_reachable()
|
|
return true
|
|
},
|
|
refetchInterval: 5 * 60 * 1000,
|
|
retry: false,
|
|
refetchOnWindowFocus: false,
|
|
})
|
|
const { data: modrinthUser } = useQuery({
|
|
queryKey: computed(() => ['authenticated-user', 'campaigns', auth.user.value?.id]),
|
|
queryFn: () => client.labrinth.users_v3.getAuthenticated(),
|
|
enabled: () => !!auth.session_token.value,
|
|
retry: false,
|
|
})
|
|
const hasModrinthPrideCampaign = computed(
|
|
() => !!auth.session_token.value && hasPride26Badge(modrinthUser.value?.campaigns?.pride_26),
|
|
)
|
|
const defaultSkins = computed(() =>
|
|
filterDefaultSkins(skins.value).filter(
|
|
(skin) => skin.section !== 'Modrinth Pride' || hasModrinthPrideCampaign.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),
|
|
infoTooltip: getDefaultSkinSectionInfoTooltip(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(() => (appSettings.hideNametagSkinsPage ? undefined : username.value))
|
|
const isSkinManagementReadOnly = computed(
|
|
() =>
|
|
!!currentUser.value &&
|
|
(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 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) {
|
|
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 Pride':
|
|
return formatMessage(messages.modrinthPrideSection)
|
|
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 getDefaultSkinSectionInfoTooltip(section: string) {
|
|
switch (section) {
|
|
case 'Modrinth Pride':
|
|
return formatMessage(messages.modrinthPrideTooltip)
|
|
default:
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
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 (
|
|
!currentUser.value ||
|
|
!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()
|
|
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 {
|
|
return skinBlobUrlMap.get(getSkinPreviewKey(skin))
|
|
}
|
|
|
|
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() {
|
|
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 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 }) {
|
|
if (isSkinManagementReadOnly.value) return
|
|
|
|
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 (isSkinManagementReadOnly.value) return
|
|
|
|
if (!isSkinFileDrag(event)) {
|
|
return
|
|
}
|
|
|
|
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 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) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => selectedSkin.value?.cape_id,
|
|
() => {},
|
|
)
|
|
|
|
watch(selectedSkin, () => {
|
|
earsFeaturesEnabled.value = true
|
|
})
|
|
|
|
watch(isSkinManagementReadOnly, (readOnly) => {
|
|
if (readOnly) {
|
|
isDraggingSkinFile.value = false
|
|
isAddSkinButtonDragActive.value = false
|
|
}
|
|
})
|
|
|
|
onMounted(() => {
|
|
window.addEventListener('offline', onOffline)
|
|
window.addEventListener('online', onOnline)
|
|
userCheckInterval = window.setInterval(checkUserChanges, 250)
|
|
void setupAddSkinDragDropListener()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
isUnmounted = true
|
|
window.removeEventListener('offline', onOffline)
|
|
window.removeEventListener('online', onOnline)
|
|
|
|
if (userCheckInterval !== null) {
|
|
window.clearInterval(userCheckInterval)
|
|
}
|
|
|
|
if (pendingSkinRefreshTimeout !== null) {
|
|
window.clearTimeout(pendingSkinRefreshTimeout)
|
|
pendingSkinRefreshTimeout = null
|
|
}
|
|
|
|
if (unlistenAddSkinDragDrop) {
|
|
unlistenAddSkinDragDrop()
|
|
unlistenAddSkinDragDrop = null
|
|
}
|
|
})
|
|
|
|
function onOffline() {
|
|
offline.value = true
|
|
}
|
|
|
|
function onOnline() {
|
|
offline.value = false
|
|
void authServerQuery.refetch()
|
|
}
|
|
|
|
async function checkUserChanges() {
|
|
try {
|
|
const defaultId = await get_default_user()
|
|
if (defaultId !== currentUserId.value) {
|
|
await accountsCard.value?.refreshValues()
|
|
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"
|
|
:demo="!currentUser"
|
|
@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 class="skin-layout box-border grow p-4" :class="{ 'pb-40': !currentUser }">
|
|
<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(appMessages.skinSelectorLabel) }}
|
|
</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 || ''"
|
|
:ears-texture-src="selectedSkin?.texture"
|
|
:variant="skinVariant"
|
|
:nametag="skinNametag"
|
|
:initial-rotation="Math.PI / 8"
|
|
:ears-enabled="earsFeaturesEnabled"
|
|
@ears-features-detected="selectedSkinHasEarsFeatures = $event"
|
|
>
|
|
<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
|
|
class="skin-preview-subtitle flex w-full flex-col items-center gap-6"
|
|
:class="{ 'has-ears-features': selectedSkinHasEarsFeatures }"
|
|
>
|
|
<div
|
|
v-if="hasPendingSkinChange"
|
|
class="skin-preview-actions flex w-full items-center justify-center gap-1.5"
|
|
:class="selectedSkinHasEarsFeatures ? 'flex-nowrap' : 'flex-wrap'"
|
|
>
|
|
<Button
|
|
v-tooltip="
|
|
selectedSkinHasEarsFeatures
|
|
? formatMessage(commonMessages.resetButton)
|
|
: undefined
|
|
"
|
|
type="base"
|
|
size="lg"
|
|
class="skin-preview-action-button"
|
|
:disabled="isApplyingSkin || isSkinManagementReadOnly"
|
|
:aria-label="formatMessage(commonMessages.resetButton)"
|
|
@click="resetSelectedSkin"
|
|
>
|
|
<RotateCounterClockwiseIcon />
|
|
<span class="skin-preview-action-label">
|
|
{{ formatMessage(commonMessages.resetButton) }}
|
|
</span>
|
|
</Button>
|
|
<Button
|
|
v-tooltip="
|
|
!currentUser
|
|
? formatMessage(messages.demoApplyTooltip)
|
|
: selectedSkinHasEarsFeatures
|
|
? formatMessage(messages.applyButton)
|
|
: undefined
|
|
"
|
|
type="colored"
|
|
color="brand"
|
|
size="lg"
|
|
class="skin-preview-action-button"
|
|
:disabled="!currentUser || isApplyingSkin || isSkinManagementReadOnly"
|
|
:aria-label="formatMessage(messages.applyButton)"
|
|
@click="applySelectedSkin"
|
|
>
|
|
<SpinnerIcon v-if="isApplyingSkin" class="animate-spin" />
|
|
<CheckIcon v-else />
|
|
<span class="skin-preview-action-label">
|
|
{{ formatMessage(messages.applyButton) }}
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
<Button
|
|
v-else
|
|
type="base"
|
|
size="lg"
|
|
:disabled="!selectedSkin || isSkinManagementReadOnly"
|
|
@click="(e: MouseEvent) => selectedSkin && editSkinModal?.show(e, selectedSkin)"
|
|
>
|
|
<EditIcon />
|
|
{{ formatMessage(messages.editSkinButton) }}
|
|
</Button>
|
|
|
|
<div
|
|
v-if="selectedSkinHasEarsFeatures"
|
|
class="ears-feature-notice box-border flex w-full max-w-[340px] items-center justify-center gap-1.5 px-2"
|
|
>
|
|
<div class="ears-feature-copy flex min-w-0 flex-1 items-center gap-1.5">
|
|
<img
|
|
:src="EarsModIcon"
|
|
alt=""
|
|
class="size-10 shrink-0 rounded-[7px] border border-solid border-surface-5 object-cover"
|
|
/>
|
|
<p
|
|
class="ears-feature-description m-0 min-w-0 flex-1 text-sm font-medium leading-5 text-primary"
|
|
>
|
|
{{ earsFeatureNoticeParts.before
|
|
}}<router-link
|
|
v-if="earsFeatureNoticeParts.hasEarsLink"
|
|
to="/project/mfzaZK3Z"
|
|
class="text-inherit underline"
|
|
>Ears</router-link
|
|
>{{ earsFeatureNoticeParts.after }}
|
|
</p>
|
|
<router-link
|
|
to="/project/mfzaZK3Z"
|
|
class="ears-feature-compact-label hidden min-w-0 flex-1 text-sm font-medium leading-5 text-primary underline"
|
|
>Ears</router-link
|
|
>
|
|
</div>
|
|
<Button
|
|
type="outlined"
|
|
size="lg"
|
|
class="ears-feature-toggle-button shadow-md"
|
|
:aria-pressed="earsFeaturesEnabled"
|
|
:aria-label="
|
|
formatMessage(
|
|
earsFeaturesEnabled
|
|
? messages.toggleEarsFeaturesOff
|
|
: messages.toggleEarsFeaturesOn,
|
|
)
|
|
"
|
|
@click="earsFeaturesEnabled = !earsFeaturesEnabled"
|
|
>
|
|
{{
|
|
formatMessage(
|
|
earsFeaturesEnabled
|
|
? messages.toggleEarsFeaturesOff
|
|
: messages.toggleEarsFeaturesOn,
|
|
)
|
|
}}
|
|
</Button>
|
|
<Toggle
|
|
v-model="earsFeaturesEnabled"
|
|
v-tooltip="
|
|
formatMessage(
|
|
earsFeaturesEnabled
|
|
? messages.toggleEarsFeaturesOff
|
|
: messages.toggleEarsFeaturesOn,
|
|
)
|
|
"
|
|
small
|
|
class="ears-feature-toggle-switch"
|
|
:aria-label="
|
|
formatMessage(
|
|
earsFeaturesEnabled
|
|
? messages.toggleEarsFeaturesOff
|
|
: messages.toggleEarsFeaturesOn,
|
|
)
|
|
"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</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"
|
|
: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-if="!currentUser" class="sticky w-full bottom-0 z-20 p-4 pt-0">
|
|
<div
|
|
class="mx-auto flex w-full max-w-5xl items-center justify-between gap-3 rounded-[20px] border border-solid border-surface-5 bg-surface-3 p-4"
|
|
>
|
|
<div class="flex min-w-0 grow items-start gap-3">
|
|
<InfoIcon class="size-6 shrink-0 text-blue" />
|
|
<div class="flex min-w-0 flex-col gap-1">
|
|
<p class="m-0 text-lg font-semibold leading-6 text-contrast">
|
|
{{ formatMessage(messages.demoTitle) }}
|
|
</p>
|
|
<p class="m-0 text-base leading-6 text-primary">
|
|
{{ formatMessage(messages.demoDescription) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
v-show="accountsCard"
|
|
type="colored"
|
|
color="brand"
|
|
:disabled="accountsCard.loginDisabled"
|
|
@click="login"
|
|
>
|
|
<SpinnerIcon v-if="accountsCard.loginDisabled" class="animate-spin" />
|
|
<WindowsIcon v-else />
|
|
{{ formatMessage(messages.signInButton) }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
.skin-preview-subtitle {
|
|
container-type: inline-size;
|
|
}
|
|
|
|
.ears-feature-toggle-switch {
|
|
display: none;
|
|
}
|
|
|
|
@container (max-width: 300px) {
|
|
.skin-preview-subtitle {
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.has-ears-features .skin-preview-action-button {
|
|
padding-left: 0 !important;
|
|
padding-right: 0 !important;
|
|
width: 2.5rem;
|
|
}
|
|
|
|
.has-ears-features .skin-preview-action-label {
|
|
display: none;
|
|
}
|
|
|
|
.ears-feature-toggle-button {
|
|
display: none !important;
|
|
}
|
|
|
|
.ears-feature-notice {
|
|
padding-left: 0;
|
|
padding-right: 0;
|
|
}
|
|
|
|
.ears-feature-description {
|
|
display: none;
|
|
}
|
|
|
|
.ears-feature-compact-label {
|
|
display: block;
|
|
}
|
|
|
|
.ears-feature-toggle-switch {
|
|
display: inline-flex;
|
|
}
|
|
}
|
|
|
|
.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>
|