Merge branch 'main' into truman/reorder-skins

This commit is contained in:
tdgao
2026-06-10 09:30:53 -07:00
10 changed files with 357 additions and 71 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 { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { handleSevereError } from '@/store/error.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 modal = ref<InstanceType<typeof NewModal>>()
const rawError = ref<string>('') const rawError = ref<string>('')
@@ -26,7 +26,7 @@ const loadingSignIn = ref(false)
function show(errorVal: { message?: string }) { function show(errorVal: { message?: string }) {
rawError.value = errorVal?.message ?? String(errorVal) 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 debugCollapsed.value = true
hide_ads_window() hide_ads_window()
@@ -1,10 +1,93 @@
export interface MinecraftAuthError { export interface MinecraftAuthError {
errorCode: string errorCode?: string
errorMatchers?: string[]
matches?: (message: string) => boolean
whatHappened: string whatHappened: string
stepsToFix: string[] stepsToFix: string[]
} }
export const minecraftAuthErrors: MinecraftAuthError[] = [ 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', errorCode: '2148916222',
whatHappened: whatHappened:
@@ -87,4 +170,31 @@ export const minecraftAuthErrors: MinecraftAuthError[] = [
'Once finished, try signing in again', '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
)
}
@@ -94,6 +94,7 @@ const props = defineProps<{
isSkinSelected: (skin: Skin) => boolean isSkinSelected: (skin: Skin) => boolean
isSkinActive: (skin: Skin) => boolean isSkinActive: (skin: Skin) => boolean
isAddSkinButtonDragActive: boolean isAddSkinButtonDragActive: boolean
readOnly?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -433,7 +434,8 @@ defineExpose({ getAddSkinButtonElement })
ref="addSkinButton" ref="addSkinButton"
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]" class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
dropzone dropzone
:drag-active="isAddSkinButtonDragActive" :disabled="readOnly"
:drag-active="!readOnly && isAddSkinButtonDragActive"
@click="emit('add-skin')" @click="emit('add-skin')"
@dragenter="emit('add-skin-dragenter', $event)" @dragenter="emit('add-skin-dragenter', $event)"
@dragover="emit('add-skin-dragover', $event)" @dragover="emit('add-skin-dragover', $event)"
@@ -460,8 +462,9 @@ defineExpose({ getAddSkinButtonElement })
:selected="isSkinSelected(skin)" :selected="isSkinSelected(skin)"
:active="isSkinActive(skin)" :active="isSkinActive(skin)"
@select="emit('select', skin)" @select="emit('select', skin)"
:disabled="readOnly"
> >
<template #overlay-buttons> <template v-if="!readOnly" #overlay-buttons>
<ButtonStyled color="brand"> <ButtonStyled color="brand">
<button <button
:aria-label="formatMessage(messages.editSkinButton)" :aria-label="formatMessage(messages.editSkinButton)"
@@ -510,6 +513,7 @@ defineExpose({ getAddSkinButtonElement })
:selected="isSkinSelected(skin)" :selected="isSkinSelected(skin)"
:active="isSkinActive(skin)" :active="isSkinActive(skin)"
:tooltip="skin.name" :tooltip="skin.name"
:disabled="readOnly"
@select="emit('select', skin)" @select="emit('select', skin)"
> >
<template #overlay-buttons> <template #overlay-buttons>
+117 -8
View File
@@ -30,7 +30,7 @@ import type AccountsCard from '@/components/ui/AccountsCard.vue'
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue' import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue' import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue'
import { trackEvent } from '@/helpers/analytics' 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 type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import { generateSkinPreviews, skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts' import { generateSkinPreviews, skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts' import type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts'
@@ -190,6 +190,7 @@ const client = injectModrinthClient()
const themeStore = useTheming() const themeStore = useTheming()
const skins = ref<Skin[]>([]) const skins = ref<Skin[]>([])
const capes = ref<Cape[]>([]) const capes = ref<Cape[]>([])
const offline = ref(!navigator.onLine)
const accountsCard = inject('accountsCard') as Ref<typeof AccountsCard> const accountsCard = inject('accountsCard') as Ref<typeof AccountsCard>
const currentUser = ref(undefined) const currentUser = ref(undefined)
@@ -209,6 +210,16 @@ const savedSkins = computed(() => {
return [] 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({ const { data: modrinthUser } = useQuery({
queryKey: computed(() => ['authenticated-user', 'campaigns', auth.user.value?.id]), queryKey: computed(() => ['authenticated-user', 'campaigns', auth.user.value?.id]),
queryFn: () => client.labrinth.users_v3.getAuthenticated(), queryFn: () => client.labrinth.users_v3.getAuthenticated(),
@@ -258,8 +269,18 @@ const currentCape = computed(() => {
}) })
const skinTexture = computedAsync(async () => { const skinTexture = computedAsync(async () => {
if (selectedSkin.value?.texture) { const skin = selectedSkin.value
return await get_normalized_skin_texture(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 { } else {
return '' return ''
} }
@@ -267,6 +288,9 @@ const skinTexture = computedAsync(async () => {
const capeTexture = computed(() => currentCape.value?.texture) const capeTexture = computed(() => currentCape.value?.texture)
const skinVariant = computed(() => selectedSkin.value?.variant) const skinVariant = computed(() => selectedSkin.value?.variant)
const skinNametag = computed(() => (themeStore.hideNametagSkinsPage ? undefined : username.value)) const skinNametag = computed(() => (themeStore.hideNametagSkinsPage ? undefined : username.value))
const isSkinManagementReadOnly = computed(
() => offline.value || (authServerQuery.isError.value && !authServerQuery.isLoading.value),
)
const hasPendingSkinChange = computed( const hasPendingSkinChange = computed(
() => !skinsMatch(selectedSkin.value, originalSelectedSkin.value), () => !skinsMatch(selectedSkin.value, originalSelectedSkin.value),
) )
@@ -283,11 +307,15 @@ const deleteSkinModal = ref()
const skinToDelete = ref<Skin | null>(null) const skinToDelete = ref<Skin | null>(null)
function confirmDeleteSkin(skin: Skin) { function confirmDeleteSkin(skin: Skin) {
if (isSkinManagementReadOnly.value) return
skinToDelete.value = skin skinToDelete.value = skin
deleteSkinModal.value?.show() deleteSkinModal.value?.show()
} }
async function deleteSkin() { async function deleteSkin() {
if (isSkinManagementReadOnly.value) return
const deletedSkin = skinToDelete.value const deletedSkin = skinToDelete.value
if (!deletedSkin) return if (!deletedSkin) return
@@ -313,7 +341,23 @@ async function loadCapes() {
async function loadSkins() { async function loadSkins() {
try { 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) generateSkinPreviews(skins.value, capes.value)
selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null
originalSelectedSkin.value = selectedSkin.value originalSelectedSkin.value = selectedSkin.value
@@ -324,6 +368,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) { function skinsMatch(a?: Skin | null, b?: Skin | null) {
return ( return (
a?.source === b?.source && a?.source === b?.source &&
@@ -394,6 +460,8 @@ function getDefaultSkinSectionSortIndex(section: string) {
} }
function changeSkin(newSkin: Skin) { function changeSkin(newSkin: Skin) {
if (isSkinManagementReadOnly.value) return
selectedSkin.value = newSkin selectedSkin.value = newSkin
} }
@@ -566,7 +634,13 @@ function schedulePendingSkinRefresh() {
async function applySelectedSkin() { async function applySelectedSkin() {
const skinToApply = selectedSkin.value const skinToApply = selectedSkin.value
if (!skinToApply || !hasPendingSkinChange.value || isApplyingSkin.value) return if (
!skinToApply ||
!hasPendingSkinChange.value ||
isApplyingSkin.value ||
isSkinManagementReadOnly.value
)
return
isApplyingSkin.value = true isApplyingSkin.value = true
try { try {
@@ -635,10 +709,14 @@ async function login() {
} }
function openAddSkinFileBrowser() { function openAddSkinFileBrowser() {
if (isSkinManagementReadOnly.value) return
addSkinFileInput.value?.click() addSkinFileInput.value?.click()
} }
async function onAddSkinFileInputChange(e: Event) { async function onAddSkinFileInputChange(e: Event) {
if (isSkinManagementReadOnly.value) return
const files = (e.target as HTMLInputElement).files const files = (e.target as HTMLInputElement).files
const file = files?.[0] const file = files?.[0]
@@ -681,6 +759,8 @@ function isPositionOverAddSkinButton(position: { x: number; y: number }) {
} }
async function handleAddSkinNativeDragDrop(event: { payload: DragDropEvent }) { async function handleAddSkinNativeDragDrop(event: { payload: DragDropEvent }) {
if (isSkinManagementReadOnly.value) return
const payload = event.payload const payload = event.payload
if (payload.type === 'leave') { if (payload.type === 'leave') {
@@ -729,6 +809,8 @@ async function handleAddSkinNativeDragDrop(event: { payload: DragDropEvent }) {
} }
function onAddSkinDragOver(event: DragEvent) { function onAddSkinDragOver(event: DragEvent) {
if (isSkinManagementReadOnly.value) return
if (!isSkinFileDrag(event)) { if (!isSkinFileDrag(event)) {
return return
} }
@@ -737,10 +819,14 @@ function onAddSkinDragOver(event: DragEvent) {
} }
function onAddSkinDragLeave() { function onAddSkinDragLeave() {
if (isSkinManagementReadOnly.value) return
isAddSkinButtonDragActive.value = false isAddSkinButtonDragActive.value = false
} }
async function onAddSkinDrop(event: DragEvent) { async function onAddSkinDrop(event: DragEvent) {
if (isSkinManagementReadOnly.value) return
isAddSkinButtonDragActive.value = false isAddSkinButtonDragActive.value = false
const file = Array.from(event.dataTransfer?.files ?? []).find( const file = Array.from(event.dataTransfer?.files ?? []).find(
@@ -770,6 +856,8 @@ async function setupAddSkinDragDropListener() {
} }
async function processSkinFileBuffer(buffer: Uint8Array | ArrayBuffer) { async function processSkinFileBuffer(buffer: Uint8Array | ArrayBuffer) {
if (isSkinManagementReadOnly.value) return
const fakeEvent = new MouseEvent('click') const fakeEvent = new MouseEvent('click')
const originalSkinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(buffer) const originalSkinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(buffer)
try { try {
@@ -789,13 +877,24 @@ watch(
() => {}, () => {},
) )
watch(isSkinManagementReadOnly, (readOnly) => {
if (readOnly) {
isDraggingSkinFile.value = false
isAddSkinButtonDragActive.value = false
}
})
onMounted(() => { onMounted(() => {
window.addEventListener('offline', onOffline)
window.addEventListener('online', onOnline)
userCheckInterval = window.setInterval(checkUserChanges, 250) userCheckInterval = window.setInterval(checkUserChanges, 250)
void setupAddSkinDragDropListener() void setupAddSkinDragDropListener()
}) })
onUnmounted(() => { onUnmounted(() => {
isUnmounted = true isUnmounted = true
window.removeEventListener('offline', onOffline)
window.removeEventListener('online', onOnline)
if (userCheckInterval !== null) { if (userCheckInterval !== null) {
window.clearInterval(userCheckInterval) window.clearInterval(userCheckInterval)
@@ -812,6 +911,15 @@ onUnmounted(() => {
} }
}) })
function onOffline() {
offline.value = true
}
function onOnline() {
offline.value = false
void authServerQuery.refetch()
}
async function checkUserChanges() { async function checkUserChanges() {
try { try {
const defaultId = await get_default_user() const defaultId = await get_default_user()
@@ -883,7 +991,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-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" 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" @click="resetSelectedSkin"
> >
<RotateCounterClockwiseIcon /> <RotateCounterClockwiseIcon />
@@ -891,7 +999,7 @@ await loadSkins()
</button> </button>
<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" 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" @click="applySelectedSkin"
> >
<SpinnerIcon v-if="isApplyingSkin" class="animate-spin" /> <SpinnerIcon v-if="isApplyingSkin" class="animate-spin" />
@@ -902,7 +1010,7 @@ await loadSkins()
<button <button
v-else 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" 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)" @click="(e: MouseEvent) => selectedSkin && editSkinModal?.show(e, selectedSkin)"
> >
<EditIcon /> <EditIcon />
@@ -922,6 +1030,7 @@ await loadSkins()
:is-skin-selected="isSkinSelected" :is-skin-selected="isSkinSelected"
:is-skin-active="isSkinActive" :is-skin-active="isSkinActive"
:is-add-skin-button-drag-active="isAddSkinButtonDragActive" :is-add-skin-button-drag-active="isAddSkinButtonDragActive"
:read-only="isSkinManagementReadOnly"
@select="changeSkin" @select="changeSkin"
@edit="(skin, event) => editSkinModal?.show(event, skin)" @edit="(skin, event) => editSkinModal?.show(event, skin)"
@delete="confirmDeleteSkin" @delete="confirmDeleteSkin"
+4 -1
View File
@@ -1,5 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { findMinecraftAuthError } from '@/components/ui/minecraft-auth-error-modal/minecraft-auth-errors'
export const useError = defineStore('errorsStore', { export const useError = defineStore('errorsStore', {
state: () => ({ state: () => ({
errorModal: null, errorModal: null,
@@ -15,7 +17,8 @@ export const useError = defineStore('errorsStore', {
showError(error, context, closable = true, source = null) { showError(error, context, closable = true, source = null) {
if ( if (
error.message && error.message &&
error.message.includes('Minecraft authentication error:') && (error.message.includes('Minecraft authentication error:') ||
findMinecraftAuthError(error.message)) &&
this.minecraftAuthErrorModal this.minecraftAuthErrorModal
) { ) {
this.minecraftAuthErrorModal.show(error) this.minecraftAuthErrorModal.show(error)
+72 -45
View File
@@ -268,12 +268,11 @@ pub async fn get_available_capes() -> crate::Result<Vec<Cape>> {
.await? .await?
.ok_or(ErrorKind::NoCredentialsError)?; .ok_or(ErrorKind::NoCredentialsError)?;
let profile = selected_credentials let Some(profile) = selected_credentials.online_profile_fresh().await
.online_profile_fresh() else {
.await return Ok(Vec::new());
.ok_or_else(|| ErrorKind::OnlineMinecraftProfileUnavailable { };
user_name: selected_credentials.offline_profile.name.clone(),
})?;
let pending_skin_change = pending_effective_skin_change(profile.id).await; let pending_skin_change = pending_effective_skin_change(profile.id).await;
let pending_cape_id = pending_skin_change let pending_cape_id = pending_skin_change
.as_ref() .as_ref()
@@ -309,16 +308,22 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
.await? .await?
.ok_or(ErrorKind::NoCredentialsError)?; .ok_or(ErrorKind::NoCredentialsError)?;
let profile = selected_credentials let online_profile = selected_credentials.online_profile_fresh().await;
.online_profile_fresh() let profile_id = online_profile
.await .as_ref()
.ok_or_else(|| ErrorKind::OnlineMinecraftProfileUnavailable { .map_or(selected_credentials.offline_profile.id, |profile| {
user_name: selected_credentials.offline_profile.name.clone(), profile.id
})?; });
let current_skin = profile.current_skin()?; let current_skin = online_profile
let current_cape_id = profile.current_cape().map(|cape| cape.id); .as_ref()
let pending_skin_change = pending_effective_skin_change(profile.id).await; .map(|profile| profile.current_skin())
.transpose()?;
let current_cape_id = online_profile
.as_ref()
.and_then(|profile| profile.current_cape())
.map(|cape| cape.id);
let pending_skin_change = pending_effective_skin_change(profile_id).await;
let pending_unequip = pending_skin_change let pending_unequip = pending_skin_change
.as_ref() .as_ref()
.is_some_and(PendingEffectiveSkinChange::is_unequip); .is_some_and(PendingEffectiveSkinChange::is_unequip);
@@ -326,16 +331,15 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
.as_ref() .as_ref()
.and_then(PendingEffectiveSkinChange::skin); .and_then(PendingEffectiveSkinChange::skin);
let fallback_default_skin = assets::DEFAULT_SKINS.first(); let fallback_default_skin = get_fallback_default_skin()?;
let current_skin_texture_key = pending_skin.as_ref().map_or_else( let current_skin_texture_key = pending_skin.as_ref().map_or_else(
|| { || {
if pending_unequip { if pending_unequip {
fallback_default_skin.map_or_else( Arc::clone(&fallback_default_skin.texture_key)
|| current_skin.texture_key(), } else if let Some(current_skin) = current_skin {
|skin| Arc::clone(&skin.texture_key),
)
} else {
current_skin.texture_key() current_skin.texture_key()
} else {
Arc::clone(&fallback_default_skin.texture_key)
} }
}, },
|skin| skin.texture_key.clone(), |skin| skin.texture_key.clone(),
@@ -343,10 +347,11 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
let current_skin_variant = pending_skin.as_ref().map_or_else( let current_skin_variant = pending_skin.as_ref().map_or_else(
|| { || {
if pending_unequip { if pending_unequip {
fallback_default_skin fallback_default_skin.variant
.map_or(current_skin.variant, |skin| skin.variant) } else if let Some(current_skin) = current_skin {
} else {
current_skin.variant current_skin.variant
} else {
fallback_default_skin.variant
} }
}, },
|skin| skin.variant, |skin| skin.variant,
@@ -354,8 +359,10 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
let current_cape_id = pending_skin.as_ref().map_or( let current_cape_id = pending_skin.as_ref().map_or(
if pending_unequip { if pending_unequip {
None None
} else { } else if current_skin.is_some() {
current_cape_id current_cape_id
} else {
None
}, },
|skin| skin.cape_id, |skin| skin.cape_id,
); );
@@ -364,38 +371,39 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
let mut custom_skins = Vec::new(); let mut custom_skins = Vec::new();
let mut saved_default_skins = Vec::new(); let mut saved_default_skins = Vec::new();
for mut custom_skin in CustomMinecraftSkin::get_all(profile.id, &state.pool) for mut custom_skin in CustomMinecraftSkin::get_all(profile_id, &state.pool)
.await? .await?
.collect::<Vec<_>>() .collect::<Vec<_>>()
.await .await
{ {
let is_saved_default_skin = let is_saved_default_skin =
is_bundled_skin(&custom_skin.texture_key, custom_skin.variant); is_bundled_skin(&custom_skin.texture_key, custom_skin.variant);
let current_skin_sync = if pending_skin.is_some() { let current_skin_sync =
SavedSkinSync { if pending_skin.is_some() || current_skin.is_none() {
is_current_skin: custom_skin.texture_key SavedSkinSync {
== current_skin_texture_key.as_ref() is_current_skin: custom_skin.texture_key
&& custom_skin.variant == current_skin_variant == current_skin_texture_key.as_ref()
&& custom_skin.cape_id == current_cape_id, && custom_skin.variant == current_skin_variant
settings_changed: false, && custom_skin.cape_id == current_cape_id,
} settings_changed: false,
} else { }
sync_saved_skin_with_current_profile( } else {
&mut custom_skin, sync_saved_skin_with_current_profile(
&current_skin_texture_key, &mut custom_skin,
current_skin_variant, &current_skin_texture_key,
current_cape_id, current_skin_variant,
) current_cape_id,
}; )
};
let synced_texture_blob = if current_skin_sync.settings_changed { let synced_texture_blob = if current_skin_sync.settings_changed {
let texture_blob = custom_skin.texture_blob(&state.pool).await?; let texture_blob = custom_skin.texture_blob(&state.pool).await?;
if is_saved_default_skin && custom_skin.cape_id.is_none() { if is_saved_default_skin && custom_skin.cape_id.is_none() {
custom_skin.remove(profile.id, &state.pool).await?; custom_skin.remove(profile_id, &state.pool).await?;
} else { } else {
CustomMinecraftSkin::add( CustomMinecraftSkin::add(
profile.id, profile_id,
&custom_skin.texture_key, &custom_skin.texture_key,
&texture_blob, &texture_blob,
custom_skin.variant, custom_skin.variant,
@@ -483,7 +491,7 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
if let Some(mut skin) = pending_skin { if let Some(mut skin) = pending_skin {
skin.is_equipped = true; skin.is_equipped = true;
available_skins.push(skin); available_skins.push(skin);
} else { } else if let Some(current_skin) = current_skin {
available_skins.push(Skin { available_skins.push(Skin {
texture_key: current_skin_texture_key, texture_key: current_skin_texture_key,
name: current_skin.name.as_deref().map(Arc::from), name: current_skin.name.as_deref().map(Arc::from),
@@ -1247,6 +1255,25 @@ fn is_bundled_skin(texture_key: &str, variant: MinecraftSkinVariant) -> bool {
}) })
} }
fn get_fallback_default_skin() -> crate::Result<&'static Skin> {
assets::DEFAULT_SKINS
.iter()
.find(|skin| {
skin.name.as_deref() == Some("Steve")
&& skin.variant == MinecraftSkinVariant::Classic
})
.or_else(|| {
assets::DEFAULT_SKINS
.iter()
.find(|skin| skin.name.as_deref() == Some("Steve"))
})
.or_else(|| assets::DEFAULT_SKINS.first())
.ok_or_else(|| {
ErrorKind::OtherError("No bundled default skins found".into())
.as_error()
})
}
fn local_skin_texture_key(texture_blob: &[u8]) -> Arc<str> { fn local_skin_texture_key(texture_blob: &[u8]) -> Arc<str> {
Arc::from(format!("local-{:x}", sha2::Sha256::digest(texture_blob))) Arc::from(format!("local-{:x}", sha2::Sha256::digest(texture_blob)))
} }
+30 -9
View File
@@ -13,12 +13,14 @@ const props = withDefaults(
selected: boolean selected: boolean
active?: boolean active?: boolean
tooltip?: string tooltip?: string
disabled?: boolean
}>(), }>(),
{ {
forwardImageSrc: undefined, forwardImageSrc: undefined,
backwardImageSrc: undefined, backwardImageSrc: undefined,
active: false, active: false,
tooltip: undefined, tooltip: undefined,
disabled: false,
}, },
) )
@@ -52,13 +54,18 @@ watch(
class="skin-button group relative flex items-end justify-center overflow-hidden border border-solid transition-[border-color,box-shadow] duration-200" class="skin-button group relative flex items-end justify-center overflow-hidden border border-solid transition-[border-color,box-shadow] duration-200"
:class="[ :class="[
selected ? 'skin-button--selected' : '', selected ? 'skin-button--selected' : '',
{ 'skin-button--with-actions': $slots['overlay-buttons'] }, active ? 'skin-button--active' : '',
{
'skin-button--with-actions': $slots['overlay-buttons'] && !disabled,
'skin-button--disabled': disabled,
},
]" ]"
> >
<button <button
class="absolute inset-0 z-10 cursor-pointer border-none bg-transparent p-0 focus-visible:outline-none" class="absolute inset-0 z-10 cursor-pointer border-none bg-transparent p-0 focus-visible:outline-none"
:aria-label="tooltip ? `Select ${tooltip}` : 'Select skin'" :aria-label="tooltip ? `Select ${tooltip}` : 'Select skin'"
:aria-pressed="selected" :aria-pressed="selected"
:disabled="disabled"
@click="emit('select')" @click="emit('select')"
></button> ></button>
@@ -98,7 +105,7 @@ watch(
</span> </span>
<span <span
v-if="$slots['overlay-buttons']" v-if="$slots['overlay-buttons'] && !disabled"
class="pointer-events-none absolute inset-x-0 bottom-3 z-30 flex translate-y-2 items-center justify-start gap-1.5 px-3 opacity-0 transition-all duration-200 group-focus-within:translate-y-0 group-focus-within:opacity-100 group-hover:translate-y-0 group-hover:opacity-100" class="pointer-events-none absolute inset-x-0 bottom-3 z-30 flex translate-y-2 items-center justify-start gap-1.5 px-3 opacity-0 transition-all duration-200 group-focus-within:translate-y-0 group-focus-within:opacity-100 group-hover:translate-y-0 group-hover:opacity-100"
> >
<slot name="overlay-buttons" /> <slot name="overlay-buttons" />
@@ -156,8 +163,8 @@ watch(
outline-offset: 2px; outline-offset: 2px;
} }
.skin-button:hover, .skin-button:not(.skin-button--disabled):hover,
.skin-button:focus-within, .skin-button:not(.skin-button--disabled):focus-within,
.skin-button--with-actions:hover, .skin-button--with-actions:hover,
.skin-button--with-actions:focus-within { .skin-button--with-actions:focus-within {
border-color: var(--surface-5); border-color: var(--surface-5);
@@ -167,13 +174,27 @@ watch(
0 1px 4px rgba(0, 0, 0, 0.15); 0 1px 4px rgba(0, 0, 0, 0.15);
} }
.skin-button--selected, .skin-button.skin-button--selected,
.skin-button--selected:hover, .skin-button.skin-button--selected:hover,
.skin-button--selected:focus-within { .skin-button.skin-button--selected:focus-within,
.skin-button.skin-button--selected.skin-button--with-actions:hover,
.skin-button.skin-button--selected.skin-button--with-actions:focus-within,
.skin-button.skin-button--active:hover,
.skin-button.skin-button--active:focus-within,
.skin-button.skin-button--active.skin-button--with-actions:hover,
.skin-button.skin-button--active.skin-button--with-actions:focus-within {
border-color: var(--color-brand); border-color: var(--color-brand);
background: var(--color-brand-highlight); background: var(--color-brand-highlight);
} }
.skin-button--disabled {
opacity: 0.65;
}
.skin-button--disabled button {
cursor: not-allowed;
}
.skin-button__image-parent { .skin-button__image-parent {
width: 100%; width: 100%;
height: 95%; height: 95%;
@@ -185,7 +206,7 @@ watch(
backface-visibility: hidden; backface-visibility: hidden;
} }
.skin-button:hover .skin-button__image-parent { .skin-button:not(.skin-button--disabled):hover .skin-button__image-parent {
transform: rotateY(180deg) translateZ(0); transform: rotateY(180deg) translateZ(0);
} }
@@ -208,7 +229,7 @@ watch(
transition: filter 200ms ease-in-out; transition: filter 200ms ease-in-out;
} }
.group:hover .skin-button__image-parent img { .group:not(.skin-button--disabled):hover .skin-button__image-parent img {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2)); filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
} }
</style> </style>
@@ -7,12 +7,14 @@ const props = withDefaults(
tooltip?: string tooltip?: string
dragActive?: boolean dragActive?: boolean
dropzone?: boolean dropzone?: boolean
disabled?: boolean
}>(), }>(),
{ {
selected: false, selected: false,
tooltip: undefined, tooltip: undefined,
dragActive: false, dragActive: false,
dropzone: false, dropzone: false,
disabled: false,
}, },
) )
@@ -28,6 +30,10 @@ function handleDragEvent(
eventName: 'dragenter' | 'dragover' | 'dragleave' | 'drop', eventName: 'dragenter' | 'dragover' | 'dragleave' | 'drop',
event: DragEvent, event: DragEvent,
) { ) {
if (props.disabled) {
return
}
if (props.dropzone) { if (props.dropzone) {
event.preventDefault() event.preventDefault()
if (event.dataTransfer) { if (event.dataTransfer) {
@@ -53,7 +59,10 @@ defineExpose({ getRootElement })
:class="[ :class="[
isHighlighted isHighlighted
? 'border-brand bg-brand-highlight' ? 'border-brand bg-brand-highlight'
: 'border-surface-5 bg-surface-2 hover:bg-surface-3', : disabled
? 'border-surface-5 bg-surface-2'
: 'border-surface-5 bg-surface-2 hover:bg-surface-3',
disabled ? 'opacity-[0.65]' : '',
]" ]"
@dragenter="handleDragEvent('dragenter', $event)" @dragenter="handleDragEvent('dragenter', $event)"
@dragover="handleDragEvent('dragover', $event)" @dragover="handleDragEvent('dragover', $event)"
@@ -64,6 +73,8 @@ defineExpose({ getRootElement })
type="button" type="button"
:aria-label="tooltip ?? undefined" :aria-label="tooltip ?? undefined"
class="absolute inset-0 z-0 cursor-pointer border-none bg-transparent p-0" class="absolute inset-0 z-0 cursor-pointer border-none bg-transparent p-0"
:class="{ 'cursor-not-allowed': disabled }"
:disabled="disabled"
@click="(e) => emit('click', e)" @click="(e) => emit('click', e)"
></button> ></button>
@@ -32,7 +32,7 @@ function configureSkinPreviewMesh(mesh: THREE.Mesh) {
materials.forEach((material) => { materials.forEach((material) => {
if (!(material instanceof THREE.MeshStandardMaterial) || material.name === 'cape') return if (!(material instanceof THREE.MeshStandardMaterial) || material.name === 'cape') return
material.transparent = false material.transparent = isSkinLayer
material.alphaTest = 0.1 material.alphaTest = 0.1
material.depthTest = true material.depthTest = true
material.depthWrite = true material.depthWrite = true
@@ -157,6 +157,7 @@ export function applyTexture(model: THREE.Object3D, texture: THREE.Texture): voi
model.traverse((child) => { model.traverse((child) => {
if ((child as THREE.Mesh).isMesh) { if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh const mesh = child as THREE.Mesh
const isSkinLayer = mesh.name.endsWith('_Layer')
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
materials.forEach((mat: THREE.Material) => { materials.forEach((mat: THREE.Material) => {
@@ -168,7 +169,7 @@ export function applyTexture(model: THREE.Object3D, texture: THREE.Texture): voi
flatShading: true, flatShading: true,
side: THREE.FrontSide, side: THREE.FrontSide,
toneMapped: false, toneMapped: false,
transparent: false, transparent: isSkinLayer,
}) })
setCommonMaterialProperties(mat) setCommonMaterialProperties(mat)