diff --git a/package-lock.json b/package-lock.json index 1ba00854..e24246f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,7 @@ "license": "MIT", "dependencies": { "hazardous": "^0.3.0", - "node-api-dotnet": "^0.9.19", - "vue-advanced-cropper": "^2.8.9" + "node-api-dotnet": "^0.9.19" }, "devDependencies": { "@dnd-kit/vue": "^0.3.2", @@ -72,6 +71,7 @@ "vite": "^7.3.1", "vitest": "^3.2.4", "vue": "^3.5.29", + "vue-advanced-cropper": "^2.8.9", "vue-i18n": "^11.2.8", "vue-input-otp": "^0.3.2", "vue-json-pretty": "^2.6.0", @@ -423,6 +423,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -432,6 +433,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -465,6 +467,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -576,6 +579,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -2450,6 +2454,7 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -4543,6 +4548,7 @@ "version": "3.5.29", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz", "integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -4556,6 +4562,7 @@ "version": "3.5.29", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz", "integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-core": "3.5.29", @@ -4566,6 +4573,7 @@ "version": "3.5.29", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz", "integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -4583,6 +4591,7 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -4592,6 +4601,7 @@ "version": "3.5.29", "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz", "integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-dom": "3.5.29", @@ -4638,6 +4648,7 @@ "version": "3.5.29", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz", "integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==", + "dev": true, "license": "MIT", "dependencies": { "@vue/shared": "3.5.29" @@ -4647,6 +4658,7 @@ "version": "3.5.29", "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz", "integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==", + "dev": true, "license": "MIT", "dependencies": { "@vue/reactivity": "3.5.29", @@ -4657,6 +4669,7 @@ "version": "3.5.29", "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz", "integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==", + "dev": true, "license": "MIT", "dependencies": { "@vue/reactivity": "3.5.29", @@ -4669,6 +4682,7 @@ "version": "3.5.29", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz", "integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-ssr": "3.5.29", @@ -4682,6 +4696,7 @@ "version": "3.5.29", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz", "integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==", + "dev": true, "license": "MIT" }, "node_modules/@vue/test-utils": { @@ -5713,6 +5728,7 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "dev": true, "license": "MIT" }, "node_modules/cli-cursor": { @@ -6091,6 +6107,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, "license": "MIT" }, "node_modules/data-urls": { @@ -6156,6 +6173,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -6486,6 +6504,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/easy-bem/-/easy-bem-1.1.1.tgz", "integrity": "sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A==", + "dev": true, "license": "MIT" }, "node_modules/echarts": { @@ -6941,6 +6960,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -7355,6 +7375,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, "license": "MIT" }, "node_modules/esutils": { @@ -9529,6 +9550,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, "funding": [ { "type": "github", @@ -10067,6 +10089,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -10123,6 +10146,7 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -11314,6 +11338,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -11982,7 +12007,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "peer": true, "bin": { @@ -12444,6 +12469,7 @@ "version": "3.5.29", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz", "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-dom": "3.5.29", @@ -12465,6 +12491,7 @@ "version": "2.8.9", "resolved": "https://registry.npmjs.org/vue-advanced-cropper/-/vue-advanced-cropper-2.8.9.tgz", "integrity": "sha512-1jc5gO674kVGpJKekoaol6ZlwaF5VYDLSBwBOUpViW0IOrrRsyLw6XNszjEqgbavvqinlKNS6Kqlom3B5M72Tw==", + "dev": true, "license": "MIT", "dependencies": { "classnames": "^2.2.6", diff --git a/package.json b/package.json index 434ad7cc..eee6b032 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "vite": "^7.3.1", "vitest": "^3.2.4", "vue": "^3.5.29", + "vue-advanced-cropper": "^2.8.9", "vue-i18n": "^11.2.8", "vue-input-otp": "^0.3.2", "vue-json-pretty": "^2.6.0", @@ -187,8 +188,7 @@ }, "dependencies": { "hazardous": "^0.3.0", - "node-api-dotnet": "^0.9.19", - "vue-advanced-cropper": "^2.8.9" + "node-api-dotnet": "^0.9.19" }, "engines": { "node": ">=24.10.0", diff --git a/src/components/dialogs/AvatarDialog/AvatarDialog.vue b/src/components/dialogs/AvatarDialog/AvatarDialog.vue index d3641c99..5d411099 100644 --- a/src/components/dialogs/AvatarDialog/AvatarDialog.vue +++ b/src/components/dialogs/AvatarDialog/AvatarDialog.vue @@ -529,9 +529,19 @@ - + + @@ -597,14 +607,20 @@ DropdownMenuSeparator, DropdownMenuTrigger } from '../../ui/dropdown-menu'; + import { + handleImageUploadInput, + readFileAsBase64, + resizeImageToFitLimits, + uploadImageLegacy, + withUploadTimeout + } from '../../../shared/utils/imageUpload'; import { avatarModerationRequest, avatarRequest, favoriteRequest } from '../../../api'; - import { AppDebug } from '../../../service/appConfig.js'; import { Badge } from '../../ui/badge'; import { database } from '../../../service/database'; import { formatJsonVars } from '../../../shared/utils/base/ui'; - import { handleImageUploadInput } from '../../../shared/utils/imageUpload'; - const ChangeAvatarImageDialog = defineAsyncComponent(() => import('./ChangeAvatarImageDialog.vue')); + import ImageCropDialog from '../ImageCropDialog.vue'; + const SetAvatarStylesDialog = defineAsyncComponent(() => import('./SetAvatarStylesDialog.vue')); const SetAvatarTagsDialog = defineAsyncComponent(() => import('./SetAvatarTagsDialog.vue')); @@ -628,8 +644,9 @@ { value: 'JSON', label: t('dialog.avatar.json.header') } ]); - const changeAvatarImageDialogVisible = ref(false); - const previousImageUrl = ref(''); + const cropDialogOpen = ref(false); + const cropDialogFile = ref(null); + const changeAvatarImageLoading = ref(false); const treeData = ref({}); const memo = ref(''); @@ -971,9 +988,63 @@ } function showChangeAvatarImageDialog() { - const { imageUrl } = avatarDialog.value.ref; - previousImageUrl.value = imageUrl; - changeAvatarImageDialogVisible.value = true; + document.getElementById('AvatarImageUploadButton').click(); + } + + function onFileChangeAvatarImage(e) { + const { file, clearInput } = handleImageUploadInput(e, { + inputSelector: '#AvatarImageUploadButton', + tooLargeMessage: () => t('message.file.too_large'), + invalidTypeMessage: () => t('message.file.not_image') + }); + if (!file) { + return; + } + if (!avatarDialog.value.visible || avatarDialog.value.loading) { + clearInput(); + return; + } + clearInput(); + cropDialogFile.value = file; + cropDialogOpen.value = true; + } + + async function onCropConfirmAvatar(blob) { + changeAvatarImageLoading.value = true; + try { + await withUploadTimeout( + (async () => { + const base64Body = await readFileAsBase64(blob); + const base64File = await resizeImageToFitLimits(base64Body); + if (LINUX) { + const args = await avatarRequest.uploadAvatarImage(base64File); + const fileUrl = args.json.versions[args.json.versions.length - 1].file.url; + await avatarRequest.saveAvatar({ + id: avatarDialog.value.id, + imageUrl: fileUrl + }); + } else { + await uploadImageLegacy('avatar', { + entityId: avatarDialog.value.id, + imageUrl: avatarDialog.value.ref.imageUrl, + base64File, + blob + }); + } + })() + ); + toast.success(t('message.upload.success')); + // force refresh cover image + const avatarId = avatarDialog.value.id; + avatarDialog.value.id = ''; + showAvatarDialog(avatarId); + } catch (error) { + console.error('avatar image upload process failed:', error); + toast.error(t('message.upload.error')); + } finally { + changeAvatarImageLoading.value = false; + cropDialogOpen.value = false; + } } function promptChangeAvatarDescription(avatar) { diff --git a/src/components/dialogs/AvatarDialog/ChangeAvatarImageDialog.vue b/src/components/dialogs/AvatarDialog/ChangeAvatarImageDialog.vue deleted file mode 100644 index 8ff11648..00000000 --- a/src/components/dialogs/AvatarDialog/ChangeAvatarImageDialog.vue +++ /dev/null @@ -1,428 +0,0 @@ - - { - if (!open) closeDialog(); - } - "> - - - {{ t('dialog.change_content_image.avatar') }} - - - - - - - {{ t('dialog.change_content_image.upload') }} - - - {{ t('dialog.change_content_image.description') }} - - - - - - - - - - - - - - {{ t('dialog.change_content_image.cancel') }} - - - {{ t('dialog.change_content_image.confirm') }} - - - - - - - - - - diff --git a/src/components/dialogs/ImageCropDialog.vue b/src/components/dialogs/ImageCropDialog.vue new file mode 100644 index 00000000..a4550b1d --- /dev/null +++ b/src/components/dialogs/ImageCropDialog.vue @@ -0,0 +1,116 @@ + + { + if (!v) cancelCrop(); + } + "> + + + {{ title }} + + + + + + + + + + {{ t('dialog.change_content_image.cancel') }} + + + + {{ loading ? t('message.upload.loading') : t('dialog.gallery_icons.crop_image') }} + + + + + + + + diff --git a/src/components/dialogs/WorldDialog/ChangeWorldImageDialog.vue b/src/components/dialogs/WorldDialog/ChangeWorldImageDialog.vue deleted file mode 100644 index d2f38395..00000000 --- a/src/components/dialogs/WorldDialog/ChangeWorldImageDialog.vue +++ /dev/null @@ -1,415 +0,0 @@ - - { - if (!open) closeDialog(); - } - "> - - - {{ t('dialog.change_content_image.world') }} - - - - - - - {{ t('dialog.change_content_image.upload') }} - - - {{ t('dialog.change_content_image.description') }} - - - - - - - - - - - - - - {{ t('dialog.change_content_image.cancel') }} - - - {{ t('dialog.change_content_image.confirm') }} - - - - - - - - - - diff --git a/src/components/dialogs/WorldDialog/WorldDialog.vue b/src/components/dialogs/WorldDialog/WorldDialog.vue index 79b414d3..6f41b485 100644 --- a/src/components/dialogs/WorldDialog/WorldDialog.vue +++ b/src/components/dialogs/WorldDialog/WorldDialog.vue @@ -744,9 +744,19 @@ - + + @@ -805,7 +815,6 @@ userStatusClass } from '../../../shared/utils'; import { - useAdvancedSettingsStore, useAppearanceSettingsStore, useFavoriteStore, useGalleryStore, @@ -813,7 +822,6 @@ useInstanceStore, useInviteStore, useLocationStore, - useModalStore, useUserStore, useWorldStore } from '../../../stores'; @@ -824,19 +832,21 @@ DropdownMenuSeparator, DropdownMenuTrigger } from '../../ui/dropdown-menu'; + import { + handleImageUploadInput, + readFileAsBase64, + resizeImageToFitLimits, + uploadImageLegacy, + withUploadTimeout + } from '../../../shared/utils/imageUpload'; import { favoriteRequest, miscRequest, userRequest, worldRequest } from '../../../api'; import { Badge } from '../../ui/badge'; import { database } from '../../../service/database.js'; import { formatJsonVars } from '../../../shared/utils/base/ui'; + import ImageCropDialog from '../ImageCropDialog.vue'; import InstanceActionBar from '../../InstanceActionBar.vue'; - const modalStore = useModalStore(); - const { translateText } = useAdvancedSettingsStore(); - const { bioLanguage, translationApi } = storeToRefs(useAdvancedSettingsStore()); - - const NewInstanceDialog = defineAsyncComponent(() => import('../NewInstanceDialog.vue')); - const ChangeWorldImageDialog = defineAsyncComponent(() => import('./ChangeWorldImageDialog.vue')); const SetWorldTagsDialog = defineAsyncComponent(() => import('./SetWorldTagsDialog.vue')); const WorldAllowedDomainsDialog = defineAsyncComponent(() => import('./WorldAllowedDomainsDialog.vue')); @@ -868,8 +878,9 @@ }); const isSetWorldTagsDialogVisible = ref(false); const newInstanceDialogLocationTag = ref(''); - const changeWorldImageDialogVisible = ref(false); - const previousImageUrl = ref(''); + const cropDialogOpen = ref(false); + const cropDialogFile = ref(null); + const changeWorldImageLoading = ref(false); const translatedDescription = ref(''); const isTranslating = ref(false); @@ -1008,9 +1019,50 @@ } function showChangeWorldImageDialog() { - const { imageUrl } = worldDialog.value.ref; - previousImageUrl.value = imageUrl; - changeWorldImageDialogVisible.value = true; + document.getElementById('WorldImageUploadButton').click(); + } + + function onFileChangeWorldImage(e) { + const { file, clearInput } = handleImageUploadInput(e, { + inputSelector: '#WorldImageUploadButton', + tooLargeMessage: () => t('message.file.too_large'), + invalidTypeMessage: () => t('message.file.not_image') + }); + if (!file) { + return; + } + if (!worldDialog.value.visible || worldDialog.value.loading) { + clearInput(); + return; + } + clearInput(); + cropDialogFile.value = file; + cropDialogOpen.value = true; + } + + async function onCropConfirmWorld(blob) { + changeWorldImageLoading.value = true; + try { + await withUploadTimeout( + (async () => { + const base64Body = await readFileAsBase64(blob); + const base64File = await resizeImageToFitLimits(base64Body); + await uploadImageLegacy('world', { + entityId: worldDialog.value.id, + imageUrl: worldDialog.value.ref.imageUrl, + base64File, + blob + }); + })() + ); + toast.success(t('message.upload.success')); + } catch (error) { + console.error('World image upload process failed:', error); + toast.error(t('message.upload.error')); + } finally { + changeWorldImageLoading.value = false; + cropDialogOpen.value = false; + } } function showNewInstanceDialog(tag) { diff --git a/src/composables/useImageCropper.js b/src/composables/useImageCropper.js new file mode 100644 index 00000000..b1900188 --- /dev/null +++ b/src/composables/useImageCropper.js @@ -0,0 +1,112 @@ +import { ref } from 'vue'; +import { toast } from 'vue-sonner'; +import { useI18n } from 'vue-i18n'; + +const MAX_PREVIEW_SIZE = 800; + +export function useImageCropper() { + const { t } = useI18n(); + + const cropperRef = ref(null); + const cropperImageSrc = ref(''); + const originalImage = ref(null); + const previewScale = ref(1); + + function resetCropState() { + cropperImageSrc.value = ''; + originalImage.value = null; + previewScale.value = 1; + } + + /** + * Downscaling for preview + * @param {File} file + */ + function loadImageForCrop(file) { + const r = new FileReader(); + r.onload = () => { + const img = new Image(); + img.onload = () => { + originalImage.value = img; + if ( + img.width > MAX_PREVIEW_SIZE || + img.height > MAX_PREVIEW_SIZE + ) { + const scale = Math.min( + MAX_PREVIEW_SIZE / img.width, + MAX_PREVIEW_SIZE / img.height + ); + previewScale.value = scale; + const cvs = document.createElement('canvas'); + cvs.width = Math.round(img.width * scale); + cvs.height = Math.round(img.height * scale); + const ctx = cvs.getContext('2d'); + ctx.drawImage(img, 0, 0, cvs.width, cvs.height); + cropperImageSrc.value = cvs.toDataURL('image/jpeg', 0.9); + } else { + previewScale.value = 1; + // @ts-ignore + cropperImageSrc.value = r.result; + } + }; + img.onerror = () => { + resetCropState(); + }; + // @ts-ignore + img.src = r.result; + }; + r.onerror = () => { + resetCropState(); + toast.error(t('message.file.not_image')); + }; + r.readAsDataURL(file); + } + + /** + * @param {File} [originalFile] + * @returns {Promise} + */ + function getCroppedBlob(originalFile) { + const result = cropperRef.value?.getResult(); + if (!result?.coordinates || !originalImage.value) { + return Promise.resolve(null); + } + + const { coordinates } = result; + const scale = previewScale.value; + const srcX = Math.round(coordinates.left / scale); + const srcY = Math.round(coordinates.top / scale); + const srcW = Math.round(coordinates.width / scale); + const srcH = Math.round(coordinates.height / scale); + + const img = originalImage.value; + const noCrop = + srcX <= 1 && + srcY <= 1 && + Math.abs(srcW - img.width) <= 1 && + Math.abs(srcH - img.height) <= 1; + + // pass no crop + if (noCrop && originalFile) { + return Promise.resolve(originalFile); + } + + const cropCanvas = document.createElement('canvas'); + cropCanvas.width = srcW; + cropCanvas.height = srcH; + const ctx = cropCanvas.getContext('2d'); + ctx.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, srcW, srcH); + + return new Promise((resolve) => { + cropCanvas.toBlob(resolve, 'image/png'); + }); + } + + return { + cropperRef, + cropperImageSrc, + resetCropState, + loadImageForCrop, + getCroppedBlob + }; +} diff --git a/src/localization/en.json b/src/localization/en.json index 693c7e51..6fa21d2a 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -1694,7 +1694,8 @@ "drone_skin": "Drone Skin", "emoji": "Emoji", "redeem": "Redeem", - "create_animated_emoji": "Animated Emoji Generator" + "create_animated_emoji": "Animated Emoji Generator", + "crop_image": "Confirm Upload" }, "gallery_select": { "header": "Select Image", diff --git a/src/shared/utils/imageUpload.js b/src/shared/utils/imageUpload.js index 55ae038b..56da954a 100644 --- a/src/shared/utils/imageUpload.js +++ b/src/shared/utils/imageUpload.js @@ -1,4 +1,24 @@ import { toast } from 'vue-sonner'; + +import { $throw } from '../../service/request'; +import { AppDebug } from '../../service/appConfig.js'; +import { extractFileId } from './index.js'; +import { imageRequest } from '../../api'; + +const UPLOAD_TIMEOUT_MS = 20_000; + +export function withUploadTimeout(promise) { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Upload timed out')), + UPLOAD_TIMEOUT_MS + ) + ) + ]); +} + function resolveMessage(message) { if (typeof message === 'function') { return message(); @@ -71,3 +91,144 @@ export function handleImageUploadInput(event, options = {}) { return { file, clearInput }; } + +/** + * File -> base64 + * @param {Blob|File} blob + * @returns {Promise} base64 encoded string + */ +export function readFileAsBase64(blob) { + return new Promise((resolve, reject) => { + const r = new FileReader(); + r.onerror = reject; + r.onabort = reject; + r.onload = () => { + const bytes = new Uint8Array(r.result); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + resolve(btoa(binary)); + }; + r.readAsArrayBuffer(blob); + }); +} + +/** + * @param {string} base64Data - base64 encoded image + * @returns {Promise} resized base64 encoded image + */ +export async function resizeImageToFitLimits(base64Data) { + // frontend limit check = 20MB + return AppApi.ResizeImageToFitLimits(base64Data); +} + +/** + * Upload image through AWS + * @param {'avatar'|'world'} type + * @param {object} opts + * @param {string} opts.entityId - avatar or world id + * @param {string} opts.imageUrl - current imageUrl on the entity + * @param {string} opts.base64File - base64 encoded image data + * @param {Blob} opts.blob - the original blob (used for file size) + */ +export async function uploadImageLegacy( + type, + { entityId, imageUrl, base64File, blob } +) { + const apiMap = { + avatar: { + uploadImage: imageRequest.uploadAvatarImage, + fileStart: imageRequest.uploadAvatarImageFileStart, + fileFinish: imageRequest.uploadAvatarImageFileFinish, + sigStart: imageRequest.uploadAvatarImageSigStart, + sigFinish: imageRequest.uploadAvatarImageSigFinish, + setImage: imageRequest.setAvatarImage + }, + world: { + uploadImage: imageRequest.uploadWorldImage, + fileStart: imageRequest.uploadWorldImageFileStart, + fileFinish: imageRequest.uploadWorldImageFileFinish, + sigStart: imageRequest.uploadWorldImageSigStart, + sigFinish: imageRequest.uploadWorldImageSigFinish, + setImage: imageRequest.setWorldImage + } + }; + const api = apiMap[type]; + + const fileMd5 = await AppApi.MD5File(base64File); + const fileSizeInBytes = parseInt(blob.size, 10); + const base64SignatureFile = await AppApi.SignFile(base64File); + const signatureMd5 = await AppApi.MD5File(base64SignatureFile); + const signatureSizeInBytes = parseInt( + await AppApi.FileLength(base64SignatureFile), + 10 + ); + const fileId = extractFileId(imageUrl); + + // imageInit + const uploadRes = await api.uploadImage( + { fileMd5, fileSizeInBytes, signatureMd5, signatureSizeInBytes }, + fileId + ); + const uploadedFileId = uploadRes.json.id; + const fileVersion = + uploadRes.json.versions[uploadRes.json.versions.length - 1].version; + + // imageFileStart + const fileStartRes = await api.fileStart({ + fileId: uploadedFileId, + fileVersion + }); + + // uploadImageFileAWS + const fileAwsRes = await webApiService.execute({ + url: fileStartRes.json.url, + uploadFilePUT: true, + fileData: base64File, + fileMIME: 'image/png', + fileMD5: fileMd5 + }); + if (fileAwsRes.status !== 200) { + $throw( + fileAwsRes.status, + `${type} image upload failed`, + fileStartRes.json.url + ); + } + + // imageFileFinish + await api.fileFinish({ fileId: uploadedFileId, fileVersion }); + + // imageSigStart + const sigStartRes = await api.sigStart({ + fileId: uploadedFileId, + fileVersion + }); + + // uploadImageSigAWS + const sigAwsRes = await webApiService.execute({ + url: sigStartRes.json.url, + uploadFilePUT: true, + fileData: base64SignatureFile, + fileMIME: 'application/x-rsync-signature', + fileMD5: signatureMd5 + }); + if (sigAwsRes.status !== 200) { + $throw( + sigAwsRes.status, + `${type} image upload failed`, + sigStartRes.json.url + ); + } + + // imageSigFinish + await api.sigFinish({ fileId: uploadedFileId, fileVersion }); + + // imageSet + const newImageUrl = `${AppDebug.endpointDomain}/file/${uploadedFileId}/${fileVersion}/file`; + const setRes = await api.setImage({ id: entityId, imageUrl: newImageUrl }); + if (setRes.json.imageUrl !== newImageUrl) { + $throw(0, `${type} image change failed`, newImageUrl); + } +} diff --git a/src/views/Tools/Gallery.vue b/src/views/Tools/Gallery.vue index d0bec098..8a8ef69a 100644 --- a/src/views/Tools/Gallery.vue +++ b/src/views/Tools/Gallery.vue @@ -559,6 +559,14 @@ + + @@ -592,11 +600,12 @@ } from '../../shared/utils'; import { inventoryRequest, miscRequest, userRequest, vrcPlusIconRequest, vrcPlusImageRequest } from '../../api'; import { useAdvancedSettingsStore, useAuthStore, useGalleryStore, useModalStore, useUserStore } from '../../stores'; + import { handleImageUploadInput, readFileAsBase64, withUploadTimeout } from '../../shared/utils/imageUpload'; import { emojiAnimationStyleList, emojiAnimationStyleUrl } from '../../shared/constants'; import { AppDebug } from '../../service/appConfig'; - import { handleImageUploadInput } from '../../shared/utils/imageUpload'; import Emoji from '../../components/Emoji.vue'; + import ImageCropDialog from '../../components/dialogs/ImageCropDialog.vue'; const { t } = useI18n(); const router = useRouter(); @@ -663,6 +672,12 @@ const pendingUploads = ref(0); const isUploading = computed(() => pendingUploads.value > 0); + const cropDialogOpen = ref(false); + const cropDialogTitle = ref(''); + const cropDialogAspectRatio = ref(4 / 3); + const cropDialogFile = ref(null); + const cropDialogUploadHandler = ref(null); + onMounted(() => { galleryDialogVisible.value = true; loadGalleryData(); @@ -685,6 +700,28 @@ router.push({ name: 'tools' }); } + function openCropDialog(file, title, aspectRatio, handler) { + cropDialogTitle.value = title; + cropDialogAspectRatio.value = aspectRatio; + cropDialogFile.value = file; + cropDialogUploadHandler.value = handler; + cropDialogOpen.value = true; + } + + async function onCropConfirm(blob) { + if (!cropDialogUploadHandler.value) { + return; + } + const handler = cropDialogUploadHandler.value; + cropDialogUploadHandler.value = null; + cropDialogFile.value = null; + try { + await handler(blob); + } finally { + cropDialogOpen.value = false; + } + } + function onFileChangeGallery(e) { const { file, clearInput } = handleImageUploadInput(e, { inputSelector: '#GalleryUploadButton', @@ -694,41 +731,25 @@ if (!file) { return; } - startUpload(); - const r = new FileReader(); - const handleReaderError = () => finishUpload(); - r.onerror = handleReaderError; - r.onabort = handleReaderError; - r.onload = function () { - try { - const base64Body = btoa(r.result.toString()); - const uploadPromise = vrcPlusImageRequest.uploadGalleryImage(base64Body).then((args) => { - handleGalleryImageAdd(args); - return args; - }); - toast.promise(uploadPromise, { - loading: t('message.upload.loading'), - success: t('message.upload.success'), - error: t('message.upload.error') - }); - uploadPromise - .catch((error) => { - console.error('Failed to upload', error); - }) - .finally(() => finishUpload()); - } catch (error) { - finishUpload(); - console.error('Failed to process image', error); - } - }; - try { - r.readAsBinaryString(file); - } catch (error) { - clearInput(); - finishUpload(); - console.error('Failed to read file', error); - } clearInput(); + openCropDialog(file, t('dialog.change_content_image.upload'), 4 / 3, async (blob) => { + startUpload(); + try { + await withUploadTimeout( + (async () => { + const base64Body = await readFileAsBase64(blob); + const args = await vrcPlusImageRequest.uploadGalleryImage(base64Body); + handleGalleryImageAdd(args); + })() + ); + toast.success(t('message.upload.success')); + } catch (error) { + console.error('Failed to upload', error); + toast.error(t('message.upload.error')); + } finally { + finishUpload(); + } + }); } function displayGalleryUpload() { @@ -789,43 +810,27 @@ if (!file) { return; } - startUpload(); - const r = new FileReader(); - const handleReaderError = () => finishUpload(); - r.onerror = handleReaderError; - r.onabort = handleReaderError; - r.onload = function () { - try { - const base64Body = btoa(r.result.toString()); - const uploadPromise = vrcPlusIconRequest.uploadVRCPlusIcon(base64Body).then((args) => { - if (Object.keys(VRCPlusIconsTable.value).length !== 0) { - VRCPlusIconsTable.value.unshift(args.json); - } - return args; - }); - toast.promise(uploadPromise, { - loading: t('message.upload.loading'), - success: t('message.upload.success'), - error: t('message.upload.error') - }); - uploadPromise - .catch((error) => { - console.error('Failed to upload VRC+ icon', error); - }) - .finally(() => finishUpload()); - } catch (error) { - finishUpload(); - console.error('Failed to process upload', error); - } - }; - try { - r.readAsBinaryString(file); - } catch (error) { - clearInput(); - finishUpload(); - console.error('Failed to read file', error); - } clearInput(); + openCropDialog(file, t('dialog.change_content_image.upload'), 1 / 1, async (blob) => { + startUpload(); + try { + await withUploadTimeout( + (async () => { + const base64Body = await readFileAsBase64(blob); + const args = await vrcPlusIconRequest.uploadVRCPlusIcon(base64Body); + if (Object.keys(VRCPlusIconsTable.value).length !== 0) { + VRCPlusIconsTable.value.unshift(args.json); + } + })() + ); + toast.success(t('message.upload.success')); + } catch (error) { + console.error('Failed to upload VRC+ icon', error); + toast.error(t('message.upload.error')); + } finally { + finishUpload(); + } + }); } function displayVRCPlusIconUpload() { @@ -908,57 +913,41 @@ if (!file) { return; } - startUpload(); // set Emoji settings from fileName parseEmojiFileName(file.name); - const r = new FileReader(); - const handleReaderError = () => finishUpload(); - r.onerror = handleReaderError; - r.onabort = handleReaderError; - r.onload = function () { - try { - const params = { - tag: emojiAnimType.value ? 'emojianimated' : 'emoji', - animationStyle: emojiAnimationStyle.value.toLowerCase(), - maskTag: 'square' - }; - if (emojiAnimType.value) { - params.frames = emojiAnimFrameCount.value; - params.framesOverTime = emojiAnimFps.value; - } - if (emojiAnimLoopPingPong.value) { - params.loopStyle = 'pingpong'; - } - const base64Body = btoa(r.result.toString()); - const uploadPromise = vrcPlusImageRequest.uploadEmoji(base64Body, params).then((args) => { - if (Object.keys(emojiTable.value).length !== 0) { - emojiTable.value.unshift(args.json); - } - return args; - }); - toast.promise(uploadPromise, { - loading: t('message.upload.loading'), - success: t('message.upload.success'), - error: t('message.upload.error') - }); - uploadPromise - .catch((error) => { - console.error('Failed to upload', error); - }) - .finally(() => finishUpload()); - } catch (error) { - finishUpload(); - console.error('Failed to process upload', error); - } - }; - try { - r.readAsBinaryString(file); - } catch (error) { - clearInput(); - finishUpload(); - console.error('Failed to read file', error); - } clearInput(); + openCropDialog(file, t('dialog.change_content_image.upload'), 1 / 1, async (blob) => { + startUpload(); + try { + await withUploadTimeout( + (async () => { + const params = { + tag: emojiAnimType.value ? 'emojianimated' : 'emoji', + animationStyle: emojiAnimationStyle.value.toLowerCase(), + maskTag: 'square' + }; + if (emojiAnimType.value) { + params.frames = emojiAnimFrameCount.value; + params.framesOverTime = emojiAnimFps.value; + } + if (emojiAnimLoopPingPong.value) { + params.loopStyle = 'pingpong'; + } + const base64Body = await readFileAsBase64(blob); + const args = await vrcPlusImageRequest.uploadEmoji(base64Body, params); + if (Object.keys(emojiTable.value).length !== 0) { + emojiTable.value.unshift(args.json); + } + })() + ); + toast.success(t('message.upload.success')); + } catch (error) { + console.error('Failed to upload', error); + toast.error(t('message.upload.error')); + } finally { + finishUpload(); + } + }); } function displayEmojiUpload() { @@ -988,45 +977,29 @@ if (!file) { return; } - startUpload(); - const r = new FileReader(); - const handleReaderError = () => finishUpload(); - r.onerror = handleReaderError; - r.onabort = handleReaderError; - r.onload = function () { - try { - const params = { - tag: 'sticker', - maskTag: 'square' - }; - const base64Body = btoa(r.result.toString()); - const uploadPromise = vrcPlusImageRequest.uploadSticker(base64Body, params).then((args) => { - handleStickerAdd(args); - return args; - }); - toast.promise(uploadPromise, { - loading: t('message.upload.loading'), - success: t('message.upload.success'), - error: t('message.upload.error') - }); - uploadPromise - .catch((error) => { - console.error('Failed to upload', error); - }) - .finally(() => finishUpload()); - } catch (error) { - finishUpload(); - console.error('Failed to process upload', error); - } - }; - try { - r.readAsBinaryString(file); - } catch (error) { - clearInput(); - finishUpload(); - console.error('Failed to read file', error); - } clearInput(); + openCropDialog(file, t('dialog.change_content_image.upload'), 1 / 1, async (blob) => { + startUpload(); + try { + await withUploadTimeout( + (async () => { + const params = { + tag: 'sticker', + maskTag: 'square' + }; + const base64Body = await readFileAsBase64(blob); + const args = await vrcPlusImageRequest.uploadSticker(base64Body, params); + handleStickerAdd(args); + })() + ); + toast.success(t('message.upload.success')); + } catch (error) { + console.error('Failed to upload', error); + toast.error(t('message.upload.error')); + } finally { + finishUpload(); + } + }); } function displayStickerUpload() { @@ -1057,55 +1030,37 @@ if (!file) { return; } - startUpload(); - const r = new FileReader(); - const handleReaderError = () => finishUpload(); - r.onerror = handleReaderError; - r.onabort = handleReaderError; - r.onload = function () { + clearInput(); + openCropDialog(file, t('dialog.change_content_image.upload'), 16 / 9, async (blob) => { + startUpload(); try { - const date = new Date(); - // why the fuck isn't this UTC - date.setMinutes(date.getMinutes() - date.getTimezoneOffset()); - const timestamp = date.toISOString().slice(0, 19); - const params = { - note: printUploadNote.value, - // worldId: '', - timestamp - }; - const base64Body = btoa(r.result.toString()); - const cropWhiteBorder = printCropBorder.value; - const uploadPromise = vrcPlusImageRequest - .uploadPrint(base64Body, cropWhiteBorder, params) - .then((args) => { + await withUploadTimeout( + (async () => { + const date = new Date(); + // why the fuck isn't this UTC + date.setMinutes(date.getMinutes() - date.getTimezoneOffset()); + const timestamp = date.toISOString().slice(0, 19); + const params = { + note: printUploadNote.value, + // worldId: '', + timestamp + }; + const base64Body = await readFileAsBase64(blob); + const cropWhiteBorder = printCropBorder.value; + const args = await vrcPlusImageRequest.uploadPrint(base64Body, cropWhiteBorder, params); if (Object.keys(printTable.value).length !== 0) { printTable.value.unshift(args.json); } - return args; - }); - toast.promise(uploadPromise, { - loading: t('message.upload.loading'), - success: t('message.upload.success'), - error: t('message.upload.error') - }); - uploadPromise - .catch((error) => { - console.error('Failed to upload', error); - }) - .finally(() => finishUpload()); + })() + ); + toast.success(t('message.upload.success')); } catch (error) { + console.error('Failed to upload', error); + toast.error(t('message.upload.error')); + } finally { finishUpload(); - console.error('Failed to process upload', error); } - }; - try { - r.readAsBinaryString(file); - } catch (error) { - clearInput(); - finishUpload(); - console.error('Failed to read file', error); - } - clearInput(); + }); } function displayPrintUpload() {