feat: Add validation for YouTube URL and enhance image upload experience in creator's album editor
This commit is contained in:
64
backend/src/Web/Common/YouTube/YouTubeUrlHelper.cs
Normal file
64
backend/src/Web/Common/YouTube/YouTubeUrlHelper.cs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Hutopy.Web.Common.YouTube;
|
||||||
|
|
||||||
|
public static class YouTubeUrlHelper
|
||||||
|
{
|
||||||
|
private static readonly Regex VideoIdRegex = new(
|
||||||
|
@"(?:youtube\.com/(?:[^/]+/.+/|(?:v|e(?:mbed)?)/|.*[?&]v=)|youtu\.be/)([^""&?/\s]{11})",
|
||||||
|
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
private static readonly Regex ShortUrlRegex = new(
|
||||||
|
@"^[a-zA-Z0-9_-]{11}$",
|
||||||
|
RegexOptions.Compiled);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the video ID from a YouTube URL or returns the input if it's already a video ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="input">The YouTube URL or video ID</param>
|
||||||
|
/// <returns>The extracted video ID or null if invalid</returns>
|
||||||
|
public static string? ExtractVideoId(string? input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// If it's already a valid video ID, return it
|
||||||
|
if (IsValidVideoId(input))
|
||||||
|
return input;
|
||||||
|
|
||||||
|
// Try to extract video ID from URL
|
||||||
|
var match = VideoIdRegex.Match(input);
|
||||||
|
return match.Success ? match.Groups[1].Value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates if the input is a valid YouTube video ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="input">The video ID to validate</param>
|
||||||
|
/// <returns>True if the input is a valid video ID</returns>
|
||||||
|
public static bool IsValidVideoId(string? input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return ShortUrlRegex.IsMatch(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates if the input is a valid YouTube URL or video ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="input">The URL or video ID to validate</param>
|
||||||
|
/// <returns>True if the input is a valid YouTube URL or video ID</returns>
|
||||||
|
public static bool IsValidYouTubeUrlOrId(string? input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Check if it's a valid video ID
|
||||||
|
if (IsValidVideoId(input))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Check if it's a valid YouTube URL
|
||||||
|
return VideoIdRegex.IsMatch(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using Hutopy.Web.Common.BlobStorage;
|
using Hutopy.Web.Common.YouTube;
|
||||||
using Hutopy.Web.Features.Contents.Data;
|
using Hutopy.Web.Features.Contents.Data;
|
||||||
|
|
||||||
namespace Hutopy.Web.Features.Contents.Handlers;
|
namespace Hutopy.Web.Features.Contents.Handlers;
|
||||||
@@ -11,10 +11,36 @@ public record ChangePresentationInfosRequest(
|
|||||||
string? PhoneNumber,
|
string? PhoneNumber,
|
||||||
string? Email);
|
string? Email);
|
||||||
|
|
||||||
|
[PublicAPI]
|
||||||
|
public sealed class ChangePresentationInfosRequestValidator : Validator<ChangePresentationInfosRequest>
|
||||||
|
{
|
||||||
|
public ChangePresentationInfosRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.CreatorId)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("Creator ID is required");
|
||||||
|
|
||||||
|
RuleFor(x => x.Description)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("Description is required");
|
||||||
|
|
||||||
|
RuleFor(x => x.VideoUrl)
|
||||||
|
.Must(url => url == null || YouTubeUrlHelper.IsValidYouTubeUrlOrId(url))
|
||||||
|
.WithMessage("Invalid YouTube URL or video ID format");
|
||||||
|
|
||||||
|
RuleFor(x => x.PhoneNumber)
|
||||||
|
.Must(phone => phone == null || !string.IsNullOrWhiteSpace(phone))
|
||||||
|
.WithMessage("Phone number cannot be empty if provided");
|
||||||
|
|
||||||
|
RuleFor(x => x.Email)
|
||||||
|
.Must(email => email == null || !string.IsNullOrWhiteSpace(email))
|
||||||
|
.WithMessage("Email cannot be empty if provided");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[PublicAPI]
|
[PublicAPI]
|
||||||
public class ChangePresentationInfosHandler(
|
public class ChangePresentationInfosHandler(
|
||||||
ContentDbContext context,
|
ContentDbContext context)
|
||||||
AzureBlobStorage blobStorage)
|
|
||||||
: Endpoint<ChangePresentationInfosRequest>
|
: Endpoint<ChangePresentationInfosRequest>
|
||||||
{
|
{
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
@@ -42,11 +68,14 @@ public class ChangePresentationInfosHandler(
|
|||||||
|
|
||||||
// Update the presentation info with the new values
|
// Update the presentation info with the new values
|
||||||
creator.Presentation.Description = request.Description.Trim();
|
creator.Presentation.Description = request.Description.Trim();
|
||||||
creator.Presentation.VideoUrl = request.VideoUrl?.Trim();
|
creator.Presentation.VideoUrl = request.VideoUrl != null
|
||||||
|
? YouTubeUrlHelper.ExtractVideoId(request.VideoUrl.Trim())
|
||||||
|
: null;
|
||||||
creator.Presentation.PhoneNumber = request.PhoneNumber?.Trim();
|
creator.Presentation.PhoneNumber = request.PhoneNumber?.Trim();
|
||||||
creator.Presentation.Email = request.Email?.Trim();
|
creator.Presentation.Email = request.Email?.Trim();
|
||||||
|
|
||||||
await context.SaveChangesAsync(ct);
|
await context.SaveChangesAsync(ct);
|
||||||
|
|
||||||
await SendOkAsync(ct);
|
await SendOkAsync(ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
64
frontend/src/utils/youtube.js
Normal file
64
frontend/src/utils/youtube.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Regular expression for matching YouTube video IDs in various URL formats
|
||||||
|
*/
|
||||||
|
const VIDEO_ID_REGEX = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regular expression for matching standalone YouTube video IDs
|
||||||
|
*/
|
||||||
|
const SHORT_URL_REGEX = /^[a-zA-Z0-9_-]{11}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the video ID from a YouTube URL or returns the input if it's already a video ID.
|
||||||
|
* @param {string} input - The YouTube URL or video ID
|
||||||
|
* @returns {string|null} The extracted video ID or null if invalid
|
||||||
|
*/
|
||||||
|
export function extractVideoId(input) {
|
||||||
|
if (!input) return null;
|
||||||
|
|
||||||
|
// If it's already a valid video ID, return it
|
||||||
|
if (isValidVideoId(input)) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract video ID from URL
|
||||||
|
const match = input.match(VIDEO_ID_REGEX);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if the input is a valid YouTube video ID.
|
||||||
|
* @param {string} input - The video ID to validate
|
||||||
|
* @returns {boolean} True if the input is a valid video ID
|
||||||
|
*/
|
||||||
|
export function isValidVideoId(input) {
|
||||||
|
if (!input) return false;
|
||||||
|
return SHORT_URL_REGEX.test(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if the input is a valid YouTube URL or video ID.
|
||||||
|
* @param {string} input - The URL or video ID to validate
|
||||||
|
* @returns {boolean} True if the input is a valid YouTube URL or video ID
|
||||||
|
*/
|
||||||
|
export function isValidYouTubeUrlOrId(input) {
|
||||||
|
if (!input) return false;
|
||||||
|
|
||||||
|
// Check if it's a valid video ID
|
||||||
|
if (isValidVideoId(input)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a valid YouTube URL
|
||||||
|
return VIDEO_ID_REGEX.test(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a YouTube embed URL from a video ID.
|
||||||
|
* @param {string} videoId - The YouTube video ID
|
||||||
|
* @returns {string} The YouTube embed URL
|
||||||
|
*/
|
||||||
|
export function buildEmbedUrl(videoId) {
|
||||||
|
if (!videoId) return '';
|
||||||
|
return `https://www.youtube.com/embed/${videoId}`;
|
||||||
|
}
|
||||||
@@ -83,6 +83,7 @@
|
|||||||
:label="t('creator.fields.videoUrl')"
|
:label="t('creator.fields.videoUrl')"
|
||||||
type="text"
|
type="text"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
:error-messages="videoUrlError"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,12 +106,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {onMounted, ref, computed} from "vue";
|
import {onMounted, ref, computed, watch} from "vue";
|
||||||
import {useClient} from "@/plugins/api.js";
|
import {useClient} from "@/plugins/api.js";
|
||||||
import {useBrandingStore} from "@/stores/brandingStore.js";
|
import {useBrandingStore} from "@/stores/brandingStore.js";
|
||||||
import {useCreatorProfileStore} from "@/stores/creatorProfileStore.js";
|
import {useCreatorProfileStore} from "@/stores/creatorProfileStore.js";
|
||||||
import {useI18n} from 'vue-i18n';
|
import {useI18n} from 'vue-i18n';
|
||||||
import CreatorAlbum from './CreatorAlbum.vue';
|
import CreatorAlbum from './CreatorAlbum.vue';
|
||||||
|
import {buildEmbedUrl, isValidYouTubeUrlOrId, extractVideoId} from '@/utils/youtube';
|
||||||
|
|
||||||
const {t} = useI18n();
|
const {t} = useI18n();
|
||||||
const creatorProfileStore = useCreatorProfileStore();
|
const creatorProfileStore = useCreatorProfileStore();
|
||||||
@@ -132,6 +134,7 @@ const originalPhotos = ref([]);
|
|||||||
// Editable fields
|
// Editable fields
|
||||||
const editableDescription = ref("");
|
const editableDescription = ref("");
|
||||||
const editableVideoUrl = ref("");
|
const editableVideoUrl = ref("");
|
||||||
|
const videoUrlError = ref("");
|
||||||
|
|
||||||
// Computed property to check if there are images
|
// Computed property to check if there are images
|
||||||
const hasImages = computed(() => {
|
const hasImages = computed(() => {
|
||||||
@@ -142,7 +145,28 @@ const hasImages = computed(() => {
|
|||||||
// Computed property for YouTube embed URL
|
// Computed property for YouTube embed URL
|
||||||
const youtubeEmbedUrl = computed(() => {
|
const youtubeEmbedUrl = computed(() => {
|
||||||
if (!videoUrl.value) return "";
|
if (!videoUrl.value) return "";
|
||||||
return `https://www.youtube.com/embed/${videoUrl.value}`;
|
return buildEmbedUrl(videoUrl.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate video URL
|
||||||
|
function validateVideoUrl(url) {
|
||||||
|
if (!url) {
|
||||||
|
videoUrlError.value = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidYouTubeUrlOrId(url)) {
|
||||||
|
videoUrlError.value = t('creator.validation.invalidYoutubeUrl');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
videoUrlError.value = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for changes in editableVideoUrl
|
||||||
|
watch(editableVideoUrl, (newValue) => {
|
||||||
|
validateVideoUrl(newValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Activer/désactiver le mode édition
|
// Activer/désactiver le mode édition
|
||||||
@@ -152,6 +176,7 @@ function toggleEditMode() {
|
|||||||
// Charger les valeurs pour l'édition
|
// Charger les valeurs pour l'édition
|
||||||
editableDescription.value = description.value;
|
editableDescription.value = description.value;
|
||||||
editableVideoUrl.value = videoUrl.value;
|
editableVideoUrl.value = videoUrl.value;
|
||||||
|
videoUrlError.value = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +231,11 @@ async function saveChanges() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate video URL before saving
|
||||||
|
if (!validateVideoUrl(editableVideoUrl.value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
|
|
||||||
@@ -220,7 +250,7 @@ async function saveChanges() {
|
|||||||
|
|
||||||
// Mettre à jour les valeurs locales pour refléter les changements
|
// Mettre à jour les valeurs locales pour refléter les changements
|
||||||
description.value = editableDescription.value;
|
description.value = editableDescription.value;
|
||||||
videoUrl.value = editableVideoUrl.value;
|
videoUrl.value = extractVideoId(editableVideoUrl.value) || "";
|
||||||
|
|
||||||
// Save album photos if they've changed
|
// Save album photos if they've changed
|
||||||
if (imageUrls.value.length > 0) {
|
if (imageUrls.value.length > 0) {
|
||||||
@@ -362,6 +392,9 @@ function cancelEdit() {
|
|||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"videoUrl": "Video URL"
|
"videoUrl": "Video URL"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"invalidYoutubeUrl": "Please enter a valid YouTube URL or video ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -382,6 +415,9 @@ function cancelEdit() {
|
|||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"videoUrl": "URL de la vidéo"
|
"videoUrl": "URL de la vidéo"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"invalidYoutubeUrl": "Veuillez entrer une URL YouTube ou un ID de vidéo valide"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -402,6 +438,9 @@ function cancelEdit() {
|
|||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"videoUrl": "URL del video"
|
"videoUrl": "URL del video"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"invalidYoutubeUrl": "Por favor, introduce una URL de YouTube o un ID de video válido"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,50 +2,68 @@
|
|||||||
<div class="album-editor">
|
<div class="album-editor">
|
||||||
<h2 class="text-xl font-semibold mb-4">{{ t('creator.sections.album.title') }}</h2>
|
<h2 class="text-xl font-semibold mb-4">{{ t('creator.sections.album.title') }}</h2>
|
||||||
|
|
||||||
<div class="image-grid">
|
<!-- Drop zone with photos -->
|
||||||
<!-- Upload button -->
|
<div class="drop-zone"
|
||||||
<div class="image-wrapper upload-wrapper" @click="triggerFileInput">
|
@dragover.prevent
|
||||||
<input
|
@drop.prevent="handleDrop"
|
||||||
type="file"
|
@click="triggerFileInput">
|
||||||
ref="fileInput"
|
<!-- Upload prompt -->
|
||||||
@change="handleFileUpload"
|
<div class="drop-zone-content">
|
||||||
accept="image/*"
|
<v-icon size="large">mdi-plus</v-icon>
|
||||||
multiple
|
<span class="text-sm mt-2">{{ t('dropzoneText') }}</span>
|
||||||
class="hidden"
|
|
||||||
/>
|
|
||||||
<div class="upload-content">
|
|
||||||
<v-icon size="large">mdi-plus</v-icon>
|
|
||||||
<span class="text-sm mt-2">{{ t('upload') }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Draggable images -->
|
<!-- Hidden file input -->
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref="fileInput"
|
||||||
|
@change="handleFileUpload"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
class="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Photos grid -->
|
||||||
<draggable
|
<draggable
|
||||||
v-model="localImages"
|
v-model="localImages"
|
||||||
class="image-grid"
|
class="photos-grid"
|
||||||
item-key="id"
|
item-key="id"
|
||||||
@end="handleReorder"
|
@end="handleReorder"
|
||||||
>
|
>
|
||||||
<template #item="{ element, index }">
|
<template #item="{ element, index }">
|
||||||
<div class="image-wrapper">
|
<div class="photo-wrapper">
|
||||||
|
<div class="index-bubble">{{ index + 1 }}</div>
|
||||||
<img :src="element.url" :alt="'Image ' + (index + 1)" />
|
<img :src="element.url" :alt="'Image ' + (index + 1)" />
|
||||||
<div class="image-actions">
|
<!-- Processing spinner overlay -->
|
||||||
<button @click="deleteImage(index)" class="action-btn delete-btn" :title="t('delete')">
|
<div v-if="element.isProcessing" class="loading-overlay">
|
||||||
<v-icon>mdi-delete</v-icon>
|
<v-progress-circular indeterminate color="primary"></v-progress-circular>
|
||||||
</button>
|
<span class="text-white text-sm mt-2">{{ t('processing') }}</span>
|
||||||
<button @click="moveImage(index, 'up')"
|
|
||||||
class="action-btn move-btn"
|
|
||||||
:disabled="index === 0"
|
|
||||||
:title="t('moveUp')">
|
|
||||||
<v-icon>mdi-arrow-up</v-icon>
|
|
||||||
</button>
|
|
||||||
<button @click="moveImage(index, 'down')"
|
|
||||||
class="action-btn move-btn"
|
|
||||||
:disabled="index === localImages.length - 1"
|
|
||||||
:title="t('moveDown')">
|
|
||||||
<v-icon>mdi-arrow-down</v-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
</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('moveUp')">
|
||||||
|
<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('moveDown')">
|
||||||
|
<v-icon>mdi-arrow-right</v-icon>
|
||||||
|
</button>
|
||||||
|
<!-- Delete button -->
|
||||||
|
<button @click.stop="deleteImage(index)"
|
||||||
|
class="action-btn delete-btn"
|
||||||
|
:title="t('delete')">
|
||||||
|
<v-icon>mdi-delete</v-icon>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
@@ -69,15 +87,16 @@ const emit = defineEmits(['update:images']);
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const fileInput = ref(null);
|
const fileInput = ref(null);
|
||||||
|
|
||||||
// Local copy of images with IDs for drag and drop
|
|
||||||
const localImages = ref([]);
|
const localImages = ref([]);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Initialize local images with IDs
|
// Initialize local images with IDs and states
|
||||||
localImages.value = props.images.map((url, index) => ({
|
localImages.value = props.images.map((url, index) => ({
|
||||||
id: index,
|
id: index,
|
||||||
url: url
|
url: url,
|
||||||
|
isProcessing: false,
|
||||||
|
isUploading: false,
|
||||||
|
file: null // Store the actual file for upload
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,31 +105,51 @@ function triggerFileInput() {
|
|||||||
fileInput.value.click();
|
fileInput.value.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle file upload
|
// Add drop handler
|
||||||
async function handleFileUpload(event) {
|
function handleDrop(event) {
|
||||||
const files = Array.from(event.target.files);
|
const files = Array.from(event.dataTransfer.files);
|
||||||
|
handleFiles(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract file handling logic
|
||||||
|
function handleFiles(files) {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.type.startsWith('image/')) {
|
if (file.type.startsWith('image/')) {
|
||||||
try {
|
try {
|
||||||
// Create a data URL for preview
|
|
||||||
const reader = new FileReader();
|
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) => {
|
reader.onload = (e) => {
|
||||||
const newImage = {
|
const index = localImages.value.findIndex(img => img.id === tempImage.id);
|
||||||
id: Date.now() + Math.random(), // Unique ID
|
if (index !== -1) {
|
||||||
url: e.target.result
|
localImages.value[index] = {
|
||||||
};
|
...tempImage,
|
||||||
localImages.value.push(newImage);
|
url: e.target.result,
|
||||||
emit('update:images', localImages.value.map(img => img.url));
|
isProcessing: false
|
||||||
|
};
|
||||||
|
emit('update:images', localImages.value.map(img => img.url));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading image:', error);
|
console.error('Error processing image:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Reset file input
|
|
||||||
|
// Update file upload handler to use common function
|
||||||
|
function handleFileUpload(event) {
|
||||||
|
const files = Array.from(event.target.files);
|
||||||
|
handleFiles(files);
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +159,12 @@ function deleteImage(index) {
|
|||||||
emit('update:images', localImages.value.map(img => img.url));
|
emit('update:images', localImages.value.map(img => img.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move image up or down
|
// Handle reorder after drag and drop
|
||||||
|
function handleReorder() {
|
||||||
|
emit('update:images', localImages.value.map(img => img.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add back the moveImage function
|
||||||
function moveImage(index, direction) {
|
function moveImage(index, direction) {
|
||||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
||||||
if (newIndex >= 0 && newIndex < localImages.value.length) {
|
if (newIndex >= 0 && newIndex < localImages.value.length) {
|
||||||
@@ -131,9 +175,42 @@ function moveImage(index, direction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle reorder after drag and drop
|
// Upload images to API
|
||||||
function handleReorder() {
|
async function uploadImages() {
|
||||||
emit('update:images', localImages.value.map(img => img.url));
|
try {
|
||||||
|
// Mark all images as uploading
|
||||||
|
localImages.value = localImages.value.map(img => ({
|
||||||
|
...img,
|
||||||
|
isUploading: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Here you would implement your API upload logic
|
||||||
|
// For each image that has a file property:
|
||||||
|
for (const image of localImages.value) {
|
||||||
|
if (image.file) {
|
||||||
|
// Example API upload (replace with your actual API call)
|
||||||
|
// await uploadImageToAPI(image.file);
|
||||||
|
|
||||||
|
// For demo, simulate API delay
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once all uploads are complete, update states
|
||||||
|
localImages.value = localImages.value.map(img => ({
|
||||||
|
...img,
|
||||||
|
isUploading: false,
|
||||||
|
file: null // Clear the file after upload
|
||||||
|
}));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading images:', error);
|
||||||
|
// Reset upload state on error
|
||||||
|
localImages.value = localImages.value.map(img => ({
|
||||||
|
...img,
|
||||||
|
isUploading: false
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -142,99 +219,143 @@ function handleReorder() {
|
|||||||
@apply w-full;
|
@apply w-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-grid {
|
.drop-zone {
|
||||||
display: grid;
|
@apply w-full;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
@apply min-h-[200px];
|
||||||
gap: 0.5rem;
|
@apply border-2;
|
||||||
width: 100%;
|
@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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-wrapper {
|
.drop-zone-content {
|
||||||
position: relative;
|
@apply flex;
|
||||||
width: 100%;
|
@apply flex-col;
|
||||||
aspect-ratio: 1;
|
@apply items-center;
|
||||||
overflow: hidden;
|
@apply text-gray-500;
|
||||||
|
@apply mb-8;
|
||||||
|
@apply relative;
|
||||||
|
@apply z-10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-wrapper {
|
.photos-grid {
|
||||||
border: 2px dashed #ccc;
|
@apply grid;
|
||||||
display: flex;
|
@apply grid-cols-2;
|
||||||
align-items: center;
|
@apply sm:grid-cols-3;
|
||||||
justify-content: center;
|
@apply md:grid-cols-4;
|
||||||
cursor: pointer;
|
@apply lg:grid-cols-5;
|
||||||
transition: all 0.3s ease;
|
@apply gap-4;
|
||||||
|
@apply w-full;
|
||||||
|
@apply pb-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-wrapper:hover {
|
.photo-wrapper {
|
||||||
border-color: #666;
|
@apply relative;
|
||||||
background-color: #f5f5f5;
|
@apply aspect-square;
|
||||||
|
@apply rounded-lg;
|
||||||
|
@apply overflow-hidden;
|
||||||
|
@apply bg-gray-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-content {
|
.photo-wrapper img {
|
||||||
display: flex;
|
@apply w-full;
|
||||||
flex-direction: column;
|
@apply h-full;
|
||||||
align-items: center;
|
@apply object-cover;
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-actions {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
display: flex;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.25rem;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-wrapper:hover .image-actions {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
background: none;
|
@apply absolute;
|
||||||
border: none;
|
@apply bg-black;
|
||||||
color: white;
|
@apply bg-opacity-50;
|
||||||
cursor: pointer;
|
@apply text-white;
|
||||||
padding: 0.25rem;
|
@apply rounded-full;
|
||||||
border-radius: 4px;
|
@apply p-1;
|
||||||
transition: background-color 0.2s;
|
@apply flex;
|
||||||
|
@apply items-center;
|
||||||
|
@apply justify-center;
|
||||||
|
@apply transition-all;
|
||||||
|
@apply duration-200;
|
||||||
|
@apply opacity-0;
|
||||||
|
@apply z-10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn:hover {
|
.photo-wrapper:hover .action-btn {
|
||||||
background-color: rgba(255, 255, 255, 0.2);
|
@apply opacity-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover:not(:disabled) {
|
||||||
|
@apply bg-opacity-75;
|
||||||
|
@apply scale-110;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn:disabled {
|
.action-btn:disabled {
|
||||||
opacity: 0.5;
|
@apply opacity-30;
|
||||||
cursor: not-allowed;
|
@apply cursor-not-allowed;
|
||||||
|
@apply bg-gray-500;
|
||||||
|
@apply scale-90;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive adjustments */
|
.left-btn {
|
||||||
@media (min-width: 768px) {
|
@apply top-1/2;
|
||||||
.image-grid {
|
@apply -translate-y-1/2;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
@apply left-2;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
.right-btn {
|
||||||
.image-grid {
|
@apply top-1/2;
|
||||||
grid-template-columns: repeat(5, 1fr);
|
@apply -translate-y-1/2;
|
||||||
}
|
@apply right-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
.delete-btn {
|
||||||
.image-grid {
|
@apply top-2;
|
||||||
gap: 0.25rem;
|
@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>
|
</style>
|
||||||
|
|
||||||
@@ -243,20 +364,45 @@ function handleReorder() {
|
|||||||
"en": {
|
"en": {
|
||||||
"upload": "Upload Photos",
|
"upload": "Upload Photos",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"moveUp": "Move Up",
|
"dropzoneText": "Click or drag photos here to upload",
|
||||||
"moveDown": "Move Down"
|
"moveUp": "Move Left",
|
||||||
|
"moveDown": "Move Right",
|
||||||
|
"uploading": "Uploading...",
|
||||||
|
"creator": {
|
||||||
|
"sections": {
|
||||||
|
"album": {
|
||||||
|
"title": "Photos"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"fr": {
|
"fr": {
|
||||||
"upload": "Télécharger des photos",
|
"upload": "Télécharger des photos",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"moveUp": "Déplacer vers le haut",
|
"dropzoneText": "Cliquez ou glissez-déposez les photos ici",
|
||||||
"moveDown": "Déplacer vers le bas"
|
"moveUp": "Déplacer à gauche",
|
||||||
|
"moveDown": "Déplacer à droite",
|
||||||
|
"creator": {
|
||||||
|
"sections": {
|
||||||
|
"album": {
|
||||||
|
"title": "Photos"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"es": {
|
"es": {
|
||||||
"upload": "Subir fotos",
|
"upload": "Subir fotos",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"moveUp": "Mover arriba",
|
"dropzoneText": "Haga clic o arrastre las fotos aquí",
|
||||||
"moveDown": "Mover abajo"
|
"moveUp": "Mover a la izquierda",
|
||||||
|
"moveDown": "Mover a la derecha",
|
||||||
|
"creator": {
|
||||||
|
"sections": {
|
||||||
|
"album": {
|
||||||
|
"title": "Fotos"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</i18n>
|
</i18n>
|
||||||
@@ -72,7 +72,6 @@ const displayedImages = computed(() => {
|
|||||||
.image-grid {
|
.image-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 0.5rem;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +102,7 @@ const displayedImages = computed(() => {
|
|||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.image-grid {
|
.image-grid {
|
||||||
gap: 0.25rem;
|
grid-template-columns: repeat(3, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user