Add vue-advanced-cropper for improved image editing in Banner and Logo editors; update dependencies and styles

This commit is contained in:
2025-04-17 22:36:08 -04:00
parent c32bdc9577
commit 8850f55dbf
4 changed files with 1633 additions and 971 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,7 @@
"pinia": "^2.1.7", "pinia": "^2.1.7",
"uuid": "^10.0.0", "uuid": "^10.0.0",
"vue": "^3.4.15", "vue": "^3.4.15",
"vue-advanced-cropper": "^2.8.9",
"vue-i18n": "^9.14.0", "vue-i18n": "^9.14.0",
"vue-router": "^4.2.5", "vue-router": "^4.2.5",
"vue3-google-login": "^2.0.26", "vue3-google-login": "^2.0.26",
@@ -33,6 +34,6 @@
"eslint-plugin-vue": "^9.22.0", "eslint-plugin-vue": "^9.22.0",
"postcss": "^8.4.35", "postcss": "^8.4.35",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"vite": "^5.2.11" "vite": "^6.3.1"
} }
} }

View File

@@ -29,22 +29,27 @@
{{ errorMessage }} {{ errorMessage }}
</div> </div>
<div class="cropper-container" v-if="showCropper"> <div v-if="showCropper" class="cropper-wrapper">
<div class="cropper-wrapper"> <Cropper
<img ref="cropper"
ref="cropperImage"
:src="fileUrl" :src="fileUrl"
alt="Image à recadrer" :aspect-ratio="3"
:stencil-props="{
aspectRatio: 3,
class: 'banner-stencil'
}"
/> />
</div> </div>
</div>
<div class="image-preview-container" v-else> <div v-else class="image-preview-container" @click="startEditing">
<img <img
:src="fileUrl || fallbackUrl" :src="fileUrl || fallbackUrl"
alt="Aperçu de la bannière" alt="Aperçu de la bannière"
class="preview-image" class="preview-image"
/> />
<div class="edit-overlay">
<span class="edit-text">Cliquez pour modifier</span>
</div>
</div> </div>
</div> </div>
@@ -63,10 +68,10 @@
</template> </template>
<script setup> <script setup>
import {ref, onBeforeUnmount} from 'vue' import {ref} from 'vue'
import {useClient} from '@/plugins/api.js' import {useClient} from '@/plugins/api.js'
import 'cropperjs/dist/cropper.css' import { Cropper } from 'vue-advanced-cropper'
import Cropper from 'cropperjs' import 'vue-advanced-cropper/dist/style.css'
const props = defineProps({ const props = defineProps({
creator: { creator: {
@@ -81,33 +86,12 @@ const selectedFile = ref(null)
const fileUrl = ref(props.creator?.images?.banner) const fileUrl = ref(props.creator?.images?.banner)
const fallbackUrl = '/images/hutopymedia/banners/hutopyul.png' const fallbackUrl = '/images/hutopymedia/banners/hutopyul.png'
const errorMessage = ref('') const errorMessage = ref('')
const cropperImage = ref(null)
const showCropper = ref(false) const showCropper = ref(false)
const cropper = ref(null) const cropper = ref(null)
const TARGET_RATIO = 3 // 3:1 ratio
const TARGET_WIDTH = 960 const TARGET_WIDTH = 960
const TARGET_HEIGHT = 320 const TARGET_HEIGHT = 320
const initCropper = () => {
if (cropper.value) {
cropper.value.destroy()
}
cropper.value = new Cropper(cropperImage.value, {
aspectRatio: TARGET_RATIO,
viewMode: 2,
responsive: true,
restore: false,
guides: true,
center: true,
highlight: false,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false,
})
}
const triggerFileInput = () => { const triggerFileInput = () => {
fileInput.value.click() fileInput.value.click()
} }
@@ -120,10 +104,6 @@ const onFileSelected = (event) => {
reader.onload = (e) => { reader.onload = (e) => {
fileUrl.value = e.target.result fileUrl.value = e.target.result
showCropper.value = true showCropper.value = true
// Wait for the image to be loaded in the DOM
setTimeout(() => {
initCropper()
}, 0)
} }
reader.readAsDataURL(file) reader.readAsDataURL(file)
} else { } else {
@@ -133,18 +113,27 @@ const onFileSelected = (event) => {
} }
} }
const startEditing = () => {
if (fileUrl.value && fileUrl.value !== fallbackUrl) {
// Create a temporary file from the current image URL
fetch(fileUrl.value)
.then(res => res.blob())
.then(blob => {
selectedFile.value = new File([blob], 'current-image.jpg', { type: 'image/jpeg' })
showCropper.value = true
})
.catch(error => {
console.error('Error loading image for editing:', error)
errorMessage.value = 'Une erreur est survenue lors du chargement de l\'image'
})
}
}
const applyCrop = () => { const applyCrop = () => {
if (!cropper.value) return if (!cropper.value) return
// Get the cropped canvas const canvas = cropper.value.getResult().canvas
const canvas = cropper.value.getCroppedCanvas({
width: TARGET_WIDTH,
height: TARGET_HEIGHT
})
// Convert canvas to blob
canvas.toBlob((blob) => { canvas.toBlob((blob) => {
// Create a new file from the blob
const croppedFile = new File([blob], selectedFile.value.name, { const croppedFile = new File([blob], selectedFile.value.name, {
type: selectedFile.value.type type: selectedFile.value.type
}) })
@@ -176,17 +165,17 @@ const publish = async () => {
} }
const cancel = () => { const cancel = () => {
if (cropper.value) { showCropper.value = false
cropper.value.destroy() // Reset to original state if we were editing
if (props.creator?.images?.banner) {
fileUrl.value = props.creator.images.banner
selectedFile.value = null
} else {
fileUrl.value = fallbackUrl
selectedFile.value = null
} }
emits('closeRequested') emits('closeRequested')
} }
onBeforeUnmount(() => {
if (cropper.value) {
cropper.value.destroy()
}
})
</script> </script>
<style scoped> <style scoped>
@@ -202,6 +191,8 @@ onBeforeUnmount(() => {
@apply items-center; @apply items-center;
@apply overflow-hidden; @apply overflow-hidden;
@apply rounded-lg; @apply rounded-lg;
@apply relative;
@apply cursor-pointer;
} }
.preview-image { .preview-image {
@@ -210,17 +201,41 @@ onBeforeUnmount(() => {
@apply object-cover; @apply object-cover;
} }
.cropper-container { .edit-overlay {
@apply mb-5; @apply absolute;
@apply w-full; @apply inset-0;
@apply flex; @apply flex;
@apply justify-center;
@apply items-center; @apply items-center;
@apply justify-center;
@apply bg-black;
@apply bg-opacity-0;
@apply transition-opacity;
@apply duration-200;
}
.image-preview-container:hover .edit-overlay {
@apply bg-opacity-30;
}
.edit-text {
@apply text-white;
@apply font-medium;
@apply opacity-0;
@apply transition-opacity;
@apply duration-200;
}
.image-preview-container:hover .edit-text {
@apply opacity-100;
} }
.cropper-wrapper { .cropper-wrapper {
@apply max-w-full; @apply mb-5;
@apply max-h-[500px]; @apply w-full;
@apply h-[400px];
@apply flex;
@apply justify-center;
@apply items-center;
@apply overflow-hidden; @apply overflow-hidden;
} }
@@ -245,4 +260,13 @@ onBeforeUnmount(() => {
@apply text-center; @apply text-center;
@apply font-medium; @apply font-medium;
} }
:deep(.banner-stencil) {
@apply border-2;
@apply border-white;
}
:deep(.cropper) {
@apply max-h-full;
}
</style> </style>

View File

@@ -29,23 +29,29 @@
{{ errorMessage }} {{ errorMessage }}
</div> </div>
<div class="cropper-container" v-if="showCropper"> <div v-if="showCropper" class="cropper-wrapper">
<div class="cropper-wrapper"> <Cropper
<img ref="cropper"
ref="cropperImage"
:src="fileUrl" :src="fileUrl"
alt="Image à recadrer" :aspect-ratio="1"
:stencil-component="CircleStencil"
:stencil-props="{
aspectRatio: 1,
class: 'circle-stencil'
}"
/> />
</div> </div>
</div>
<div class="image-preview-container" v-else> <div v-else class="image-preview-container" @click="startEditing">
<div class="circular-preview"> <div class="circular-preview">
<img <img
:src="fileUrl || fallbackUrl" :src="fileUrl || fallbackUrl"
alt="Aperçu du logo" alt="Aperçu du logo"
class="preview-image" class="preview-image"
/> />
<div class="edit-overlay">
<span class="edit-text">Cliquez pour modifier</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -65,10 +71,10 @@
</template> </template>
<script setup> <script setup>
import {ref, onBeforeUnmount} from 'vue' import {ref} from 'vue'
import {useClient} from '@/plugins/api.js' import {useClient} from '@/plugins/api.js'
import 'cropperjs/dist/cropper.css' import { Cropper, CircleStencil } from 'vue-advanced-cropper'
import Cropper from 'cropperjs' import 'vue-advanced-cropper/dist/style.css'
const props = defineProps({ const props = defineProps({
creator: { creator: {
@@ -83,42 +89,12 @@ const selectedFile = ref(null)
const fileUrl = ref(props.creator.images.logo) const fileUrl = ref(props.creator.images.logo)
const fallbackUrl = '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png' const fallbackUrl = '/images/usersmedia/HutopyProfile/profilepictures/profileHutopyProfile01.png'
const errorMessage = ref('') const errorMessage = ref('')
const cropperImage = ref(null)
const showCropper = ref(false) const showCropper = ref(false)
const cropper = ref(null) const cropper = ref(null)
const TARGET_RATIO = 1 // 1:1 ratio for square logo
const TARGET_WIDTH = 200 const TARGET_WIDTH = 200
const TARGET_HEIGHT = 200 const TARGET_HEIGHT = 200
const initCropper = () => {
if (cropper.value) {
cropper.value.destroy()
}
cropper.value = new Cropper(cropperImage.value, {
aspectRatio: TARGET_RATIO,
viewMode: 2,
responsive: true,
restore: false,
guides: true,
center: true,
highlight: false,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false,
cropBoxResizable: true,
cropBoxMovable: true,
cropBoxWidth: 200,
cropBoxHeight: 200,
preview: '.circular-preview',
cropBoxResizable: true,
cropBoxMovable: true,
cropBoxWidth: 200,
cropBoxHeight: 200,
})
}
const triggerFileInput = () => { const triggerFileInput = () => {
fileInput.value.click() fileInput.value.click()
} }
@@ -131,10 +107,6 @@ const onFileSelected = (event) => {
reader.onload = (e) => { reader.onload = (e) => {
fileUrl.value = e.target.result fileUrl.value = e.target.result
showCropper.value = true showCropper.value = true
// Wait for the image to be loaded in the DOM
setTimeout(() => {
initCropper()
}, 0)
} }
reader.readAsDataURL(file) reader.readAsDataURL(file)
} else { } else {
@@ -144,18 +116,27 @@ const onFileSelected = (event) => {
} }
} }
const startEditing = () => {
if (fileUrl.value && fileUrl.value !== fallbackUrl) {
// Create a temporary file from the current image URL
fetch(fileUrl.value)
.then(res => res.blob())
.then(blob => {
selectedFile.value = new File([blob], 'current-image.jpg', { type: 'image/jpeg' })
showCropper.value = true
})
.catch(error => {
console.error('Error loading image for editing:', error)
errorMessage.value = 'Une erreur est survenue lors du chargement de l\'image'
})
}
}
const applyCrop = () => { const applyCrop = () => {
if (!cropper.value) return if (!cropper.value) return
// Get the cropped canvas const canvas = cropper.value.getResult().canvas
const canvas = cropper.value.getCroppedCanvas({
width: TARGET_WIDTH,
height: TARGET_HEIGHT
})
// Convert canvas to blob
canvas.toBlob((blob) => { canvas.toBlob((blob) => {
// Create a new file from the blob
const croppedFile = new File([blob], selectedFile.value.name, { const croppedFile = new File([blob], selectedFile.value.name, {
type: selectedFile.value.type type: selectedFile.value.type
}) })
@@ -187,17 +168,17 @@ const publish = async () => {
} }
const cancel = () => { const cancel = () => {
if (cropper.value) { showCropper.value = false
cropper.value.destroy() // Reset to original state if we were editing
if (props.creator.images.logo) {
fileUrl.value = props.creator.images.logo
selectedFile.value = null
} else {
fileUrl.value = fallbackUrl
selectedFile.value = null
} }
emits('closeRequested') emits('closeRequested')
} }
onBeforeUnmount(() => {
if (cropper.value) {
cropper.value.destroy()
}
})
</script> </script>
<style scoped> <style scoped>
@@ -220,6 +201,8 @@ onBeforeUnmount(() => {
@apply overflow-hidden; @apply overflow-hidden;
@apply border-2; @apply border-2;
@apply border-gray-200; @apply border-gray-200;
@apply relative;
@apply cursor-pointer;
} }
.preview-image { .preview-image {
@@ -228,17 +211,41 @@ onBeforeUnmount(() => {
@apply object-cover; @apply object-cover;
} }
.cropper-container { .edit-overlay {
@apply mb-5; @apply absolute;
@apply w-full; @apply inset-0;
@apply flex; @apply flex;
@apply justify-center;
@apply items-center; @apply items-center;
@apply justify-center;
@apply bg-black;
@apply bg-opacity-0;
@apply transition-opacity;
@apply duration-200;
}
.circular-preview:hover .edit-overlay {
@apply bg-opacity-30;
}
.edit-text {
@apply text-white;
@apply font-medium;
@apply opacity-0;
@apply transition-opacity;
@apply duration-200;
}
.circular-preview:hover .edit-text {
@apply opacity-100;
} }
.cropper-wrapper { .cropper-wrapper {
@apply max-w-full; @apply mb-5;
@apply max-h-[500px]; @apply w-full;
@apply h-[400px];
@apply flex;
@apply justify-center;
@apply items-center;
@apply overflow-hidden; @apply overflow-hidden;
} }
@@ -264,19 +271,18 @@ onBeforeUnmount(() => {
@apply font-medium; @apply font-medium;
} }
/* Add circular crop box styles */ :deep(.circle-stencil) {
:deep(.cropper-view-box), @apply border-2;
:deep(.cropper-face) { @apply border-white;
border-radius: 50%; @apply rounded-full;
} }
:deep(.cropper-view-box) { :deep(.cropper) {
outline: 2px solid #fff; @apply max-h-full;
outline-color: #fff;
} }
:deep(.cropper-face) { :deep(.cropper__stencil) {
background-color: inherit !important; @apply rounded-full;
} }
</style> </style>