Merge branch 'main' into cal/drag-and-drop-to-move-skins

This commit is contained in:
tdgao
2026-06-10 09:32:41 -07:00
52 changed files with 1671 additions and 459 deletions
@@ -14,7 +14,7 @@ import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { handleSevereError } from '@/store/error.js'
import { type MinecraftAuthError, minecraftAuthErrors } from './minecraft-auth-errors'
import { findMinecraftAuthError, type MinecraftAuthError } from './minecraft-auth-errors'
const modal = ref<InstanceType<typeof NewModal>>()
const rawError = ref<string>('')
@@ -26,7 +26,7 @@ const loadingSignIn = ref(false)
function show(errorVal: { message?: string }) {
rawError.value = errorVal?.message ?? String(errorVal)
matchedError.value = minecraftAuthErrors.find((e) => rawError.value.includes(e.errorCode)) ?? null
matchedError.value = findMinecraftAuthError(rawError.value)
debugCollapsed.value = true
hide_ads_window()
@@ -1,10 +1,93 @@
export interface MinecraftAuthError {
errorCode: string
errorCode?: string
errorMatchers?: string[]
matches?: (message: string) => boolean
whatHappened: string
stepsToFix: string[]
}
export const minecraftAuthErrors: MinecraftAuthError[] = [
{
errorMatchers: ['Failed to deserialize response to JSON during step RefreshOAuthToken:'],
whatHappened:
'Your saved Microsoft sign-in token has expired or was revoked, so Modrinth App cannot refresh your Minecraft session.',
stepsToFix: [
'Sign out of the affected Minecraft account in Modrinth App',
'Sign in to the account again',
'Once the new sign-in finishes, try launching Minecraft again',
],
},
{
errorMatchers: ['Failed to deserialize response to JSON during step SisuAuthenticate:'],
whatHappened:
'Xbox services rejected the first sign-in response. This is most often caused by your system clock or time zone being out of sync.',
stepsToFix: [
'Open your system date and time settings',
'Turn on automatic time zone and automatic time, if available',
'Use the sync option in your system settings to synchronize the clock',
'Restart Modrinth App',
'Try signing in again',
],
},
{
matches: (message) =>
message.includes('Failed to deserialize response to JSON during step MinecraftToken:') &&
message.includes('429 Too Many Requests'),
whatHappened:
'Microsoft or Minecraft temporarily blocked the sign-in request because there were too many recent attempts.',
stepsToFix: [
'Wait about an hour before trying again',
'Restart Modrinth App after waiting',
'Try signing in once more',
'If the same message appears, wait longer before retrying so the temporary limit can clear',
],
},
{
matches: (message) =>
message.includes('Failed to deserialize response to JSON during step MinecraftToken:') &&
/Status Code: 5\d\d/.test(message),
whatHappened:
"Minecraft's authentication service is returning a server error, so Modrinth App cannot finish signing you in right now.",
stepsToFix: [
'Wait a few minutes and try signing in again',
'Check <a href="https://support.xbox.com/xbox-live-status">Xbox Status</a> for current service issues',
'Try signing in with the <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a> to confirm whether Minecraft sign-in is also affected there',
'If the service is healthy and this keeps happening, contact support with the debug information below',
],
},
{
errorMatchers: ['Failed to fetch player profile'],
whatHappened:
'Minecraft services could not return a Java Edition profile for this account. This most often happens when the game was purchased recently, the Java profile has not finished being created, or the wrong Microsoft account is being used.',
stepsToFix: [
'Sign in with the <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a>',
'Launch Minecraft: Java Edition once from the official launcher',
'Wait up to an hour if the purchase or profile setup was recent',
'Make sure you are using the Microsoft account that owns Minecraft. See <a href="https://support.modrinth.com/en/articles/9409136-finding-the-right-xbox-account">Finding the right Xbox account</a> for help',
'Try signing in to Modrinth App again',
],
},
{
matches: (message) =>
message.includes('error sending request for url (') &&
[
'minecraft.net',
'minecraftservices.com',
'mojang.com',
'xbox.com',
'xboxlive.com',
'live.com',
].some((domain) => message.includes(domain)),
whatHappened:
'Modrinth App could not connect to a Microsoft, Xbox, or Minecraft service needed for sign-in. This is usually caused by a local network, DNS, proxy, firewall, hosts file, VPN, or antivirus issue.',
stepsToFix: [
'Restart Modrinth App and try signing in again',
'Check that your internet connection is working',
'Allow Modrinth App through your firewall, antivirus, proxy, VPN, and hosts file rules',
'Try a different network or temporarily disable VPN/proxy software if you use one',
'If routing or DNS is the issue, a service like Cloudflare WARP can sometimes help',
],
},
{
errorCode: '2148916222',
whatHappened:
@@ -87,4 +170,31 @@ export const minecraftAuthErrors: MinecraftAuthError[] = [
'Once finished, try signing in again',
],
},
{
errorMatchers: ['Failed to deserialize response to JSON during step XstsAuthorize:'],
whatHappened:
'Xbox services rejected the request to authorize this account for Minecraft services, but did not return a specific account restriction that Modrinth App recognizes.',
stepsToFix: [
'Sign in with the <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a>',
'Complete any prompts shown by Microsoft, Xbox, or Minecraft',
'Try signing in to Modrinth App again',
'If the official launcher also fails, follow the error shown there or contact Xbox Support',
],
},
]
export function findMinecraftAuthError(message: string): MinecraftAuthError | null {
return (
minecraftAuthErrors.find((error) => {
if (error.errorCode && message.includes(error.errorCode)) {
return true
}
if (error.errorMatchers?.some((matcher) => message.includes(matcher))) {
return true
}
return error.matches?.(message) ?? false
}) ?? null
)
}
@@ -1,12 +1,5 @@
<script setup lang="ts">
import {
DropdownIcon,
EditIcon,
GripVerticalIcon,
PlusIcon,
TrashIcon,
UnknownIcon,
} from '@modrinth/assets'
import { DropdownIcon, EditIcon, PlusIcon, TrashIcon, UnknownIcon } from '@modrinth/assets'
import {
Accordion,
ButtonStyled,
@@ -20,7 +13,6 @@ import {
import { useElementSize, useWindowSize } from '@vueuse/core'
import { Tooltip } from 'floating-vue'
import { computed, nextTick, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import Draggable from 'vuedraggable'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Skin } from '@/helpers/skins.ts'
@@ -81,18 +73,6 @@ const messages = defineMessages({
id: 'app.skins.delete-button',
defaultMessage: 'Delete skin',
},
reorderButton: {
id: 'app.skins.section.saved-skins.reorder-button',
defaultMessage: 'Reorder',
},
finishReorderButton: {
id: 'app.skins.section.saved-skins.finish-reorder-button',
defaultMessage: 'Finish',
},
reorderSkinButton: {
id: 'app.skins.reorder-skin-button',
defaultMessage: 'Reorder skin',
},
})
const props = defineProps<{
@@ -102,7 +82,7 @@ const props = defineProps<{
isSkinSelected: (skin: Skin) => boolean
isSkinActive: (skin: Skin) => boolean
isAddSkinButtonDragActive: boolean
isSavedSkinReorderMode: boolean
readOnly?: boolean
}>()
const emit = defineEmits<{
@@ -114,8 +94,6 @@ const emit = defineEmits<{
'add-skin-dragover': [event: DragEvent]
'add-skin-dragleave': [event: DragEvent]
'add-skin-drop': [event: DragEvent]
'reorder-saved-skins': [textureKeys: string[]]
'toggle-saved-skin-reorder': []
}>()
const addSkinButton = useTemplateRef<AddSkinButtonRef>('addSkinButton')
@@ -318,13 +296,6 @@ function getAddSkinButtonElement() {
return button?.getRootElement()
}
function emitSavedSkinOrder(nextSkins: Skin[]) {
emit(
'reorder-saved-skins',
nextSkins.map((skin) => skin.texture_key),
)
}
defineExpose({ getAddSkinButtonElement })
</script>
@@ -346,24 +317,8 @@ defineExpose({ getAddSkinButtonElement })
]"
:style="{ transform: `translateY(${top}px)` }"
>
<div
v-if="section.kind === 'saved'"
class="absolute right-0 z-20"
:class="index === 0 ? 'top-1' : 'top-6'"
>
<ButtonStyled size="small" :color="isSavedSkinReorderMode ? 'brand' : 'standard'">
<button @click.stop="emit('toggle-saved-skin-reorder')">
{{
formatMessage(
isSavedSkinReorderMode ? messages.finishReorderButton : messages.reorderButton,
)
}}
</button>
</ButtonStyled>
</div>
<Accordion
button-class="group flex w-full items-center gap-[6px] bg-transparent m-0 p-0 pr-24 border-none cursor-pointer text-left"
button-class="group flex w-full items-center gap-[6px] bg-transparent m-0 p-0 border-none cursor-pointer text-left"
content-class="pt-2"
:open-by-default="isSectionOpen(section.key)"
@on-open="setSectionOpen(section.key, true)"
@@ -400,91 +355,63 @@ defineExpose({ getAddSkinButtonElement })
</Tooltip>
</template>
<Draggable
<div
v-if="section.kind === 'saved'"
:model-value="section.skins"
item-key="texture_key"
tag="div"
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
handle=".saved-skin-reorder-handle"
ghost-class="saved-skin-sortable-ghost"
chosen-class="saved-skin-sortable-chosen"
drag-class="saved-skin-sortable-drag"
fallback-class="saved-skin-sortable-fallback"
:animation="150"
:disabled="!isSavedSkinReorderMode"
:fallback-on-body="true"
:fallback-tolerance="4"
:force-fallback="true"
@update:model-value="emitSavedSkinOrder"
>
<template #header>
<SkinLikeTextButton
ref="addSkinButton"
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
dropzone
:drag-active="isAddSkinButtonDragActive"
@click="emit('add-skin')"
@dragenter="emit('add-skin-dragenter', $event)"
@dragover="emit('add-skin-dragover', $event)"
@dragleave="emit('add-skin-dragleave', $event)"
@drop="emit('add-skin-drop', $event)"
>
<template #icon>
<PlusIcon class="size-8" />
</template>
{{ formatMessage(messages.addSkinButton) }}
<template #subtitle>{{ formatMessage(messages.dragAndDropSubtitle) }}</template>
</SkinLikeTextButton>
</template>
<SkinLikeTextButton
ref="addSkinButton"
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
dropzone
:disabled="readOnly"
:drag-active="!readOnly && isAddSkinButtonDragActive"
@click="emit('add-skin')"
@dragenter="emit('add-skin-dragenter', $event)"
@dragover="emit('add-skin-dragover', $event)"
@dragleave="emit('add-skin-dragleave', $event)"
@drop="emit('add-skin-drop', $event)"
>
<template #icon>
<PlusIcon class="size-8" />
</template>
{{ formatMessage(messages.addSkinButton) }}
<template #subtitle>{{ formatMessage(messages.dragAndDropSubtitle) }}</template>
</SkinLikeTextButton>
<template #item="{ element: skin }">
<SkinButton
:key="skinKey(skin, 'saved-skin')"
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="isSkinSelected(skin)"
:active="isSkinActive(skin)"
:selectable="!isSavedSkinReorderMode"
@select="emit('select', skin)"
>
<template v-if="isSavedSkinReorderMode" #top-buttons>
<ButtonStyled circular type="transparent">
<button
v-tooltip="formatMessage(messages.reorderSkinButton)"
:aria-label="formatMessage(messages.reorderSkinButton)"
class="saved-skin-reorder-handle pointer-events-auto cursor-grab active:cursor-grabbing"
@click.stop
>
<GripVerticalIcon />
</button>
</ButtonStyled>
</template>
<template v-if="!isSavedSkinReorderMode" #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
class="pointer-events-auto"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
>
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-show="!skin.is_equipped" circular color="red">
<button
v-tooltip="formatMessage(messages.deleteSkinButton)"
:aria-label="formatMessage(messages.deleteSkinButton)"
class="!rounded-[100%] pointer-events-auto"
@click.stop="emit('delete', skin)"
>
<TrashIcon />
</button>
</ButtonStyled>
</template>
</SkinButton>
</template>
</Draggable>
<SkinButton
v-for="skin in section.skins"
:key="skinKey(skin, 'saved-skin')"
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="isSkinSelected(skin)"
:active="isSkinActive(skin)"
:disabled="readOnly"
@select="emit('select', skin)"
>
<template v-if="!readOnly" #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
class="pointer-events-auto"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
>
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-show="!skin.is_equipped" circular color="red">
<button
v-tooltip="formatMessage(messages.deleteSkinButton)"
:aria-label="formatMessage(messages.deleteSkinButton)"
class="!rounded-[100%] pointer-events-auto"
@click.stop="emit('delete', skin)"
>
<TrashIcon />
</button>
</ButtonStyled>
</template>
</SkinButton>
</div>
<div
v-else
@@ -499,28 +426,23 @@ defineExpose({ getAddSkinButtonElement })
:selected="isSkinSelected(skin)"
:active="isSkinActive(skin)"
:tooltip="skin.name"
:disabled="readOnly"
@select="emit('select', skin)"
/>
>
<template #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
class="pointer-events-auto"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
>
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
</template>
</SkinButton>
</div>
</Accordion>
</div>
</div>
</template>
<style scoped>
.saved-skin-sortable-chosen {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
}
.saved-skin-sortable-ghost {
opacity: 0.4;
}
.saved-skin-sortable-drag,
.saved-skin-sortable-fallback {
box-shadow:
0 8px 18px rgba(0, 0, 0, 0.25),
0 2px 8px rgba(0, 0, 0, 0.2);
}
</style>
+2
View File
@@ -197,11 +197,13 @@ export async function add_project_from_version(
path: string,
versionId: string,
reason: DownloadReason,
dependentOnVersionId?: string,
): Promise<string> {
return await invoke('plugin:profile|profile_add_project_from_version', {
path,
versionId,
reason,
dependentOnVersionId,
})
}
@@ -404,7 +404,7 @@ async function generateSkinPreviewsForGeneration(
const headKey = headKeys[i]
const rawCached = cachedSkinPreviews[skinKey]
if (rawCached) {
if (rawCached && !skinBlobUrlMap.has(skinKey)) {
const cached: RenderResult = {
forwards: URL.createObjectURL(rawCached.forwards),
backwards: URL.createObjectURL(rawCached.backwards),
@@ -413,7 +413,7 @@ async function generateSkinPreviewsForGeneration(
}
const cachedHead = cachedHeadPreviews[headKey]
if (cachedHead) {
if (cachedHead && !headBlobUrlMap.has(headKey)) {
headBlobUrlMap.set(headKey, URL.createObjectURL(cachedHead))
}
}
+117 -51
View File
@@ -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'
@@ -46,7 +46,6 @@ import {
get_normalized_skin_texture,
normalize_skin_texture,
remove_custom_skin,
set_custom_skin_order,
} from '@/helpers/skins.ts'
import { hasPride26Badge } from '@/helpers/user-campaigns.ts'
import { handleSevereError } from '@/store/error'
@@ -182,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)
@@ -201,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(),
@@ -250,8 +260,18 @@ 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 ''
}
@@ -259,6 +279,9 @@ const skinTexture = computedAsync(async () => {
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),
)
@@ -270,17 +293,20 @@ let isUnmounted = false
const isDraggingSkinFile = ref(false)
const isAddSkinButtonDragActive = ref(false)
const isSavedSkinReorderMode = 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
@@ -306,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
@@ -317,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 &&
@@ -387,6 +451,8 @@ function getDefaultSkinSectionSortIndex(section: string) {
}
function changeSkin(newSkin: Skin) {
if (isSkinManagementReadOnly.value) return
selectedSkin.value = newSkin
}
@@ -482,44 +548,6 @@ function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin)
generateSkinPreviews(skins.value, capes.value)
}
function reorderLocalSavedSkins(textureKeys: string[]) {
const requestedKeys = new Set(textureKeys)
const savedSkinList = skins.value.filter((skin) => skin.source !== 'default')
const savedSkinByKey = new Map(savedSkinList.map((skin) => [skin.texture_key, skin]))
const orderedSavedSkins = [
...textureKeys.map((key) => savedSkinByKey.get(key)).filter((skin): skin is Skin => !!skin),
...savedSkinList.filter((skin) => !requestedKeys.has(skin.texture_key)),
]
let savedSkinIndex = 0
skins.value = skins.value.flatMap((skin) => {
if (skin.source === 'default') {
return [skin]
}
const nextSkin = orderedSavedSkins[savedSkinIndex++]
return nextSkin ? [nextSkin] : []
})
}
async function onSavedSkinsReordered(textureKeys: string[]) {
const previousSkins = skins.value
reorderLocalSavedSkins(textureKeys)
try {
await set_custom_skin_order(textureKeys)
} catch (error) {
skins.value = previousSkins
handleError(error as Error)
await loadSkins()
}
}
function toggleSavedSkinReorderMode() {
isSavedSkinReorderMode.value = !isSavedSkinReorderMode.value
}
function schedulePendingSkinRefresh() {
if (pendingSkinRefreshTimeout !== null) {
window.clearTimeout(pendingSkinRefreshTimeout)
@@ -557,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 {
@@ -626,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]
@@ -672,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') {
@@ -720,6 +760,8 @@ async function handleAddSkinNativeDragDrop(event: { payload: DragDropEvent }) {
}
function onAddSkinDragOver(event: DragEvent) {
if (isSkinManagementReadOnly.value) return
if (!isSkinFileDrag(event)) {
return
}
@@ -728,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(
@@ -761,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 {
@@ -780,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)
@@ -803,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()
@@ -874,7 +942,7 @@ 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 />
@@ -882,7 +950,7 @@ await loadSkins()
</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" />
@@ -893,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 || isSavedSkinReorderMode"
:disabled="!selectedSkin || isSkinManagementReadOnly"
@click="(e: MouseEvent) => selectedSkin && editSkinModal?.show(e, selectedSkin)"
>
<EditIcon />
@@ -913,12 +981,10 @@ await loadSkins()
:is-skin-selected="isSkinSelected"
:is-skin-active="isSkinActive"
:is-add-skin-button-drag-active="isAddSkinButtonDragActive"
:is-saved-skin-reorder-mode="isSavedSkinReorderMode"
:read-only="isSkinManagementReadOnly"
@select="changeSkin"
@edit="(skin, event) => editSkinModal?.show(event, skin)"
@delete="confirmDeleteSkin"
@reorder-saved-skins="onSavedSkinsReordered"
@toggle-saved-skin-reorder="toggleSavedSkinReorderMode"
@add-skin="openAddSkinFileBrowser"
@add-skin-dragenter="onAddSkinDragOver"
@add-skin-dragover="onAddSkinDragOver"
+4 -1
View File
@@ -1,5 +1,7 @@
import { defineStore } from 'pinia'
import { findMinecraftAuthError } from '@/components/ui/minecraft-auth-error-modal/minecraft-auth-errors'
export const useError = defineStore('errorsStore', {
state: () => ({
errorModal: null,
@@ -15,7 +17,8 @@ export const useError = defineStore('errorsStore', {
showError(error, context, closable = true, source = null) {
if (
error.message &&
error.message.includes('Minecraft authentication error:') &&
(error.message.includes('Minecraft authentication error:') ||
findMinecraftAuthError(error.message)) &&
this.minecraftAuthErrorModal
) {
this.minecraftAuthErrorModal.show(error)
+9 -5
View File
@@ -71,7 +71,7 @@ export const installVersionDependencies = async (profile, version, reason, onDep
return installed
}
const queueInstall = async (projectId, resolvedVersion) => {
const queueInstall = async (projectId, resolvedVersion, dependentOn) => {
if (!resolvedVersion?.id) return false
const versionId = resolvedVersion.id
@@ -91,7 +91,11 @@ export const installVersionDependencies = async (profile, version, reason, onDep
if (resolvedProjectId) {
queuedProjectVersions.set(resolvedProjectId, versionId)
}
queuedInstalls.push({ versionId, projectId: resolvedProjectId })
queuedInstalls.push({
versionId,
projectId: resolvedProjectId,
dependentOnVersionId: dependentOn?.id,
})
return true
}
@@ -159,7 +163,7 @@ export const installVersionDependencies = async (profile, version, reason, onDep
if (!resolved) continue
const { depVersion, depProjectId } = resolved
const queued = await queueInstall(depProjectId, depVersion)
const queued = await queueInstall(depProjectId, depVersion, inputVersion)
if (queued && depProjectId) {
await announceDependency(depProjectId, depVersion)
}
@@ -176,8 +180,8 @@ export const installVersionDependencies = async (profile, version, reason, onDep
for (let i = 0; i < queuedInstalls.length; i += batchSize) {
const batch = queuedInstalls.slice(i, i + batchSize)
await Promise.all(
batch.map(async ({ versionId }) => {
await add_project_from_version(profile.path, versionId, reason)
batch.map(async ({ versionId, dependentOnVersionId }) => {
await add_project_from_version(profile.path, versionId, reason, dependentOnVersionId)
}),
)
}