400 lines
9.8 KiB
Vue
400 lines
9.8 KiB
Vue
<template>
|
|
<div class="album-editor">
|
|
|
|
<h2 class="text-xl font-semibold mb-4">
|
|
{{ t('title') }}
|
|
</h2>
|
|
|
|
<!-- Drop zone with photos -->
|
|
<div class="drop-zone"
|
|
@dragover.prevent
|
|
@drop.prevent="handleDrop"
|
|
@click="triggerFileInput">
|
|
|
|
<!-- Upload prompt -->
|
|
<div class="drop-zone-content">
|
|
<v-icon size="large">mdi-plus</v-icon>
|
|
<span class="text-sm mt-2">{{ t('dropzoneText') }}</span>
|
|
</div>
|
|
|
|
<!-- Hidden file input -->
|
|
<input
|
|
type="file"
|
|
ref="fileInput"
|
|
@change="handleFileUpload"
|
|
accept="image/*"
|
|
multiple
|
|
class="hidden"
|
|
/>
|
|
|
|
<!-- Photos grid -->
|
|
<draggable
|
|
v-model="localImages"
|
|
class="photos-grid"
|
|
item-key="id"
|
|
@end="handleReorder"
|
|
>
|
|
<template #item="{ element, index }">
|
|
<div class="photo-wrapper" @click.stop="toggleMobileControls(index)">
|
|
<div class="index-bubble">{{ index + 1 }}</div>
|
|
<img :src="element.url" :alt="'Image ' + (index + 1)" />
|
|
<!-- Processing spinner overlay -->
|
|
<div v-if="element.isProcessing" class="loading-overlay">
|
|
<v-progress-circular indeterminate color="primary"></v-progress-circular>
|
|
<span class="text-white text-sm mt-2">{{ t('processing') }}</span>
|
|
</div>
|
|
<!-- Upload spinner overlay -->
|
|
<div v-if="element.isUploading" class="loading-overlay uploading">
|
|
<v-progress-circular indeterminate color="secondary"></v-progress-circular>
|
|
<span class="text-white text-sm mt-2">{{ t('uploading') }}</span>
|
|
</div>
|
|
<!-- Left arrow -->
|
|
<button @click.stop="moveImage(index, 'up')"
|
|
class="action-btn left-btn"
|
|
:disabled="index === 0"
|
|
:title="t('moveLeft')"
|
|
:class="{'mobile-active': activePhotoIndex === index}">
|
|
<v-icon>mdi-arrow-left</v-icon>
|
|
</button>
|
|
<!-- Right arrow -->
|
|
<button @click.stop="moveImage(index, 'down')"
|
|
class="action-btn right-btn"
|
|
:disabled="index === localImages.length - 1"
|
|
:title="t('moveRight')"
|
|
:class="{'mobile-active': activePhotoIndex === index}">
|
|
<v-icon>mdi-arrow-right</v-icon>
|
|
</button>
|
|
<!-- Delete button -->
|
|
<button @click.stop="deleteImage(index)"
|
|
class="action-btn delete-btn"
|
|
:title="t('delete')"
|
|
:class="{'mobile-active': activePhotoIndex === index}">
|
|
<v-icon>mdi-delete</v-icon>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</draggable>
|
|
</div>
|
|
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, onUnmounted } from "vue";
|
|
import { useI18n } from 'vue-i18n';
|
|
import draggable from 'vuedraggable';
|
|
|
|
const props = defineProps({
|
|
images: {
|
|
type: Array,
|
|
required: true
|
|
}
|
|
});
|
|
|
|
const emit = defineEmits(['update:images']);
|
|
|
|
const { t } = useI18n();
|
|
const fileInput = ref(null);
|
|
const localImages = ref([]);
|
|
const activePhotoIndex = ref(null); // Track which photo is currently active for mobile
|
|
|
|
onMounted(() => {
|
|
// Initialize local images with IDs and states
|
|
localImages.value = props.images.map((url, index) => ({
|
|
id: index,
|
|
url: url,
|
|
isProcessing: false,
|
|
isUploading: false,
|
|
file: null // Store the actual file for upload
|
|
}));
|
|
|
|
// Add event listener to close active controls when clicking outside
|
|
document.addEventListener('click', closeActiveControls);
|
|
});
|
|
|
|
// Close active controls when component is unmounted
|
|
onUnmounted(() => {
|
|
document.removeEventListener('click', closeActiveControls);
|
|
});
|
|
|
|
// Function to handle mobile control visibility
|
|
function toggleMobileControls(index) {
|
|
// If clicking the same photo, toggle the controls
|
|
if (activePhotoIndex.value === index) {
|
|
activePhotoIndex.value = null;
|
|
} else {
|
|
// Otherwise, set this photo as active
|
|
activePhotoIndex.value = index;
|
|
}
|
|
}
|
|
|
|
// Close active controls when clicking outside
|
|
function closeActiveControls() {
|
|
activePhotoIndex.value = null;
|
|
}
|
|
|
|
// Trigger file input click
|
|
function triggerFileInput() {
|
|
fileInput.value.click();
|
|
}
|
|
|
|
// Add drop handler
|
|
function handleDrop(event) {
|
|
const files = Array.from(event.dataTransfer.files);
|
|
handleFiles(files);
|
|
}
|
|
|
|
// Extract file handling logic
|
|
function handleFiles(files) {
|
|
for (const file of files) {
|
|
if (file.type.startsWith('image/')) {
|
|
try {
|
|
const reader = new FileReader();
|
|
// Create a temporary image object with processing state
|
|
const tempImage = {
|
|
id: Date.now() + Math.random(),
|
|
url: '',
|
|
isProcessing: true,
|
|
isUploading: false,
|
|
file: file // Store the file for later upload
|
|
};
|
|
localImages.value.push(tempImage);
|
|
|
|
reader.onload = (e) => {
|
|
const index = localImages.value.findIndex(img => img.id === tempImage.id);
|
|
if (index !== -1) {
|
|
localImages.value[index] = {
|
|
...tempImage,
|
|
url: e.target.result,
|
|
isProcessing: false
|
|
};
|
|
emit('update:images', localImages.value.map(img => img.url));
|
|
}
|
|
};
|
|
reader.readAsDataURL(file);
|
|
} catch (error) {
|
|
console.error('Error processing image:', error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update file upload handler to use common function
|
|
function handleFileUpload(event) {
|
|
const files = Array.from(event.target.files);
|
|
handleFiles(files);
|
|
event.target.value = '';
|
|
}
|
|
|
|
// Delete an image
|
|
function deleteImage(index) {
|
|
localImages.value.splice(index, 1);
|
|
activePhotoIndex.value = null; // Reset active photo
|
|
emit('update:images', localImages.value.map(img => img.url));
|
|
}
|
|
|
|
// Handle reorder after drag and drop
|
|
function handleReorder() {
|
|
activePhotoIndex.value = null; // Reset active photo
|
|
emit('update:images', localImages.value.map(img => img.url));
|
|
}
|
|
|
|
// Add back the moveImage function
|
|
function moveImage(index, direction) {
|
|
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
|
if (newIndex >= 0 && newIndex < localImages.value.length) {
|
|
const temp = localImages.value[index];
|
|
localImages.value[index] = localImages.value[newIndex];
|
|
localImages.value[newIndex] = temp;
|
|
activePhotoIndex.value = newIndex; // Keep the moved image active
|
|
emit('update:images', localImages.value.map(img => img.url));
|
|
}
|
|
}
|
|
|
|
</script>
|
|
|
|
<style scoped>
|
|
.album-editor {
|
|
@apply w-full;
|
|
}
|
|
|
|
.drop-zone {
|
|
@apply w-full;
|
|
@apply min-h-[200px];
|
|
@apply border-2;
|
|
@apply border-dashed;
|
|
@apply border-gray-300 hover:border-gray-500;
|
|
@apply rounded-lg;
|
|
@apply p-4;
|
|
@apply relative;
|
|
@apply transition-colors;
|
|
@apply duration-200;
|
|
@apply overflow-visible;
|
|
@apply bg-hSurface;
|
|
}
|
|
|
|
.drop-zone-content {
|
|
@apply flex;
|
|
@apply flex-col;
|
|
@apply items-center;
|
|
@apply text-gray-500;
|
|
@apply mb-8;
|
|
@apply relative;
|
|
@apply z-10;
|
|
}
|
|
|
|
.photos-grid {
|
|
@apply grid;
|
|
@apply grid-cols-2;
|
|
@apply sm:grid-cols-3;
|
|
@apply md:grid-cols-4;
|
|
@apply lg:grid-cols-5;
|
|
@apply gap-4;
|
|
@apply w-full;
|
|
@apply pb-1;
|
|
}
|
|
|
|
.photo-wrapper {
|
|
@apply relative;
|
|
@apply aspect-square;
|
|
@apply rounded-lg;
|
|
@apply overflow-hidden;
|
|
@apply bg-gray-100;
|
|
@apply cursor-pointer;
|
|
}
|
|
|
|
.photo-wrapper img {
|
|
@apply w-full;
|
|
@apply h-full;
|
|
@apply object-cover;
|
|
}
|
|
|
|
.action-btn {
|
|
@apply absolute;
|
|
@apply bg-black;
|
|
@apply bg-opacity-50;
|
|
@apply text-white;
|
|
@apply rounded-full;
|
|
@apply p-1;
|
|
@apply flex;
|
|
@apply items-center;
|
|
@apply justify-center;
|
|
@apply transition-all;
|
|
@apply duration-200;
|
|
@apply opacity-0;
|
|
@apply z-10;
|
|
}
|
|
|
|
/* Show buttons on hover for desktop */
|
|
@media (hover: hover) {
|
|
.photo-wrapper:hover .action-btn {
|
|
@apply opacity-100;
|
|
}
|
|
}
|
|
|
|
/* For mobile, show buttons when photo is active */
|
|
.action-btn.mobile-active {
|
|
@apply opacity-100;
|
|
}
|
|
|
|
.action-btn:hover:not(:disabled) {
|
|
@apply bg-opacity-75;
|
|
@apply scale-110;
|
|
}
|
|
|
|
.action-btn:disabled {
|
|
@apply opacity-30;
|
|
@apply cursor-not-allowed;
|
|
@apply bg-gray-500;
|
|
@apply scale-90;
|
|
}
|
|
|
|
.left-btn {
|
|
@apply top-1/2;
|
|
@apply -translate-y-1/2;
|
|
@apply left-2;
|
|
}
|
|
|
|
.right-btn {
|
|
@apply top-1/2;
|
|
@apply -translate-y-1/2;
|
|
@apply right-2;
|
|
}
|
|
|
|
.delete-btn {
|
|
@apply top-2;
|
|
@apply right-2;
|
|
@apply bg-red-500;
|
|
@apply bg-opacity-50;
|
|
}
|
|
|
|
.delete-btn:hover {
|
|
@apply bg-opacity-75;
|
|
}
|
|
|
|
.index-bubble {
|
|
@apply absolute;
|
|
@apply top-2;
|
|
@apply left-2;
|
|
@apply bg-black;
|
|
@apply bg-opacity-50;
|
|
@apply text-white;
|
|
@apply text-xs;
|
|
@apply font-medium;
|
|
@apply rounded-full;
|
|
@apply w-6;
|
|
@apply h-6;
|
|
@apply flex;
|
|
@apply items-center;
|
|
@apply justify-center;
|
|
@apply z-10;
|
|
}
|
|
|
|
.loading-overlay {
|
|
@apply absolute;
|
|
@apply inset-0;
|
|
@apply flex;
|
|
@apply flex-col;
|
|
@apply items-center;
|
|
@apply justify-center;
|
|
@apply bg-black;
|
|
@apply bg-opacity-50;
|
|
@apply z-20;
|
|
}
|
|
|
|
.loading-overlay.uploading {
|
|
@apply bg-opacity-75;
|
|
}
|
|
</style>
|
|
|
|
<i18n>
|
|
{
|
|
"en": {
|
|
"title": "Album",
|
|
"dropzoneText": "Drop a photo here to add it to your album",
|
|
"processing": "Processing...",
|
|
"uploading": "Uploading...",
|
|
"moveLeft": "Move Left",
|
|
"moveRight": "Move Right",
|
|
"delete": "Delete"
|
|
},
|
|
"fr": {
|
|
"title": "Album",
|
|
"dropzoneText": "Déposez une photo ici pour l'ajouter à l'album",
|
|
"processing": "Traitement en cours...",
|
|
"uploading": "Téléchargement...",
|
|
"moveLeft": "Déplacer à gauche",
|
|
"moveRight": "Déplacer à droite",
|
|
"delete": "Supprimer"
|
|
},
|
|
"es": {
|
|
"title": "Album",
|
|
"dropzoneText": "Suelta una foto aquí para añadirla al álbum",
|
|
"processing": "Procesando...",
|
|
"uploading": "Subiendo...",
|
|
"moveLeft": "Mover a la izquierda",
|
|
"moveRight": "Mover a la derecha",
|
|
"delete": "Eliminar"
|
|
}
|
|
}
|
|
</i18n> |