From c767a8893411d13c4baf5ab7ffc52a70f24c27fa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Paulo=20S=C3=A9rgio=20Dantas?=
Date: Wed, 1 Jul 2026 02:37:43 -0300
Subject: [PATCH] feat(profile): crop avatar/logo before upload (round mask by
default)
Avatar/logo upload was raw: any aspect ratio went straight to the server, so
the circular avatar ended up off-center. Since the avatar/logo also renders as a
circle on generated cards, letting the user frame it improves the result a lot.
Selecting a file now opens an ImageCropperDialog (zoom + drag) with a round mask
by default; Save produces a canvas.toBlob -> File that gets uploaded already
cropped and resized (512x512 output, well under the 2 MB limit). The integration
lives in one place, PhotoUpload.vue, used by both the profile avatar and the
workspace logo. The cropper mounts only after the dialog has its final layout,
otherwise the circular stencil degrades into a square inside the modal.
Adds the vue-advanced-cropper dependency.
- ImageCropperDialog.vue (new): the crop modal (vue-advanced-cropper, CircleStencil).
- PhotoUpload.vue: opens the crop before uploading.
- lang/{en,es,pt-BR}/common.php: photo_upload.crop_* strings.
---
lang/en/common.php | 5 +
lang/es/common.php | 5 +
lang/pt-BR/common.php | 5 +
package-lock.json | 37 +++++
package.json | 1 +
.../js/components/ImageCropperDialog.vue | 142 ++++++++++++++++++
resources/js/components/PhotoUpload.vue | 36 ++++-
7 files changed, 228 insertions(+), 3 deletions(-)
create mode 100644 resources/js/components/ImageCropperDialog.vue
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') }}
+
+