feat(album): add thumbnails and AlbumViewer.vue
This commit is contained in:
@@ -10,9 +10,9 @@
|
||||
<!-- Edit button with pencil icon -->
|
||||
<button
|
||||
v-if="!isEditMode"
|
||||
:title="t('edit')"
|
||||
class="w-12 h-12 bg-hutopyPrimary rounded-full flex items-center justify-center shadow-lg"
|
||||
@click="toggleEditMode()"
|
||||
:title="t('edit')"
|
||||
>
|
||||
<v-icon large>mdi-pencil</v-icon>
|
||||
</button>
|
||||
@@ -20,10 +20,10 @@
|
||||
<!-- Save button -->
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
:disabled="!canSave"
|
||||
:title="t('save')"
|
||||
class="w-12 h-12 bg-hutopyPrimary rounded-full flex items-center justify-center shadow-lg"
|
||||
@click="saveChanges()"
|
||||
:title="t('save')"
|
||||
:disabled="!canSave"
|
||||
>
|
||||
<v-icon large>mdi-check</v-icon>
|
||||
</button>
|
||||
@@ -31,9 +31,9 @@
|
||||
<!-- Cancel button -->
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
:title="t('cancel')"
|
||||
class="w-12 h-12 bg-red-500 rounded-full flex items-center justify-center shadow-lg"
|
||||
@click="cancelEdit"
|
||||
:title="t('cancel')"
|
||||
>
|
||||
<v-icon large>mdi-close</v-icon>
|
||||
</button>
|
||||
@@ -56,15 +56,15 @@
|
||||
</div>
|
||||
<v-textarea v-if="isEditMode"
|
||||
v-model="editableDescription"
|
||||
class="w-full p-2 py-6"
|
||||
:label="t('creator.sections.about.description')"
|
||||
:error-messages="descriptionError"
|
||||
:counter="2000"
|
||||
:error-messages="descriptionError"
|
||||
:label="t('creator.sections.about.description')"
|
||||
:rules="[
|
||||
v => !!v || t('creator.validation.descriptionRequired'),
|
||||
v => v.length <= 2000 || t('creator.validation.descriptionTooLong')
|
||||
]"
|
||||
auto-grow
|
||||
class="w-full p-2 py-6"
|
||||
rows="5"
|
||||
variant="outlined"></v-textarea>
|
||||
</div>
|
||||
@@ -88,11 +88,11 @@
|
||||
<div v-if="isEditMode">
|
||||
<v-text-field
|
||||
v-model="editableVideoUrl"
|
||||
class="w-full p-2"
|
||||
:error-messages="videoUrlError"
|
||||
:label="t('creator.fields.videoUrl')"
|
||||
class="w-full p-2"
|
||||
type="text"
|
||||
variant="outlined"
|
||||
:error-messages="videoUrlError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,18 +101,23 @@
|
||||
<div>
|
||||
<!-- Use AlbumView for display mode -->
|
||||
<AlbumView v-if="!isEditMode && hasImages"
|
||||
:images="imageUrls"
|
||||
:class="['content-section', {
|
||||
'rounded-b-xl': videoUrl && !isEditMode,
|
||||
'rounded-xl': !videoUrl && !isEditMode
|
||||
}]"/>
|
||||
}]"
|
||||
:images="thumbnailUrls"
|
||||
@photo-click="handlePhotoClick"/>
|
||||
|
||||
<AlbumViewer v-model="showAlbumViewer"
|
||||
:images="originalUrls"
|
||||
:start-index="selectedPhotoIndex"/>
|
||||
|
||||
<!-- Use AlbumEditor for edit mode -->
|
||||
<AlbumEditor v-if="isEditMode"
|
||||
:images="imageUrls"
|
||||
:images="thumbnailUrls"
|
||||
@update:images="updateImages"/>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Contact Information Section -->
|
||||
<div v-if="phoneNumber || email" class="contact-info mt-6">
|
||||
<!-- Phone Number -->
|
||||
@@ -136,10 +141,11 @@ import {useClient} from "@/plugins/api.js";
|
||||
import {useBrandingStore} from "@/stores/brandingStore.js";
|
||||
import {useCreatorProfileStore} from "@/stores/creatorProfileStore.js";
|
||||
import {useI18n} from 'vue-i18n';
|
||||
import Album from './Album.vue';
|
||||
import {buildEmbedUrl, isValidYouTubeUrlOrId, extractVideoId} from '@/utils/youtube';
|
||||
import AlbumEditor from "@/views/creators/AlbumEditor.vue";
|
||||
import AlbumView from "@/views/creators/AlbumView.vue";
|
||||
// Add these imports at the top with your other imports
|
||||
import AlbumViewer from './AlbumViewer.vue';
|
||||
|
||||
const {t} = useI18n();
|
||||
const creatorProfileStore = useCreatorProfileStore();
|
||||
@@ -156,9 +162,12 @@ const description = ref("");
|
||||
const videoUrl = ref("");
|
||||
const phoneNumber = ref("");
|
||||
const email = ref("");
|
||||
const imageUrls = ref([]);
|
||||
const thumbnailUrls = ref([]);
|
||||
const albumId = ref(null);
|
||||
const originalPhotos = ref([]);
|
||||
// Add these refs with your other refs
|
||||
const showAlbumViewer = ref(false);
|
||||
const selectedPhotoIndex = ref(0);
|
||||
|
||||
// Editable fields
|
||||
const editableDescription = ref("");
|
||||
@@ -189,7 +198,7 @@ const canSave = computed(() => {
|
||||
// Computed property to check if there are images
|
||||
const hasImages = computed(() => {
|
||||
// Only consider it has images if there are actual image URLs (not empty strings)
|
||||
return imageUrls.value.length > 0 && imageUrls.value.some(img => img && img.trim() !== "");
|
||||
return thumbnailUrls.value.length > 0 && thumbnailUrls.value.some(img => img && img.trim() !== "");
|
||||
});
|
||||
|
||||
// Computed property for YouTube embed URL
|
||||
@@ -244,17 +253,17 @@ async function fetchAlbumData() {
|
||||
// Store original photos for comparison
|
||||
originalPhotos.value = response.data.photos;
|
||||
// Extract photo URLs from the album photos
|
||||
imageUrls.value = response.data.photos.map(photo => photo.photoUrl);
|
||||
thumbnailUrls.value = response.data.photos.map(photo => photo.thumbnailUrl);
|
||||
} else {
|
||||
// Initialize with empty array instead of empty slots
|
||||
imageUrls.value = [];
|
||||
thumbnailUrls.value = [];
|
||||
originalPhotos.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
// Album might not exist yet, which is fine
|
||||
console.log("Album might not exist yet:", error);
|
||||
// Initialize with empty array instead of empty slots
|
||||
imageUrls.value = [];
|
||||
thumbnailUrls.value = [];
|
||||
originalPhotos.value = [];
|
||||
}
|
||||
}
|
||||
@@ -274,7 +283,7 @@ onMounted(async () => {
|
||||
|
||||
// Update images from Album component
|
||||
function updateImages(newImages) {
|
||||
imageUrls.value = newImages;
|
||||
thumbnailUrls.value = newImages;
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
@@ -317,7 +326,7 @@ async function saveChanges() {
|
||||
videoUrl.value = extractVideoId(editableVideoUrl.value) || "";
|
||||
|
||||
// Save album photos if they've changed
|
||||
if (imageUrls.value.length > 0) {
|
||||
if (thumbnailUrls.value.length > 0) {
|
||||
// Create or update the album
|
||||
const albumId = brandingStore.value.id;
|
||||
|
||||
@@ -336,7 +345,7 @@ async function saveChanges() {
|
||||
// Check for deleted photos
|
||||
const deletedPhotos = originalPhotos.value.filter(originalPhoto => {
|
||||
// If the photo URL is not in the current images array, it was deleted
|
||||
return !imageUrls.value.includes(originalPhoto.photoUrl);
|
||||
return !thumbnailUrls.value.includes(originalPhoto.thumbnailUrl);
|
||||
});
|
||||
|
||||
// Delete removed photos
|
||||
@@ -349,8 +358,8 @@ async function saveChanges() {
|
||||
}
|
||||
|
||||
// Now add or update photos
|
||||
for (let i = 0; i < imageUrls.value.length; i++) {
|
||||
const imageUrl = imageUrls.value[i];
|
||||
for (let i = 0; i < thumbnailUrls.value.length; i++) {
|
||||
const imageUrl = thumbnailUrls.value[i];
|
||||
if (imageUrl && imageUrl.startsWith('data:')) {
|
||||
// This is a new image that needs to be uploaded
|
||||
const photoId = crypto.randomUUID();
|
||||
@@ -396,6 +405,16 @@ function cancelEdit() {
|
||||
isEditMode.value = false;
|
||||
}
|
||||
|
||||
// Add this computed property to get the original image URLs
|
||||
const originalUrls = computed(() => {
|
||||
return originalPhotos.value.map(photo => photo.originalUrl);
|
||||
});
|
||||
|
||||
// Add this function to handle photo clicks
|
||||
function handlePhotoClick(index) {
|
||||
selectedPhotoIndex.value = index;
|
||||
showAlbumViewer.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -547,4 +566,4 @@ function cancelEdit() {
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
</i18n>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="album-editor">
|
||||
|
||||
<h2 class="text-xl font-semibold mb-4">
|
||||
{{ t('creator.sections.album.title') }}
|
||||
{{ t('title') }}
|
||||
</h2>
|
||||
|
||||
<!-- Drop zone with photos -->
|
||||
@@ -52,7 +52,7 @@
|
||||
<button @click.stop="moveImage(index, 'up')"
|
||||
class="action-btn left-btn"
|
||||
:disabled="index === 0"
|
||||
:title="t('moveUp')"
|
||||
:title="t('moveLeft')"
|
||||
:class="{'mobile-active': activePhotoIndex === index}">
|
||||
<v-icon>mdi-arrow-left</v-icon>
|
||||
</button>
|
||||
@@ -60,7 +60,7 @@
|
||||
<button @click.stop="moveImage(index, 'down')"
|
||||
class="action-btn right-btn"
|
||||
:disabled="index === localImages.length - 1"
|
||||
:title="t('moveDown')"
|
||||
:title="t('moveRight')"
|
||||
:class="{'mobile-active': activePhotoIndex === index}">
|
||||
<v-icon>mdi-arrow-right</v-icon>
|
||||
</button>
|
||||
@@ -365,4 +365,36 @@ function moveImage(index, direction) {
|
||||
.loading-overlay.uploading {
|
||||
@apply bg-opacity-75;
|
||||
}
|
||||
</style>
|
||||
</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>
|
||||
@@ -4,7 +4,8 @@
|
||||
<div class="image-grid">
|
||||
<div v-for="(url, index) in displayedImages"
|
||||
:key="index"
|
||||
class="image-wrapper">
|
||||
class="image-wrapper"
|
||||
@click="$emit('photo-click', index)">
|
||||
<img :src="url"
|
||||
:alt="t('creator.sections.album.image')"
|
||||
class="image"/>
|
||||
@@ -14,6 +15,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Add 'photo-click' to emits
|
||||
const emit = defineEmits(['photo-click']);
|
||||
|
||||
import { computed, ref, onMounted, onUnmounted } from "vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
@@ -131,4 +135,4 @@ const gridColumns = computed(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
</i18n>
|
||||
195
frontend/src/views/creators/AlbumViewer.vue
Normal file
195
frontend/src/views/creators/AlbumViewer.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
v-model="dialog"
|
||||
fullscreen
|
||||
:scrim="true"
|
||||
transition="dialog-bottom-transition"
|
||||
@click:outside="closeViewer"
|
||||
>
|
||||
<div class="album-viewer" @click.self="closeViewer">
|
||||
<!-- Main image container -->
|
||||
<div class="image-container">
|
||||
<img
|
||||
:src="currentImage"
|
||||
:alt="t('viewer.imageAlt', { index: currentIndex + 1 })"
|
||||
class="main-image"
|
||||
/>
|
||||
|
||||
<!-- Navigation buttons -->
|
||||
<button
|
||||
class="nav-btn left-btn"
|
||||
@click.stop="previousImage"
|
||||
:disabled="currentIndex === 0"
|
||||
:title="t('viewer.previous')"
|
||||
>
|
||||
<v-icon size="large" color="white">mdi-chevron-left</v-icon>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="nav-btn right-btn"
|
||||
@click.stop="nextImage"
|
||||
:disabled="currentIndex === images.length - 1"
|
||||
:title="t('viewer.next')"
|
||||
>
|
||||
<v-icon size="large" color="white">mdi-chevron-right</v-icon>
|
||||
</button>
|
||||
|
||||
<!-- Close button -->
|
||||
<button
|
||||
class="close-btn"
|
||||
@click.stop="closeViewer"
|
||||
:title="t('viewer.close')"
|
||||
>
|
||||
<v-icon size="large" color="white">mdi-close</v-icon>
|
||||
</button>
|
||||
|
||||
<!-- Image counter -->
|
||||
<div class="image-counter">
|
||||
{{ currentIndex + 1 }} / {{ images.length }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
images: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
startIndex: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const dialog = ref(false);
|
||||
const currentIndex = ref(0);
|
||||
|
||||
const currentImage = computed(() => props.images[currentIndex.value]);
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
dialog.value = newVal;
|
||||
if (newVal) {
|
||||
currentIndex.value = props.startIndex;
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => dialog.value, (newVal) => {
|
||||
emit('update:modelValue', newVal);
|
||||
});
|
||||
|
||||
function nextImage() {
|
||||
if (currentIndex.value < props.images.length - 1) {
|
||||
currentIndex.value++;
|
||||
}
|
||||
}
|
||||
|
||||
function previousImage() {
|
||||
if (currentIndex.value > 0) {
|
||||
currentIndex.value--;
|
||||
}
|
||||
}
|
||||
|
||||
function closeViewer() {
|
||||
dialog.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.album-viewer {
|
||||
@apply fixed inset-0;
|
||||
@apply flex items-center justify-center;
|
||||
@apply bg-black bg-opacity-90;
|
||||
@apply z-50;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
@apply relative;
|
||||
@apply max-w-[90vw];
|
||||
@apply max-h-[90vh];
|
||||
}
|
||||
|
||||
.main-image {
|
||||
@apply max-w-full;
|
||||
@apply max-h-[90vh];
|
||||
@apply object-contain;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
@apply absolute top-1/2 -translate-y-1/2;
|
||||
@apply p-4;
|
||||
@apply rounded-full;
|
||||
@apply bg-black bg-opacity-50;
|
||||
@apply transition-all duration-200;
|
||||
@apply hover:bg-opacity-75;
|
||||
@apply disabled:opacity-30 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.left-btn {
|
||||
@apply left-4;
|
||||
}
|
||||
|
||||
.right-btn {
|
||||
@apply right-4;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
@apply absolute top-4 right-4;
|
||||
@apply p-2;
|
||||
@apply rounded-full;
|
||||
@apply bg-black bg-opacity-50;
|
||||
@apply transition-all duration-200;
|
||||
@apply hover:bg-opacity-75;
|
||||
}
|
||||
|
||||
.image-counter {
|
||||
@apply absolute bottom-4 left-1/2 -translate-x-1/2;
|
||||
@apply px-4 py-2;
|
||||
@apply bg-black bg-opacity-50;
|
||||
@apply text-white;
|
||||
@apply rounded-full;
|
||||
@apply text-sm;
|
||||
}
|
||||
</style>
|
||||
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"viewer": {
|
||||
"previous": "Previous image",
|
||||
"next": "Next image",
|
||||
"close": "Close viewer",
|
||||
"imageAlt": "Image {index}"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"viewer": {
|
||||
"previous": "Image précédente",
|
||||
"next": "Image suivante",
|
||||
"close": "Fermer",
|
||||
"imageAlt": "Image {index}"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"viewer": {
|
||||
"previous": "Imagen anterior",
|
||||
"next": "Imagen siguiente",
|
||||
"close": "Cerrar",
|
||||
"imageAlt": "Imagen {index}"
|
||||
}
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
Reference in New Issue
Block a user