fix(skins): better offline handling (#6344)

This commit is contained in:
Calum H.
2026-06-10 13:28:10 +00:00
committed by GitHub
parent b828fa17de
commit 180cef6eaa
5 changed files with 237 additions and 65 deletions
@@ -82,6 +82,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<{
@@ -362,7 +363,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)"
@@ -384,9 +386,10 @@ defineExpose({ getAddSkinButtonElement })
:backward-image-src="getBakedSkinTextures(skin)?.backwards" :backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="isSkinSelected(skin)" :selected="isSkinSelected(skin)"
:active="isSkinActive(skin)" :active="isSkinActive(skin)"
:disabled="readOnly"
@select="emit('select', skin)" @select="emit('select', skin)"
> >
<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)"
@@ -423,6 +426,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'
@@ -181,6 +181,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)
@@ -200,6 +201,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(),
@@ -249,8 +260,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 ''
} }
@@ -258,6 +279,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),
) )
@@ -274,11 +298,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
@@ -304,7 +332,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
@@ -315,6 +359,28 @@ async function loadSkins() {
} }
} }
function mergeEquippedSkin(list: Skin[], equippedSkin: Skin) {
let foundEquippedSkin = false
const mergedSkins = list.map((skin) => {
const isEquipped = skinsMatch(skin, equippedSkin)
foundEquippedSkin ||= isEquipped
return {
...skin,
is_equipped: isEquipped,
}
})
if (!foundEquippedSkin) {
mergedSkins.unshift({
...equippedSkin,
is_equipped: true,
})
}
return mergedSkins
}
function skinsMatch(a?: Skin | null, b?: Skin | null) { function skinsMatch(a?: Skin | null, b?: Skin | null) {
return ( return (
a?.source === b?.source && a?.source === b?.source &&
@@ -385,6 +451,8 @@ function getDefaultSkinSectionSortIndex(section: string) {
} }
function changeSkin(newSkin: Skin) { function changeSkin(newSkin: Skin) {
if (isSkinManagementReadOnly.value) return
selectedSkin.value = newSkin selectedSkin.value = newSkin
} }
@@ -517,7 +585,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 {
@@ -586,10 +660,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]
@@ -632,6 +710,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') {
@@ -680,6 +760,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
} }
@@ -688,10 +770,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(
@@ -721,6 +807,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 {
@@ -740,13 +828,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)
@@ -763,6 +862,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()
@@ -834,7 +942,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 />
@@ -842,7 +950,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" />
@@ -853,7 +961,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 />
@@ -873,6 +981,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"
+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>