diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index 9df74bb68..18ff367db 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -44,7 +44,8 @@ "vue": "^3.5.13", "vue-i18n": "^10.0.0", "vue-router": "^4.6.0", - "vue-virtual-scroller": "v2.0.0-beta.8" + "vue-virtual-scroller": "v2.0.0-beta.8", + "vuedraggable": "^4.1.0" }, "devDependencies": { "@eslint/compat": "^1.1.1", diff --git a/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue b/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue index 33485ddb6..76f9ec6d0 100644 --- a/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue +++ b/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue @@ -148,7 +148,6 @@ import { arrayBufferToBase64 } from '@modrinth/utils' import { computed, nextTick, ref, useTemplateRef, watch } from 'vue' import { - add_and_equip_custom_skin, type Cape, determineModelType, equip_skin, @@ -440,9 +439,22 @@ async function save() { const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer()) if (mode.value === 'new') { - const addedSkin = await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value) + const addedSkin = await save_custom_skin( + { + texture_key: '', + variant: variant.value, + cape_id: selectedCape.value?.id, + texture: textureUrl, + source: 'custom', + is_equipped: false, + }, + bytes, + variant.value, + selectedCape.value, + true, + ) emit('saved', { - applied: true, + applied: false, skin: addedSkin, }) } else { diff --git a/apps/app-frontend/src/components/ui/skin/VirtualSkinSectionList.vue b/apps/app-frontend/src/components/ui/skin/VirtualSkinSectionList.vue index 1150291f7..999672604 100644 --- a/apps/app-frontend/src/components/ui/skin/VirtualSkinSectionList.vue +++ b/apps/app-frontend/src/components/ui/skin/VirtualSkinSectionList.vue @@ -13,6 +13,7 @@ import { import { useElementSize, useWindowSize } from '@vueuse/core' import { Tooltip } from 'floating-vue' import { computed, nextTick, onUnmounted, ref, useTemplateRef, watch } from 'vue' +import Draggable from 'vuedraggable' import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts' import type { Skin } from '@/helpers/skins.ts' @@ -89,6 +90,7 @@ const emit = defineEmits<{ select: [skin: Skin] edit: [skin: Skin, event: MouseEvent] delete: [skin: Skin] + 'reorder-saved-skins': [skins: Skin[]] 'add-skin': [] 'add-skin-dragenter': [event: DragEvent] 'add-skin-dragover': [event: DragEvent] @@ -154,6 +156,13 @@ const sections = computed(() => [ })), ]) +const draggableSavedSkins = ref([]) +const isDraggingSavedSkin = ref(false) +const canReorderSavedSkins = computed(() => draggableSavedSkins.value.length > 1) +const fixedSavedSkins = computed(() => + props.savedSkins.filter((skin) => !canPersistSkinOrder(skin)), +) + const sectionLayouts = computed(() => { const layouts: Array<{ section: SkinSection; top: number; height: number; index: number }> = [] let top = 0 @@ -210,6 +219,18 @@ watch( { immediate: true }, ) +watch( + () => props.savedSkins, + (nextSkins) => { + if (isDraggingSavedSkin.value) { + return + } + + draggableSavedSkins.value = nextSkins.filter(canPersistSkinOrder) + }, + { immediate: true }, +) + watch( listWidth, (width) => { @@ -258,6 +279,40 @@ function skinKey(skin: Skin, prefix: string) { return `${prefix}-${skin.source}-${skin.texture_key}-${skin.variant}-${skin.cape_id ?? 'no-cape'}` } +function savedSkinKey(skin: Skin) { + return skinKey(skin, 'saved-skin') +} + +function canPersistSkinOrder(skin: Skin) { + return skin.source === 'custom' +} + +function doSkinOrdersMatch(firstSkins: Skin[], secondSkins: Skin[]) { + const persistedSecondSkins = secondSkins.filter(canPersistSkinOrder) + + return ( + firstSkins.length === persistedSecondSkins.length && + firstSkins.every( + (skin, index) => savedSkinKey(skin) === savedSkinKey(persistedSecondSkins[index]), + ) + ) +} + +function onSavedSkinDragStart() { + isDraggingSavedSkin.value = true +} + +function onSavedSkinDragEnd() { + isDraggingSavedSkin.value = false + + if (doSkinOrdersMatch(draggableSavedSkins.value, props.savedSkins)) { + draggableSavedSkins.value = props.savedSkins.filter(canPersistSkinOrder) + return + } + + emit('reorder-saved-skins', [...draggableSavedSkins.value]) +} + function isSectionOpen(key: string) { return openSectionKeys.value.has(key) } @@ -355,63 +410,125 @@ defineExpose({ getAddSkinButtonElement }) -
- - - {{ formatMessage(messages.addSkinButton) }} - - + - - - -
+ + + +
+ + diff --git a/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts b/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts index f22a2b6e0..cb92cd45b 100644 --- a/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts +++ b/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts @@ -15,12 +15,10 @@ import { skinPreviewStorage } from '../storage/skin-preview-storage' export interface RenderResult { forwards: string - backwards: string } export interface RawRenderResult { forwards: Blob - backwards: Blob } class BatchSkinRenderer { @@ -92,12 +90,9 @@ class BatchSkinRenderer { } const frontCameraPos: [number, number, number] = [-1.3, 1, 6.3] - const backCameraPos: [number, number, number] = [-1.3, 1, -2.5] - const forwards = await this.renderView(frontCameraPos, lookAtTarget) - const backwards = await this.renderView(backCameraPos, lookAtTarget) - return { forwards, backwards } + return { forwards } } private async renderView( @@ -407,7 +402,6 @@ async function generateSkinPreviewsForGeneration( if (rawCached && !skinBlobUrlMap.has(skinKey)) { const cached: RenderResult = { forwards: URL.createObjectURL(rawCached.forwards), - backwards: URL.createObjectURL(rawCached.backwards), } skinBlobUrlMap.set(skinKey, cached) } @@ -427,7 +421,6 @@ async function generateSkinPreviewsForGeneration( if (DEBUG_MODE) { const result = skinBlobUrlMap.get(key)! URL.revokeObjectURL(result.forwards) - URL.revokeObjectURL(result.backwards) skinBlobUrlMap.delete(key) } else continue } @@ -456,7 +449,6 @@ async function generateSkinPreviewsForGeneration( const renderResult: RenderResult = { forwards: URL.createObjectURL(rawRenderResult.forwards), - backwards: URL.createObjectURL(rawRenderResult.backwards), } skinBlobUrlMap.set(key, renderResult) diff --git a/apps/app-frontend/src/helpers/skins.ts b/apps/app-frontend/src/helpers/skins.ts index 9a05df55d..e0d17fe0e 100644 --- a/apps/app-frontend/src/helpers/skins.ts +++ b/apps/app-frontend/src/helpers/skins.ts @@ -142,6 +142,12 @@ export async function remove_custom_skin(skin: Skin): Promise { }) } +export async function set_custom_skin_order(textureKeys: string[]): Promise { + await invoke('plugin:minecraft-skins|set_custom_skin_order', { + textureKeys, + }) +} + export async function save_custom_skin( skin: Skin, textureBlob: Uint8Array, diff --git a/apps/app-frontend/src/helpers/storage/skin-preview-storage.ts b/apps/app-frontend/src/helpers/storage/skin-preview-storage.ts index 29bc25527..003532bb8 100644 --- a/apps/app-frontend/src/helpers/storage/skin-preview-storage.ts +++ b/apps/app-frontend/src/helpers/storage/skin-preview-storage.ts @@ -2,7 +2,6 @@ import type { RawRenderResult } from '../rendering/batch-skin-renderer' interface StoredPreview { forwards: Blob - backwards: Blob timestamp: number } @@ -38,7 +37,6 @@ export class SkinPreviewStorage { const storedPreview: StoredPreview = { forwards: result.forwards, - backwards: result.backwards, timestamp: Date.now(), } @@ -67,7 +65,7 @@ export class SkinPreviewStorage { return } - resolve({ forwards: result.forwards, backwards: result.backwards }) + resolve({ forwards: result.forwards }) } request.onerror = () => reject(request.error) }) @@ -95,7 +93,7 @@ export class SkinPreviewStorage { const result = request.result as StoredPreview | undefined if (result) { - results[key] = { forwards: result.forwards, backwards: result.backwards } + results[key] = { forwards: result.forwards } } else { results[key] = null } @@ -173,7 +171,7 @@ export class SkinPreviewStorage { const key = cursor.primaryKey as string const value = cursor.value as StoredPreview - const entrySize = value.forwards.size + value.backwards.size + const entrySize = value.forwards.size totalSize += entrySize count++ diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 0c387a124..cf98dc3a7 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -437,6 +437,12 @@ "app.skins.rate-limit.title": { "message": "Slow down!" }, + "app.skins.reorder-error.text": { + "message": "Your skin order could not be saved." + }, + "app.skins.reorder-error.title": { + "message": "Failed to reorder skins" + }, "app.skins.section.builders-and-biomes": { "message": "Builders & Biomes" }, diff --git a/apps/app-frontend/src/pages/Skins.vue b/apps/app-frontend/src/pages/Skins.vue index 5fbae02a6..fcb9ad686 100644 --- a/apps/app-frontend/src/pages/Skins.vue +++ b/apps/app-frontend/src/pages/Skins.vue @@ -46,6 +46,7 @@ import { get_normalized_skin_texture, normalize_skin_texture, remove_custom_skin, + set_custom_skin_order, } from '@/helpers/skins.ts' import { hasPride26Badge } from '@/helpers/user-campaigns.ts' import { handleSevereError } from '@/store/error' @@ -129,6 +130,14 @@ const messages = defineMessages({ id: 'app.skins.dropped-file-error.text', defaultMessage: 'Failed to read the dropped file.', }, + reorderSkinErrorTitle: { + id: 'app.skins.reorder-error.title', + defaultMessage: 'Failed to reorder skins', + }, + reorderSkinErrorText: { + id: 'app.skins.reorder-error.text', + defaultMessage: 'Your skin order could not be saved.', + }, deleteSkinTitle: { id: 'app.skins.delete-modal.title', defaultMessage: 'Are you sure you want to delete this skin?', @@ -491,6 +500,19 @@ function setLocallyEquippedSkin(skinToApply: Skin) { void accountsCard.value?.setEquippedSkin(originalSelectedSkin.value) } +function insertLocalSkin(savedSkin: Skin) { + const firstNonCustomSkinIndex = skins.value.findIndex((skin) => skin.source !== 'custom') + + if (firstNonCustomSkinIndex === -1) { + skins.value = [...skins.value, savedSkin] + return + } + + const nextSkins = [...skins.value] + nextSkins.splice(firstNonCustomSkinIndex, 0, savedSkin) + skins.value = nextSkins +} + function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin) { let foundSkin = false const replacesSelectedSkin = @@ -519,7 +541,7 @@ function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin) }) if (!foundSkin) { - skins.value.unshift({ + insertLocalSkin({ ...savedSkin, is_equipped: applied || savedSkin.is_equipped, }) @@ -548,6 +570,35 @@ function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin) generateSkinPreviews(skins.value, capes.value) } +async function reorderSavedSkins(orderedSkins: Skin[]) { + const previousSkins = skins.value + const orderedTextureKeys = orderedSkins.map((skin) => skin.texture_key) + const orderedTextureKeySet = new Set(orderedTextureKeys) + const remainingSavedSkins = previousSkins.filter( + (skin) => skin.source !== 'default' && !orderedTextureKeySet.has(skin.texture_key), + ) + const defaultSkins = previousSkins.filter((skin) => skin.source === 'default') + const nextSavedSkins = [...orderedSkins, ...remainingSavedSkins] + + skins.value = [...nextSavedSkins, ...defaultSkins] + generateSkinPreviews(skins.value, capes.value) + + try { + await set_custom_skin_order( + nextSavedSkins.filter((skin) => skin.source === 'custom').map((skin) => skin.texture_key), + ) + } catch (error) { + skins.value = previousSkins + generateSkinPreviews(skins.value, capes.value) + addNotification({ + type: 'error', + title: formatMessage(messages.reorderSkinErrorTitle), + text: error instanceof Error ? error.message : formatMessage(messages.reorderSkinErrorText), + }) + await loadSkins() + } +} + function schedulePendingSkinRefresh() { if (pendingSkinRefreshTimeout !== null) { window.clearTimeout(pendingSkinRefreshTimeout) @@ -985,6 +1036,7 @@ await loadSkins() @select="changeSkin" @edit="(skin, event) => editSkinModal?.show(event, skin)" @delete="confirmDeleteSkin" + @reorder-saved-skins="reorderSavedSkins" @add-skin="openAddSkinFileBrowser" @add-skin-dragenter="onAddSkinDragOver" @add-skin-dragover="onAddSkinDragOver" diff --git a/apps/app/build.rs b/apps/app/build.rs index d98d01de4..1f78fcce4 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -117,6 +117,7 @@ fn main() { "equip_skin", "remove_custom_skin", "save_custom_skin", + "set_custom_skin_order", "unequip_skin", "flush_pending_skin_change", "flush_pending_skin_change_for_profile", diff --git a/apps/app/src/api/minecraft_skins.rs b/apps/app/src/api/minecraft_skins.rs index 55a67e12e..0f190f4f5 100644 --- a/apps/app/src/api/minecraft_skins.rs +++ b/apps/app/src/api/minecraft_skins.rs @@ -14,6 +14,7 @@ pub fn init() -> tauri::plugin::TauriPlugin { equip_skin, remove_custom_skin, save_custom_skin, + set_custom_skin_order, unequip_skin, flush_pending_skin_change, flush_pending_skin_change_for_profile, @@ -91,6 +92,14 @@ pub async fn save_custom_skin( .await?) } +/// `invoke('plugin:minecraft-skins|set_custom_skin_order', texture_keys)` +/// +/// See also: [minecraft_skins::set_custom_skin_order] +#[tauri::command] +pub async fn set_custom_skin_order(texture_keys: Vec) -> Result<()> { + Ok(minecraft_skins::set_custom_skin_order(texture_keys).await?) +} + /// `invoke('plugin:minecraft-skins|unequip_skin')` /// /// See also: [minecraft_skins::unequip_skin] diff --git a/packages/app-lib/.sqlx/query-11875ae76d8a61099dff08e9792fd6f231ecffd45315ed2b76253d02bbd531ff.json b/packages/app-lib/.sqlx/query-11875ae76d8a61099dff08e9792fd6f231ecffd45315ed2b76253d02bbd531ff.json new file mode 100644 index 000000000..31ebfa7ce --- /dev/null +++ b/packages/app-lib/.sqlx/query-11875ae76d8a61099dff08e9792fd6f231ecffd45315ed2b76253d02bbd531ff.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE custom_minecraft_skins SET display_order = display_order + 1 WHERE minecraft_user_uuid = ? AND display_order >= ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "11875ae76d8a61099dff08e9792fd6f231ecffd45315ed2b76253d02bbd531ff" +} diff --git a/packages/app-lib/.sqlx/query-18f04a0f6c262995b5f1eee10c2c5a396443ead9a9e295f6eea0986e40d65449.json b/packages/app-lib/.sqlx/query-18f04a0f6c262995b5f1eee10c2c5a396443ead9a9e295f6eea0986e40d65449.json new file mode 100644 index 000000000..940afa85a --- /dev/null +++ b/packages/app-lib/.sqlx/query-18f04a0f6c262995b5f1eee10c2c5a396443ead9a9e295f6eea0986e40d65449.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT display_order FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?", + "describe": { + "columns": [ + { + "name": "display_order", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + }, + "hash": "18f04a0f6c262995b5f1eee10c2c5a396443ead9a9e295f6eea0986e40d65449" +} diff --git a/packages/app-lib/.sqlx/query-3538a56e37f456ca57998bd4753da27bff30801646436358acf57eb4cdfa25ad.json b/packages/app-lib/.sqlx/query-3538a56e37f456ca57998bd4753da27bff30801646436358acf57eb4cdfa25ad.json new file mode 100644 index 000000000..7604f57fb --- /dev/null +++ b/packages/app-lib/.sqlx/query-3538a56e37f456ca57998bd4753da27bff30801646436358acf57eb4cdfa25ad.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE custom_minecraft_skins SET display_order = display_order + 1 WHERE minecraft_user_uuid = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "3538a56e37f456ca57998bd4753da27bff30801646436358acf57eb4cdfa25ad" +} diff --git a/packages/app-lib/.sqlx/query-4c8063f9ce2fd7deec9b69e0b2c1055fe47287d7f99be41215c25c1019d439b9.json b/packages/app-lib/.sqlx/query-4c8063f9ce2fd7deec9b69e0b2c1055fe47287d7f99be41215c25c1019d439b9.json deleted file mode 100644 index 4b7932bbe..000000000 --- a/packages/app-lib/.sqlx/query-4c8063f9ce2fd7deec9b69e0b2c1055fe47287d7f99be41215c25c1019d439b9.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 4 - }, - "nullable": [] - }, - "hash": "4c8063f9ce2fd7deec9b69e0b2c1055fe47287d7f99be41215c25c1019d439b9" -} diff --git a/packages/app-lib/.sqlx/query-aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac.json b/packages/app-lib/.sqlx/query-57df2984dd65b408473ed1ae86b0215f46946f9b9043d4b037e3a4d5b27e0373.json similarity index 59% rename from packages/app-lib/.sqlx/query-aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac.json rename to packages/app-lib/.sqlx/query-57df2984dd65b408473ed1ae86b0215f46946f9b9043d4b037e3a4d5b27e0373.json index 4d0c3892f..acdcf2330 100644 --- a/packages/app-lib/.sqlx/query-aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac.json +++ b/packages/app-lib/.sqlx/query-57df2984dd65b408473ed1ae86b0215f46946f9b9043d4b037e3a4d5b27e0373.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? ORDER BY rowid ASC LIMIT ? OFFSET ?", + "query": "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated', display_order FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? ORDER BY display_order ASC, rowid ASC LIMIT ? OFFSET ?", "describe": { "columns": [ { @@ -17,6 +17,11 @@ "name": "cape_id: Hyphenated", "ordinal": 2, "type_info": "Text" + }, + { + "name": "display_order", + "ordinal": 3, + "type_info": "Integer" } ], "parameters": { @@ -25,8 +30,9 @@ "nullable": [ false, false, - true + true, + false ] }, - "hash": "aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac" + "hash": "57df2984dd65b408473ed1ae86b0215f46946f9b9043d4b037e3a4d5b27e0373" } diff --git a/packages/app-lib/.sqlx/query-69c5a93676809ab6480fc41a2e6e34b16057db8b0eab08a9d8b8dce961c6f81c.json b/packages/app-lib/.sqlx/query-69c5a93676809ab6480fc41a2e6e34b16057db8b0eab08a9d8b8dce961c6f81c.json new file mode 100644 index 000000000..911feebc5 --- /dev/null +++ b/packages/app-lib/.sqlx/query-69c5a93676809ab6480fc41a2e6e34b16057db8b0eab08a9d8b8dce961c6f81c.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT texture_key FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? ORDER BY display_order ASC, rowid ASC", + "describe": { + "columns": [ + { + "name": "texture_key", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "69c5a93676809ab6480fc41a2e6e34b16057db8b0eab08a9d8b8dce961c6f81c" +} diff --git a/packages/app-lib/.sqlx/query-9a9b8b5c0b646b841b73461980b37ad3b03ecf99d1484c402694799dc08271f5.json b/packages/app-lib/.sqlx/query-9a9b8b5c0b646b841b73461980b37ad3b03ecf99d1484c402694799dc08271f5.json new file mode 100644 index 000000000..c46e3dbd6 --- /dev/null +++ b/packages/app-lib/.sqlx/query-9a9b8b5c0b646b841b73461980b37ad3b03ecf99d1484c402694799dc08271f5.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id, display_order) VALUES (?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "9a9b8b5c0b646b841b73461980b37ad3b03ecf99d1484c402694799dc08271f5" +} diff --git a/packages/app-lib/.sqlx/query-a0b0ff0ae4b88d5df9d15d3427ab4e9a6ff21cffdc9c2f3d6860e245949d313d.json b/packages/app-lib/.sqlx/query-a1217622558d50ee18e7ba0d85e991032037aaccfeca64dc076f0dcc826c108a.json similarity index 61% rename from packages/app-lib/.sqlx/query-a0b0ff0ae4b88d5df9d15d3427ab4e9a6ff21cffdc9c2f3d6860e245949d313d.json rename to packages/app-lib/.sqlx/query-a1217622558d50ee18e7ba0d85e991032037aaccfeca64dc076f0dcc826c108a.json index 53263ede9..56da9215c 100644 --- a/packages/app-lib/.sqlx/query-a0b0ff0ae4b88d5df9d15d3427ab4e9a6ff21cffdc9c2f3d6860e245949d313d.json +++ b/packages/app-lib/.sqlx/query-a1217622558d50ee18e7ba0d85e991032037aaccfeca64dc076f0dcc826c108a.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?", + "query": "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated', display_order FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?", "describe": { "columns": [ { @@ -17,6 +17,11 @@ "name": "cape_id: Hyphenated", "ordinal": 2, "type_info": "Text" + }, + { + "name": "display_order", + "ordinal": 3, + "type_info": "Integer" } ], "parameters": { @@ -25,8 +30,9 @@ "nullable": [ false, false, - true + true, + false ] }, - "hash": "a0b0ff0ae4b88d5df9d15d3427ab4e9a6ff21cffdc9c2f3d6860e245949d313d" + "hash": "a1217622558d50ee18e7ba0d85e991032037aaccfeca64dc076f0dcc826c108a" } diff --git a/packages/app-lib/.sqlx/query-bab7f687a8397975747cfe194a0e2cbc2e701584d8b3394a1aa686e3ff4d47f5.json b/packages/app-lib/.sqlx/query-bab7f687a8397975747cfe194a0e2cbc2e701584d8b3394a1aa686e3ff4d47f5.json new file mode 100644 index 000000000..754681e61 --- /dev/null +++ b/packages/app-lib/.sqlx/query-bab7f687a8397975747cfe194a0e2cbc2e701584d8b3394a1aa686e3ff4d47f5.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT COALESCE(MAX(display_order) + 1, 0) AS 'display_order!: i64' FROM custom_minecraft_skins WHERE minecraft_user_uuid = ?", + "describe": { + "columns": [ + { + "name": "display_order!: i64", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "bab7f687a8397975747cfe194a0e2cbc2e701584d8b3394a1aa686e3ff4d47f5" +} diff --git a/packages/app-lib/.sqlx/query-d68c41fb2cb182aa8fc23422c55bd5b728ba93d9e3bb6f3db57ac1c83574d508.json b/packages/app-lib/.sqlx/query-d68c41fb2cb182aa8fc23422c55bd5b728ba93d9e3bb6f3db57ac1c83574d508.json new file mode 100644 index 000000000..8757e8679 --- /dev/null +++ b/packages/app-lib/.sqlx/query-d68c41fb2cb182aa8fc23422c55bd5b728ba93d9e3bb6f3db57ac1c83574d508.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE custom_minecraft_skins SET display_order = ? WHERE minecraft_user_uuid = ? AND texture_key = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "d68c41fb2cb182aa8fc23422c55bd5b728ba93d9e3bb6f3db57ac1c83574d508" +} diff --git a/packages/app-lib/migrations/20260609120000_add-saved-skin-order.sql b/packages/app-lib/migrations/20260609120000_add-saved-skin-order.sql new file mode 100644 index 000000000..f5dcb86b8 --- /dev/null +++ b/packages/app-lib/migrations/20260609120000_add-saved-skin-order.sql @@ -0,0 +1,13 @@ +ALTER TABLE custom_minecraft_skins +ADD COLUMN display_order INTEGER NOT NULL DEFAULT 0; + +UPDATE custom_minecraft_skins +SET display_order = ( + SELECT COUNT(*) + FROM custom_minecraft_skins AS previous + WHERE previous.minecraft_user_uuid = custom_minecraft_skins.minecraft_user_uuid + AND previous.rowid <= custom_minecraft_skins.rowid +) - 1; + +CREATE INDEX custom_minecraft_skins_user_display_order + ON custom_minecraft_skins (minecraft_user_uuid, display_order); diff --git a/packages/app-lib/src/api/minecraft_skins.rs b/packages/app-lib/src/api/minecraft_skins.rs index 1a877d8a9..dc4373e67 100644 --- a/packages/app-lib/src/api/minecraft_skins.rs +++ b/packages/app-lib/src/api/minecraft_skins.rs @@ -65,7 +65,9 @@ use crate::{ ErrorKind, State, state::{ MinecraftCharacterExpressionState, MinecraftProfile, - minecraft_skins::{CustomMinecraftSkin, mojang_api}, + minecraft_skins::{ + CustomMinecraftSkin, CustomMinecraftSkinInsertPosition, mojang_api, + }, }, }; @@ -408,6 +410,7 @@ pub async fn get_available_skins() -> crate::Result> { &texture_blob, custom_skin.variant, custom_skin.cape_id, + CustomMinecraftSkinInsertPosition::Bottom, &state.pool, ) .await?; @@ -453,7 +456,6 @@ pub async fn get_available_skins() -> crate::Result> { }); } - custom_skins.sort_by(|a, b| a.texture.as_str().cmp(b.texture.as_str())); available_skins.extend(custom_skins); for default_skin in assets::DEFAULT_SKINS.iter() { @@ -534,6 +536,7 @@ pub async fn add_and_equip_custom_skin( &texture_blob, variant, cape_id, + CustomMinecraftSkinInsertPosition::Top, &state.pool, ) .await?; @@ -606,6 +609,21 @@ async fn add_and_equip_custom_skin_now( let equipped_skin = profile.current_skin()?; let equipped_skin_texture_key = equipped_skin.texture_key(); let equipped_skin_variant = equipped_skin.variant; + let insert_position = if local_texture_key + != equipped_skin_texture_key.as_ref() + { + CustomMinecraftSkin::get_by_texture( + profile.id, + local_texture_key, + &state.pool, + ) + .await? + .map_or(CustomMinecraftSkinInsertPosition::Top, |skin| { + CustomMinecraftSkinInsertPosition::At(skin.display_order) + }) + } else { + CustomMinecraftSkinInsertPosition::Top + }; let persistence_result = if cape_id.is_none() && is_bundled_skin(&equipped_skin_texture_key, equipped_skin_variant) @@ -614,6 +632,7 @@ async fn add_and_equip_custom_skin_now( texture_key: equipped_skin_texture_key.to_string(), variant: equipped_skin_variant, cape_id: None, + display_order: 0, } .remove(profile.id, &state.pool) .await @@ -624,6 +643,7 @@ async fn add_and_equip_custom_skin_now( &texture_blob, variant, cape_id, + insert_position, &state.pool, ) .await @@ -639,6 +659,7 @@ async fn add_and_equip_custom_skin_now( texture_key: local_texture_key.to_string(), variant, cape_id, + display_order: 0, } .remove(profile.id, &state.pool) .await?; @@ -741,6 +762,22 @@ async fn persist_equipped_skin( let equipped_skin_variant = equipped_skin.variant; let texture_key_changed = skin.texture_key.as_ref() != equipped_skin_texture_key.as_ref(); + let insert_position = if texture_key_changed { + CustomMinecraftSkin::get_by_texture( + profile.id, + &skin.texture_key, + &state.pool, + ) + .await? + .map_or( + CustomMinecraftSkinInsertPosition::Bottom, + |saved_skin| { + CustomMinecraftSkinInsertPosition::At(saved_skin.display_order) + }, + ) + } else { + CustomMinecraftSkinInsertPosition::Bottom + }; if skin.cape_id.is_none() && is_bundled_skin(&equipped_skin_texture_key, equipped_skin_variant) @@ -749,6 +786,7 @@ async fn persist_equipped_skin( texture_key: equipped_skin_texture_key.to_string(), variant: equipped_skin_variant, cape_id: None, + display_order: 0, } .remove(profile.id, &state.pool) .await?; @@ -760,6 +798,7 @@ async fn persist_equipped_skin( texture_blob, equipped_skin_variant, skin.cape_id, + insert_position, &state.pool, ) .await?; @@ -770,6 +809,7 @@ async fn persist_equipped_skin( texture_key: skin.texture_key.to_string(), variant: skin.variant, cape_id: skin.cape_id, + display_order: 0, } .remove(profile.id, &state.pool) .await?; @@ -796,6 +836,7 @@ pub async fn remove_custom_skin(skin: Skin) -> crate::Result<()> { texture_key: skin.texture_key.to_string(), variant: skin.variant, cape_id: skin.cape_id, + display_order: 0, } .remove(selected_credentials.offline_profile.id, &state.pool) .await?; @@ -809,6 +850,25 @@ pub async fn remove_custom_skin(skin: Skin) -> crate::Result<()> { Ok(()) } +/// Persists the custom saved skin order for the currently selected Minecraft profile. +#[tracing::instrument] +pub async fn set_custom_skin_order( + texture_keys: Vec, +) -> crate::Result<()> { + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + CustomMinecraftSkin::set_order( + selected_credentials.offline_profile.id, + &texture_keys, + &state.pool, + ) + .await +} + /// Adds or updates a saved skin locally without applying it to Mojang. /// /// This is used by the skin editor. If the edited skin is currently equipped, the caller should @@ -839,12 +899,26 @@ pub async fn save_custom_skin( Arc::clone(&skin.texture_key) }; let cape_id = cape.map(|cape| cape.id); + let insert_position = if replace_texture && old_texture_key != texture_key { + CustomMinecraftSkin::get_by_texture( + selected_credentials.offline_profile.id, + &old_texture_key, + &state.pool, + ) + .await? + .map_or(CustomMinecraftSkinInsertPosition::Bottom, |skin| { + CustomMinecraftSkinInsertPosition::At(skin.display_order) + }) + } else { + CustomMinecraftSkinInsertPosition::Bottom + }; if cape_id.is_none() && is_bundled_skin(&texture_key, variant) { CustomMinecraftSkin { texture_key: texture_key.to_string(), variant, cape_id: None, + display_order: 0, } .remove(selected_credentials.offline_profile.id, &state.pool) .await?; @@ -855,6 +929,7 @@ pub async fn save_custom_skin( &texture_blob, variant, cape_id, + insert_position, &state.pool, ) .await?; @@ -865,6 +940,7 @@ pub async fn save_custom_skin( texture_key: old_texture_key.to_string(), variant: skin.variant, cape_id: skin.cape_id, + display_order: 0, } .remove(selected_credentials.offline_profile.id, &state.pool) .await?; @@ -1210,6 +1286,7 @@ async fn preserve_current_profile_skin( &texture, current_skin.variant, current_cape_id, + CustomMinecraftSkinInsertPosition::Bottom, &state.pool, ) .await?; @@ -1231,6 +1308,7 @@ async fn preserve_current_profile_skin( &texture, current_skin.variant, current_cape_id, + CustomMinecraftSkinInsertPosition::Bottom, &state.pool, ) .await?; diff --git a/packages/app-lib/src/state/minecraft_skins/mod.rs b/packages/app-lib/src/state/minecraft_skins/mod.rs index 80a9bbe9d..4c869a6c0 100644 --- a/packages/app-lib/src/state/minecraft_skins/mod.rs +++ b/packages/app-lib/src/state/minecraft_skins/mod.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use futures::{Stream, StreamExt, stream}; use uuid::{Uuid, fmt::Hyphenated}; @@ -22,12 +24,22 @@ pub struct CustomMinecraftSkin { /// /// If `None`, the skin is saved without a cape. pub cape_id: Option, + /// The saved skin display order within this player's saved skins. + pub display_order: i64, +} + +#[derive(Debug, Clone, Copy)] +pub enum CustomMinecraftSkinInsertPosition { + Top, + Bottom, + At(i64), } struct CustomMinecraftSkinRow { texture_key: String, variant: MinecraftSkinVariant, cape_id: Option, + display_order: i64, } impl CustomMinecraftSkin { @@ -37,6 +49,7 @@ impl CustomMinecraftSkin { texture: &[u8], variant: MinecraftSkinVariant, cape_id: Option, + insert_position: CustomMinecraftSkinInsertPosition, db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, ) -> crate::Result<()> { let minecraft_user_id = minecraft_user_id.as_hyphenated(); @@ -44,6 +57,51 @@ impl CustomMinecraftSkin { let mut transaction = db.begin().await?; + let existing_order = sqlx::query_scalar!( + "SELECT display_order FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?", + minecraft_user_id, + texture_key + ) + .fetch_optional(&mut *transaction) + .await?; + + let display_order = match existing_order { + Some(display_order) => display_order, + None => match insert_position { + CustomMinecraftSkinInsertPosition::Top => { + sqlx::query!( + "UPDATE custom_minecraft_skins SET display_order = display_order + 1 WHERE minecraft_user_uuid = ?", + minecraft_user_id + ) + .execute(&mut *transaction) + .await?; + + 0 + } + CustomMinecraftSkinInsertPosition::Bottom => { + sqlx::query_scalar!( + "SELECT COALESCE(MAX(display_order) + 1, 0) AS 'display_order!: i64' \ + FROM custom_minecraft_skins WHERE minecraft_user_uuid = ?", + minecraft_user_id + ) + .fetch_one(&mut *transaction) + .await? + } + CustomMinecraftSkinInsertPosition::At(display_order) => { + sqlx::query!( + "UPDATE custom_minecraft_skins SET display_order = display_order + 1 \ + WHERE minecraft_user_uuid = ? AND display_order >= ?", + minecraft_user_id, + display_order + ) + .execute(&mut *transaction) + .await?; + + display_order + } + }, + }; + sqlx::query!( "DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?", minecraft_user_id, @@ -57,11 +115,11 @@ impl CustomMinecraftSkin { texture_key, texture ) .execute(&mut *transaction) - .await?; + .await?; sqlx::query!( - "INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)", - minecraft_user_id, texture_key, variant, cape_id + "INSERT INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id, display_order) VALUES (?, ?, ?, ?, ?)", + minecraft_user_id, texture_key, variant, cape_id, display_order ) .execute(&mut *transaction) .await?; @@ -80,7 +138,7 @@ impl CustomMinecraftSkin { sqlx::query_as!( CustomMinecraftSkinRow, - "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' \ + "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated', display_order \ FROM custom_minecraft_skins \ WHERE minecraft_user_uuid = ? AND texture_key = ?", minecraft_user_id, @@ -93,6 +151,7 @@ impl CustomMinecraftSkin { texture_key: row.texture_key, variant: row.variant, cape_id: row.cape_id.map(Uuid::from), + display_order: row.display_order, }) }) .transpose() @@ -107,10 +166,10 @@ impl CustomMinecraftSkin { let minecraft_user_id = minecraft_user_id.as_hyphenated(); Ok(stream::iter(sqlx::query!( - "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' \ + "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated', display_order \ FROM custom_minecraft_skins \ WHERE minecraft_user_uuid = ? \ - ORDER BY rowid ASC \ + ORDER BY display_order ASC, rowid ASC \ LIMIT ? OFFSET ?", minecraft_user_id, count, offset ) @@ -120,6 +179,7 @@ impl CustomMinecraftSkin { texture_key: row.texture_key, variant: row.variant, cape_id: row.cape_id.map(Uuid::from), + display_order: row.display_order, })) } @@ -161,4 +221,62 @@ impl CustomMinecraftSkin { Ok(()) } + + pub async fn set_order( + minecraft_user_id: Uuid, + texture_keys: &[String], + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result<()> { + let minecraft_user_id = minecraft_user_id.as_hyphenated(); + let mut transaction = db.begin().await?; + + let existing_rows = sqlx::query!( + "SELECT texture_key FROM custom_minecraft_skins \ + WHERE minecraft_user_uuid = ? \ + ORDER BY display_order ASC, rowid ASC", + minecraft_user_id + ) + .fetch_all(&mut *transaction) + .await?; + + let existing_keys = existing_rows + .iter() + .map(|row| row.texture_key.as_str()) + .collect::>(); + let mut seen_keys = HashSet::new(); + let mut ordered_keys = Vec::with_capacity(existing_rows.len()); + + for texture_key in texture_keys { + if seen_keys.insert(texture_key.as_str()) + && existing_keys.contains(texture_key.as_str()) + { + ordered_keys.push(texture_key.as_str()); + } + } + + for row in &existing_rows { + if seen_keys.insert(row.texture_key.as_str()) { + ordered_keys.push(row.texture_key.as_str()); + } + } + + for (display_order, texture_key) in ordered_keys.into_iter().enumerate() + { + let display_order = display_order as i64; + + sqlx::query!( + "UPDATE custom_minecraft_skins SET display_order = ? \ + WHERE minecraft_user_uuid = ? AND texture_key = ?", + display_order, + minecraft_user_id, + texture_key + ) + .execute(&mut *transaction) + .await?; + } + + transaction.commit().await?; + + Ok(()) + } } diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts index 6f8549ccb..90d709b0f 100644 --- a/packages/assets/generated-icons.ts +++ b/packages/assets/generated-icons.ts @@ -135,6 +135,7 @@ import _GitGraphIcon from './icons/git-graph.svg?component' import _GlassesIcon from './icons/glasses.svg?component' import _GlobeIcon from './icons/globe.svg?component' import _GridIcon from './icons/grid.svg?component' +import _GripVerticalIcon from './icons/grip-vertical.svg?component' import _HamburgerIcon from './icons/hamburger.svg?component' import _HammerIcon from './icons/hammer.svg?component' import _HandHelpingIcon from './icons/hand-helping.svg?component' @@ -189,6 +190,7 @@ import _MonitorSmartphoneIcon from './icons/monitor-smartphone.svg?component' import _MoonIcon from './icons/moon.svg?component' import _MoreHorizontalIcon from './icons/more-horizontal.svg?component' import _MoreVerticalIcon from './icons/more-vertical.svg?component' +import _MoveIcon from './icons/move.svg?component' import _NewspaperIcon from './icons/newspaper.svg?component' import _NoSignalIcon from './icons/no-signal.svg?component' import _NotepadTextIcon from './icons/notepad-text.svg?component' @@ -555,6 +557,7 @@ export const GitGraphIcon = _GitGraphIcon export const GlassesIcon = _GlassesIcon export const GlobeIcon = _GlobeIcon export const GridIcon = _GridIcon +export const GripVerticalIcon = _GripVerticalIcon export const HamburgerIcon = _HamburgerIcon export const HammerIcon = _HammerIcon export const HandHelpingIcon = _HandHelpingIcon @@ -609,6 +612,7 @@ export const MonitorSmartphoneIcon = _MonitorSmartphoneIcon export const MoonIcon = _MoonIcon export const MoreHorizontalIcon = _MoreHorizontalIcon export const MoreVerticalIcon = _MoreVerticalIcon +export const MoveIcon = _MoveIcon export const NewspaperIcon = _NewspaperIcon export const NoSignalIcon = _NoSignalIcon export const NotepadTextIcon = _NotepadTextIcon diff --git a/packages/assets/icons/grip-vertical.svg b/packages/assets/icons/grip-vertical.svg new file mode 100644 index 000000000..3cadd75a1 --- /dev/null +++ b/packages/assets/icons/grip-vertical.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/packages/assets/icons/move.svg b/packages/assets/icons/move.svg new file mode 100644 index 000000000..19c0f042e --- /dev/null +++ b/packages/assets/icons/move.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/packages/ui/src/components/skin/SkinButton.vue b/packages/ui/src/components/skin/SkinButton.vue index 613fb3df6..ef73293ba 100644 --- a/packages/ui/src/components/skin/SkinButton.vue +++ b/packages/ui/src/components/skin/SkinButton.vue @@ -9,28 +9,27 @@ const emit = defineEmits<{ const props = withDefaults( defineProps<{ forwardImageSrc?: string - backwardImageSrc?: string selected: boolean active?: boolean tooltip?: string disabled?: boolean + isDragging?: boolean }>(), { forwardImageSrc: undefined, - backwardImageSrc: undefined, active: false, tooltip: undefined, disabled: false, + isDragging: false, }, ) const imagesLoaded = ref({ forward: false, - backward: false, }) -function onImageLoad(type: 'forward' | 'backward') { - imagesLoaded.value[type] = true +function onImageLoad() { + imagesLoaded.value.forward = true } watch( @@ -39,13 +38,6 @@ watch( imagesLoaded.value.forward = false }, ) - -watch( - () => props.backwardImageSrc, - () => { - imagesLoaded.value.backward = false - }, -)