mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 11:36:05 +00:00
Merge branch 'main' into truman/reorder-skins
This commit is contained in:
+2
-2
@@ -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()
|
||||
|
||||
+111
-1
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ const props = defineProps<{
|
||||
isSkinSelected: (skin: Skin) => boolean
|
||||
isSkinActive: (skin: Skin) => boolean
|
||||
isAddSkinButtonDragActive: boolean
|
||||
readOnly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -433,7 +434,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)"
|
||||
@@ -460,8 +462,9 @@ defineExpose({ getAddSkinButtonElement })
|
||||
:selected="isSkinSelected(skin)"
|
||||
:active="isSkinActive(skin)"
|
||||
@select="emit('select', skin)"
|
||||
:disabled="readOnly"
|
||||
>
|
||||
<template #overlay-buttons>
|
||||
<template v-if="!readOnly" #overlay-buttons>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:aria-label="formatMessage(messages.editSkinButton)"
|
||||
@@ -510,6 +513,7 @@ defineExpose({ getAddSkinButtonElement })
|
||||
:selected="isSkinSelected(skin)"
|
||||
:active="isSkinActive(skin)"
|
||||
:tooltip="skin.name"
|
||||
:disabled="readOnly"
|
||||
@select="emit('select', skin)"
|
||||
>
|
||||
<template #overlay-buttons>
|
||||
|
||||
@@ -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'
|
||||
@@ -190,6 +190,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)
|
||||
@@ -209,6 +210,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(),
|
||||
@@ -258,8 +269,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 ''
|
||||
}
|
||||
@@ -267,6 +288,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),
|
||||
)
|
||||
@@ -283,11 +307,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
|
||||
|
||||
@@ -313,7 +341,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
|
||||
@@ -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) {
|
||||
return (
|
||||
a?.source === b?.source &&
|
||||
@@ -394,6 +460,8 @@ function getDefaultSkinSectionSortIndex(section: string) {
|
||||
}
|
||||
|
||||
function changeSkin(newSkin: Skin) {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
|
||||
selectedSkin.value = newSkin
|
||||
}
|
||||
|
||||
@@ -566,7 +634,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 {
|
||||
@@ -635,10 +709,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]
|
||||
|
||||
@@ -681,6 +759,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') {
|
||||
@@ -729,6 +809,8 @@ async function handleAddSkinNativeDragDrop(event: { payload: DragDropEvent }) {
|
||||
}
|
||||
|
||||
function onAddSkinDragOver(event: DragEvent) {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
|
||||
if (!isSkinFileDrag(event)) {
|
||||
return
|
||||
}
|
||||
@@ -737,10 +819,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(
|
||||
@@ -770,6 +856,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 {
|
||||
@@ -789,13 +877,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)
|
||||
@@ -812,6 +911,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()
|
||||
@@ -883,7 +991,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 />
|
||||
@@ -891,7 +999,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" />
|
||||
@@ -902,7 +1010,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 />
|
||||
@@ -922,6 +1030,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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -268,12 +268,11 @@ pub async fn get_available_capes() -> crate::Result<Vec<Cape>> {
|
||||
.await?
|
||||
.ok_or(ErrorKind::NoCredentialsError)?;
|
||||
|
||||
let profile = selected_credentials
|
||||
.online_profile_fresh()
|
||||
.await
|
||||
.ok_or_else(|| ErrorKind::OnlineMinecraftProfileUnavailable {
|
||||
user_name: selected_credentials.offline_profile.name.clone(),
|
||||
})?;
|
||||
let Some(profile) = selected_credentials.online_profile_fresh().await
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let pending_skin_change = pending_effective_skin_change(profile.id).await;
|
||||
let pending_cape_id = pending_skin_change
|
||||
.as_ref()
|
||||
@@ -309,16 +308,22 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
|
||||
.await?
|
||||
.ok_or(ErrorKind::NoCredentialsError)?;
|
||||
|
||||
let profile = selected_credentials
|
||||
.online_profile_fresh()
|
||||
.await
|
||||
.ok_or_else(|| ErrorKind::OnlineMinecraftProfileUnavailable {
|
||||
user_name: selected_credentials.offline_profile.name.clone(),
|
||||
})?;
|
||||
let online_profile = selected_credentials.online_profile_fresh().await;
|
||||
let profile_id = online_profile
|
||||
.as_ref()
|
||||
.map_or(selected_credentials.offline_profile.id, |profile| {
|
||||
profile.id
|
||||
});
|
||||
|
||||
let current_skin = profile.current_skin()?;
|
||||
let current_cape_id = profile.current_cape().map(|cape| cape.id);
|
||||
let pending_skin_change = pending_effective_skin_change(profile.id).await;
|
||||
let current_skin = online_profile
|
||||
.as_ref()
|
||||
.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
|
||||
.as_ref()
|
||||
.is_some_and(PendingEffectiveSkinChange::is_unequip);
|
||||
@@ -326,16 +331,15 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
|
||||
.as_ref()
|
||||
.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(
|
||||
|| {
|
||||
if pending_unequip {
|
||||
fallback_default_skin.map_or_else(
|
||||
|| current_skin.texture_key(),
|
||||
|skin| Arc::clone(&skin.texture_key),
|
||||
)
|
||||
} else {
|
||||
Arc::clone(&fallback_default_skin.texture_key)
|
||||
} else if let Some(current_skin) = current_skin {
|
||||
current_skin.texture_key()
|
||||
} else {
|
||||
Arc::clone(&fallback_default_skin.texture_key)
|
||||
}
|
||||
},
|
||||
|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(
|
||||
|| {
|
||||
if pending_unequip {
|
||||
fallback_default_skin
|
||||
.map_or(current_skin.variant, |skin| skin.variant)
|
||||
} else {
|
||||
fallback_default_skin.variant
|
||||
} else if let Some(current_skin) = current_skin {
|
||||
current_skin.variant
|
||||
} else {
|
||||
fallback_default_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(
|
||||
if pending_unequip {
|
||||
None
|
||||
} else {
|
||||
} else if current_skin.is_some() {
|
||||
current_cape_id
|
||||
} else {
|
||||
None
|
||||
},
|
||||
|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 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?
|
||||
.collect::<Vec<_>>()
|
||||
.await
|
||||
{
|
||||
let is_saved_default_skin =
|
||||
is_bundled_skin(&custom_skin.texture_key, custom_skin.variant);
|
||||
let current_skin_sync = if pending_skin.is_some() {
|
||||
SavedSkinSync {
|
||||
is_current_skin: custom_skin.texture_key
|
||||
== current_skin_texture_key.as_ref()
|
||||
&& custom_skin.variant == current_skin_variant
|
||||
&& custom_skin.cape_id == current_cape_id,
|
||||
settings_changed: false,
|
||||
}
|
||||
} else {
|
||||
sync_saved_skin_with_current_profile(
|
||||
&mut custom_skin,
|
||||
¤t_skin_texture_key,
|
||||
current_skin_variant,
|
||||
current_cape_id,
|
||||
)
|
||||
};
|
||||
let current_skin_sync =
|
||||
if pending_skin.is_some() || current_skin.is_none() {
|
||||
SavedSkinSync {
|
||||
is_current_skin: custom_skin.texture_key
|
||||
== current_skin_texture_key.as_ref()
|
||||
&& custom_skin.variant == current_skin_variant
|
||||
&& custom_skin.cape_id == current_cape_id,
|
||||
settings_changed: false,
|
||||
}
|
||||
} else {
|
||||
sync_saved_skin_with_current_profile(
|
||||
&mut custom_skin,
|
||||
¤t_skin_texture_key,
|
||||
current_skin_variant,
|
||||
current_cape_id,
|
||||
)
|
||||
};
|
||||
|
||||
let synced_texture_blob = if current_skin_sync.settings_changed {
|
||||
let texture_blob = custom_skin.texture_blob(&state.pool).await?;
|
||||
|
||||
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 {
|
||||
CustomMinecraftSkin::add(
|
||||
profile.id,
|
||||
profile_id,
|
||||
&custom_skin.texture_key,
|
||||
&texture_blob,
|
||||
custom_skin.variant,
|
||||
@@ -483,7 +491,7 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
|
||||
if let Some(mut skin) = pending_skin {
|
||||
skin.is_equipped = true;
|
||||
available_skins.push(skin);
|
||||
} else {
|
||||
} else if let Some(current_skin) = current_skin {
|
||||
available_skins.push(Skin {
|
||||
texture_key: current_skin_texture_key,
|
||||
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> {
|
||||
Arc::from(format!("local-{:x}", sha2::Sha256::digest(texture_blob)))
|
||||
}
|
||||
|
||||
@@ -13,12 +13,14 @@ const props = withDefaults(
|
||||
selected: boolean
|
||||
active?: boolean
|
||||
tooltip?: string
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
forwardImageSrc: undefined,
|
||||
backwardImageSrc: undefined,
|
||||
active: false,
|
||||
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="[
|
||||
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
|
||||
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-pressed="selected"
|
||||
:disabled="disabled"
|
||||
@click="emit('select')"
|
||||
></button>
|
||||
|
||||
@@ -98,7 +105,7 @@ watch(
|
||||
</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"
|
||||
>
|
||||
<slot name="overlay-buttons" />
|
||||
@@ -156,8 +163,8 @@ watch(
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.skin-button:hover,
|
||||
.skin-button:focus-within,
|
||||
.skin-button:not(.skin-button--disabled):hover,
|
||||
.skin-button:not(.skin-button--disabled):focus-within,
|
||||
.skin-button--with-actions:hover,
|
||||
.skin-button--with-actions:focus-within {
|
||||
border-color: var(--surface-5);
|
||||
@@ -167,13 +174,27 @@ watch(
|
||||
0 1px 4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.skin-button--selected,
|
||||
.skin-button--selected:hover,
|
||||
.skin-button--selected:focus-within {
|
||||
.skin-button.skin-button--selected,
|
||||
.skin-button.skin-button--selected:hover,
|
||||
.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);
|
||||
background: var(--color-brand-highlight);
|
||||
}
|
||||
|
||||
.skin-button--disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.skin-button--disabled button {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.skin-button__image-parent {
|
||||
width: 100%;
|
||||
height: 95%;
|
||||
@@ -185,7 +206,7 @@ watch(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -208,7 +229,7 @@ watch(
|
||||
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));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,12 +7,14 @@ const props = withDefaults(
|
||||
tooltip?: string
|
||||
dragActive?: boolean
|
||||
dropzone?: boolean
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
selected: false,
|
||||
tooltip: undefined,
|
||||
dragActive: false,
|
||||
dropzone: false,
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -28,6 +30,10 @@ function handleDragEvent(
|
||||
eventName: 'dragenter' | 'dragover' | 'dragleave' | 'drop',
|
||||
event: DragEvent,
|
||||
) {
|
||||
if (props.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (props.dropzone) {
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer) {
|
||||
@@ -53,7 +59,10 @@ defineExpose({ getRootElement })
|
||||
:class="[
|
||||
isHighlighted
|
||||
? '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)"
|
||||
@dragover="handleDragEvent('dragover', $event)"
|
||||
@@ -64,6 +73,8 @@ defineExpose({ getRootElement })
|
||||
type="button"
|
||||
:aria-label="tooltip ?? undefined"
|
||||
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)"
|
||||
></button>
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ function configureSkinPreviewMesh(mesh: THREE.Mesh) {
|
||||
materials.forEach((material) => {
|
||||
if (!(material instanceof THREE.MeshStandardMaterial) || material.name === 'cape') return
|
||||
|
||||
material.transparent = false
|
||||
material.transparent = isSkinLayer
|
||||
material.alphaTest = 0.1
|
||||
material.depthTest = true
|
||||
material.depthWrite = true
|
||||
|
||||
@@ -157,6 +157,7 @@ export function applyTexture(model: THREE.Object3D, texture: THREE.Texture): voi
|
||||
model.traverse((child) => {
|
||||
if ((child as THREE.Mesh).isMesh) {
|
||||
const mesh = child as THREE.Mesh
|
||||
const isSkinLayer = mesh.name.endsWith('_Layer')
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
|
||||
materials.forEach((mat: THREE.Material) => {
|
||||
@@ -168,7 +169,7 @@ export function applyTexture(model: THREE.Object3D, texture: THREE.Texture): voi
|
||||
flatShading: true,
|
||||
side: THREE.FrontSide,
|
||||
toneMapped: false,
|
||||
transparent: false,
|
||||
transparent: isSkinLayer,
|
||||
})
|
||||
|
||||
setCommonMaterialProperties(mat)
|
||||
|
||||
Reference in New Issue
Block a user