Files
social-media/frontend/src/components/ImageCropperDialog.vue
Jonathan Bourdon 1ca6ab7117
All checks were successful
deploy-socialize / image (push) Successful in 50s
deploy-socialize / deploy (push) Successful in 19s
feat: centralize frontend Vuetify styling
2026-05-08 13:45:42 -04:00

357 lines
9.7 KiB
Vue

<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { Cropper } from 'vue-advanced-cropper';
import 'vue-advanced-cropper/dist/style.css';
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
title: {
type: String,
default: 'Edit image',
},
confirmLabel: {
type: String,
default: 'Save image',
},
aspectRatio: {
type: Number,
default: 1,
},
uploadLabel: {
type: String,
default: 'Choose image',
},
isSaving: {
type: Boolean,
default: false,
},
initialUrl: {
type: String,
default: '',
},
sourceLabel: {
type: String,
default: 'Image URL',
},
loadLabel: {
type: String,
default: 'Load URL',
},
});
const emit = defineEmits(['update:modelValue', 'save']);
const cropper = ref(null);
const imageUrl = ref(null);
const remoteUrl = ref('');
const error = ref(null);
const isReady = computed(() => Boolean(imageUrl.value));
function closeDialog() {
emit('update:modelValue', false);
}
function revokeImageUrl() {
if (imageUrl.value?.startsWith('blob:')) {
URL.revokeObjectURL(imageUrl.value);
}
}
function resetState() {
revokeImageUrl();
imageUrl.value = props.initialUrl || null;
remoteUrl.value = props.initialUrl || '';
error.value = null;
}
function onFileSelected(value) {
const file = Array.isArray(value) ? value[0] : value;
if (!file) {
return;
}
revokeImageUrl();
imageUrl.value = URL.createObjectURL(file);
error.value = null;
}
function loadImageFromUrl() {
if (!remoteUrl.value) {
error.value = 'Enter an image URL before loading it.';
return;
}
revokeImageUrl();
imageUrl.value = remoteUrl.value;
error.value = null;
}
function zoom(factor) {
cropper.value?.zoom(factor);
}
function rotate(angle) {
cropper.value?.rotate(angle);
}
async function saveCrop() {
const result = cropper.value?.getResult();
const canvas = result?.canvas;
if (!canvas) {
error.value = 'Select an image before saving.';
return;
}
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png', 0.92));
if (!blob) {
error.value = 'The edited image could not be prepared.';
return;
}
const file = new File([blob], 'cropped-image.png', { type: 'image/png' });
const dataUrl = canvas.toDataURL('image/png', 0.92);
await emit('save', { file, dataUrl, sourceUrl: remoteUrl.value || imageUrl.value });
}
watch(
() => props.modelValue,
isOpen => {
if (isOpen) {
resetState();
} else {
resetState();
}
}
);
onBeforeUnmount(() => {
revokeImageUrl();
});
</script>
<template>
<v-dialog
:model-value="modelValue"
max-width="920"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="cropper-card">
<div class="cropper-header">
<div>
<div class="cropper-eyebrow">Image editor</div>
<h2>{{ title }}</h2>
</div>
<v-btn variant="text" :ripple="false"
class="plain-button"
:disabled="isSaving"
@click="closeDialog"
>
Close
</v-btn>
</div>
<div class="cropper-actions">
<v-file-input
:label="uploadLabel"
accept="image/*"
:disabled="isSaving"
density="compact"
variant="outlined"
hide-details
@update:model-value="onFileSelected"
/>
<div class="url-controls">
<v-text-field
v-model="remoteUrl"
type="url"
class="url-input"
:placeholder="sourceLabel"
:disabled="isSaving"
density="compact"
variant="outlined"
hide-details
/>
<v-btn variant="text" :ripple="false"
class="action-button secondary"
:disabled="isSaving"
@click="loadImageFromUrl"
>
{{ loadLabel }}
</v-btn>
</div>
<v-btn variant="text" :ripple="false"
class="action-button secondary"
:disabled="!isReady || isSaving"
@click="zoom(1.15)"
>
Zoom in
</v-btn>
<v-btn variant="text" :ripple="false"
class="action-button secondary"
:disabled="!isReady || isSaving"
@click="zoom(0.85)"
>
Zoom out
</v-btn>
<v-btn variant="text" :ripple="false"
class="action-button secondary"
:disabled="!isReady || isSaving"
@click="rotate(-90)"
>
Rotate left
</v-btn>
<v-btn variant="text" :ripple="false"
class="action-button secondary"
:disabled="!isReady || isSaving"
@click="rotate(90)"
>
Rotate right
</v-btn>
</div>
<div
v-if="error"
class="error-message"
>
{{ error }}
</div>
<div
v-if="isReady"
class="cropper-stage"
>
<Cropper
ref="cropper"
:src="imageUrl"
:stencil-props="{ aspectRatio }"
image-restriction="stencil"
/>
</div>
<div
v-else
class="empty-state"
>
Choose an image to crop and upload.
</div>
<div class="footer-actions">
<v-btn variant="text" :ripple="false"
class="action-button secondary"
:disabled="isSaving"
@click="closeDialog"
>
Cancel
</v-btn>
<v-btn variant="text" :ripple="false"
class="action-button"
:disabled="!isReady || isSaving"
@click="saveCrop"
>
<v-progress-circular
v-if="isSaving"
indeterminate
:size="16"
:width="2"
/>
<span>{{ isSaving ? 'Saving...' : confirmLabel }}</span>
</v-btn>
</div>
</div>
</v-dialog>
</template>
<style scoped>
@reference "@/assets/main.css";
.cropper-card {
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5;
background: var(--app-surface-raised);
border-color: var(--app-border-subtle);
}
.cropper-header {
@apply flex items-start justify-between gap-4;
}
.cropper-eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: var(--app-color-on-tertiary);
}
.cropper-header h2 {
@apply mt-2 text-2xl font-black;
color: var(--app-color-on-surface);
}
.cropper-actions,
.footer-actions {
@apply flex flex-wrap gap-3;
}
.url-controls {
@apply flex min-w-full flex-wrap gap-3 md:min-w-0 md:flex-1;
}
.url-input {
@apply min-w-0 flex-1 rounded-full border px-4 py-3 text-sm;
border-color: var(--app-border-subtle);
background: rgba(255, 255, 255, 0.92);
color: var(--app-color-on-surface);
}
.footer-actions {
@apply justify-end;
}
.action-button,
.plain-button {
@apply inline-flex items-center justify-center gap-2 rounded-full px-4 py-3 text-sm font-bold transition;
}
.action-button {
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.action-button.secondary,
.plain-button {
background: rgba(255, 255, 255, 0.84);
color: var(--app-color-on-surface);
border: 1px solid var(--app-border-subtle);
}
.cropper-stage {
@apply overflow-hidden rounded-[1.5rem] border;
height: 28rem;
border-color: var(--app-border-subtle);
background: var(--app-color-on-primary);
}
.empty-state,
.error-message {
@apply rounded-[1.25rem] border p-4 text-sm;
}
.empty-state {
border-color: var(--app-border-subtle);
color: var(--app-text-muted);
background: rgba(255, 250, 242, 0.9);
}
.error-message {
border-color: rgba(185, 28, 28, 0.12);
color: var(--app-danger-muted);
background: rgba(254, 226, 226, 0.75);
}
.hidden-input {
display: none;
}
</style>