diff --git a/lang/en/common.php b/lang/en/common.php index b90f4a9e..a73d7bbc 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -19,6 +19,11 @@ 'uploading' => 'Uploading...', 'remove' => 'Remove photo', 'hint' => 'Recommended: square image, max 2 MB.', + 'crop_title' => 'Crop image', + 'crop_description' => 'Drag and zoom to frame it. The area inside the circle will be used.', + 'crop_hint' => 'Drag to position', + 'crop_save' => 'Save', + 'crop_cancel' => 'Cancel', ], 'timezone' => [ diff --git a/lang/es/common.php b/lang/es/common.php index c85c9c0f..5de37818 100644 --- a/lang/es/common.php +++ b/lang/es/common.php @@ -19,6 +19,11 @@ 'uploading' => 'Subiendo...', 'remove' => 'Eliminar foto', 'hint' => 'Recomendado: imagen cuadrada, máximo 2 MB.', + 'crop_title' => 'Recortar imagen', + 'crop_description' => 'Arrastra y usa el zoom para encuadrar. Se usará el área dentro del círculo.', + 'crop_hint' => 'Arrastra para posicionar', + 'crop_save' => 'Guardar', + 'crop_cancel' => 'Cancelar', ], 'timezone' => [ diff --git a/lang/pt-BR/common.php b/lang/pt-BR/common.php index 6cfae2e2..ee3eefcb 100644 --- a/lang/pt-BR/common.php +++ b/lang/pt-BR/common.php @@ -19,6 +19,11 @@ 'uploading' => 'Enviando...', 'remove' => 'Remover foto', 'hint' => 'Recomendado: imagem quadrada, máximo 2 MB.', + 'crop_title' => 'Recortar imagem', + 'crop_description' => 'Arraste e use o zoom para enquadrar. A área dentro do círculo será usada.', + 'crop_hint' => 'Arraste para posicionar', + 'crop_save' => 'Salvar', + 'crop_cancel' => 'Cancelar', ], 'timezone' => [ diff --git a/package-lock.json b/package-lock.json index 3941c5b3..bff42b4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "tw-animate-css": "^1.2.5", "vaul-vue": "^0.4.1", "vue": "^3.5.13", + "vue-advanced-cropper": "^2.8.9", "vue-input-otp": "^0.3.2", "vue-sonner": "^2.0.9" }, @@ -4030,6 +4031,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -4817,6 +4824,12 @@ "dev": true, "license": "MIT" }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4954,6 +4967,12 @@ "dev": true, "license": "ISC" }, + "node_modules/easy-bem": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/easy-bem/-/easy-bem-1.1.1.tgz", + "integrity": "sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A==", + "license": "MIT" + }, "node_modules/elkjs": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.10.2.tgz", @@ -9588,6 +9607,24 @@ } } }, + "node_modules/vue-advanced-cropper": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/vue-advanced-cropper/-/vue-advanced-cropper-2.8.9.tgz", + "integrity": "sha512-1jc5gO674kVGpJKekoaol6ZlwaF5VYDLSBwBOUpViW0IOrrRsyLw6XNszjEqgbavvqinlKNS6Kqlom3B5M72Tw==", + "license": "MIT", + "dependencies": { + "classnames": "^2.2.6", + "debounce": "^1.2.0", + "easy-bem": "^1.0.2" + }, + "engines": { + "node": ">=8", + "npm": ">=5" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/vue-eslint-parser": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.2.0.tgz", diff --git a/package.json b/package.json index 9f650655..c01ae957 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "tw-animate-css": "^1.2.5", "vaul-vue": "^0.4.1", "vue": "^3.5.13", + "vue-advanced-cropper": "^2.8.9", "vue-input-otp": "^0.3.2", "vue-sonner": "^2.0.9" }, diff --git a/resources/js/components/ImageCropperDialog.vue b/resources/js/components/ImageCropperDialog.vue new file mode 100644 index 00000000..37facabd --- /dev/null +++ b/resources/js/components/ImageCropperDialog.vue @@ -0,0 +1,142 @@ + + + diff --git a/resources/js/components/PhotoUpload.vue b/resources/js/components/PhotoUpload.vue index 3be75a6f..033212c5 100644 --- a/resources/js/components/PhotoUpload.vue +++ b/resources/js/components/PhotoUpload.vue @@ -4,6 +4,7 @@ import { IconTrash } from '@tabler/icons-vue'; import { trans } from 'laravel-vue-i18n'; import { ref } from 'vue'; +import ImageCropperDialog from '@/components/ImageCropperDialog.vue'; import { Avatar } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { @@ -34,6 +35,12 @@ const props = withDefaults(defineProps(), { const fileInput = ref(null); const uploading = ref(false); +// Crop: selecting a file opens the crop (round mask) before uploading. +const cropOpen = ref(false); +const cropSrc = ref(null); +const cropFileName = ref('image.png'); +const cropMime = ref('image/png'); + const sizeClasses = { sm: 'size-16', md: 'size-20', @@ -63,6 +70,23 @@ const handleFileChange = (event: Event) => { return; } + // Open the cropper with the selected image; the actual upload happens on crop. + cropFileName.value = file.name || 'image.png'; + cropMime.value = file.type || 'image/png'; + const reader = new FileReader(); + reader.onload = () => { + cropSrc.value = reader.result as string; + cropOpen.value = true; + }; + reader.readAsDataURL(file); + + // Reset the input so the same file can be selected again later. + if (fileInput.value) { + fileInput.value.value = ''; + } +}; + +const uploadCropped = (file: File) => { uploading.value = true; router.post( @@ -72,9 +96,7 @@ const handleFileChange = (event: Event) => { forceFormData: true, onFinish: () => { uploading.value = false; - if (fileInput.value) { - fileInput.value.value = ''; - } + cropSrc.value = null; }, }, ); @@ -140,5 +162,13 @@ const handleDelete = () => { {{ $t('common.photo_upload.hint') }}

+ +