Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ const props = defineProps<{
isSkinSelected: (skin: Skin) => boolean
isSkinActive: (skin: Skin) => boolean
isAddSkinButtonDragActive: boolean
readOnly?: boolean
}>()

const emit = defineEmits<{
Expand Down Expand Up @@ -362,7 +363,8 @@ defineExpose({ getAddSkinButtonElement })
ref="addSkinButton"
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
dropzone
:drag-active="isAddSkinButtonDragActive"
:disabled="readOnly"
:drag-active="!readOnly && isAddSkinButtonDragActive"
@click="emit('add-skin')"
@dragenter="emit('add-skin-dragenter', $event)"
@dragover="emit('add-skin-dragover', $event)"
Expand All @@ -384,9 +386,10 @@ defineExpose({ getAddSkinButtonElement })
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="isSkinSelected(skin)"
:active="isSkinActive(skin)"
:disabled="readOnly"
@select="emit('select', skin)"
>
<template #overlay-buttons>
<template v-if="!readOnly" #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
Expand Down Expand Up @@ -423,6 +426,7 @@ defineExpose({ getAddSkinButtonElement })
:selected="isSkinSelected(skin)"
:active="isSkinActive(skin)"
:tooltip="skin.name"
:disabled="readOnly"
@select="emit('select', skin)"
/>
</div>
Expand Down
125 changes: 117 additions & 8 deletions apps/app-frontend/src/pages/Skins.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ 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 { 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, skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts'
Expand Down Expand Up @@ -181,6 +181,7 @@ const client = injectModrinthClient()
const themeStore = useTheming()
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)
Expand All @@ -200,6 +201,16 @@ const savedSkins = computed(() => {
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(),
Expand Down Expand Up @@ -249,15 +260,28 @@ const currentCape = computed(() => {
})

const skinTexture = computedAsync(async () => {
if (selectedSkin.value?.texture) {
return await get_normalized_skin_texture(selectedSkin.value)
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(
() => offline.value || (authServerQuery.isError.value && !authServerQuery.isLoading.value),
)
const hasPendingSkinChange = computed(
() => !skinsMatch(selectedSkin.value, originalSelectedSkin.value),
)
Expand All @@ -274,11 +298,15 @@ 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

Expand All @@ -304,7 +332,23 @@ async function loadCapes() {

async function loadSkins() {
try {
skins.value = (await get_available_skins()) ?? []
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
Expand All @@ -315,6 +359,28 @@ async function loadSkins() {
}
}

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 &&
Expand Down Expand Up @@ -385,6 +451,8 @@ function getDefaultSkinSectionSortIndex(section: string) {
}

function changeSkin(newSkin: Skin) {
if (isSkinManagementReadOnly.value) return

selectedSkin.value = newSkin
}

Expand Down Expand Up @@ -517,7 +585,13 @@ function schedulePendingSkinRefresh() {

async function applySelectedSkin() {
const skinToApply = selectedSkin.value
if (!skinToApply || !hasPendingSkinChange.value || isApplyingSkin.value) return
if (
!skinToApply ||
!hasPendingSkinChange.value ||
isApplyingSkin.value ||
isSkinManagementReadOnly.value
)
return

isApplyingSkin.value = true
try {
Expand Down Expand Up @@ -586,10 +660,14 @@ async function 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]

Expand Down Expand Up @@ -632,6 +710,8 @@ function isPositionOverAddSkinButton(position: { x: number; y: number }) {
}

async function handleAddSkinNativeDragDrop(event: { payload: DragDropEvent }) {
if (isSkinManagementReadOnly.value) return

const payload = event.payload

if (payload.type === 'leave') {
Expand Down Expand Up @@ -680,6 +760,8 @@ async function handleAddSkinNativeDragDrop(event: { payload: DragDropEvent }) {
}

function onAddSkinDragOver(event: DragEvent) {
if (isSkinManagementReadOnly.value) return

if (!isSkinFileDrag(event)) {
return
}
Expand All @@ -688,10 +770,14 @@ function onAddSkinDragOver(event: DragEvent) {
}

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(
Expand Down Expand Up @@ -721,6 +807,8 @@ async function setupAddSkinDragDropListener() {
}

async function processSkinFileBuffer(buffer: Uint8Array | ArrayBuffer) {
if (isSkinManagementReadOnly.value) return

const fakeEvent = new MouseEvent('click')
const originalSkinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(buffer)
try {
Expand All @@ -740,13 +828,24 @@ watch(
() => {},
)

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)
Expand All @@ -763,6 +862,15 @@ onUnmounted(() => {
}
})

function onOffline() {
offline.value = true
}

function onOnline() {
offline.value = false
void authServerQuery.refetch()
}

async function checkUserChanges() {
try {
const defaultId = await get_default_user()
Expand Down Expand Up @@ -834,15 +942,15 @@ await loadSkins()
>
<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"
: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"
:disabled="isApplyingSkin || isSkinManagementReadOnly"
@click="applySelectedSkin"
>
<SpinnerIcon v-if="isApplyingSkin" class="animate-spin" />
Expand All @@ -853,7 +961,7 @@ await loadSkins()
<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"
:disabled="!selectedSkin || isSkinManagementReadOnly"
@click="(e: MouseEvent) => selectedSkin && editSkinModal?.show(e, selectedSkin)"
>
<EditIcon />
Expand All @@ -873,6 +981,7 @@ await loadSkins()
: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"
Expand Down
Loading
Loading