diff --git a/backend/src/Web/Common/YouTube/YouTubeUrlHelper.cs b/backend/src/Web/Common/YouTube/YouTubeUrlHelper.cs new file mode 100644 index 0000000..9e1d0be --- /dev/null +++ b/backend/src/Web/Common/YouTube/YouTubeUrlHelper.cs @@ -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); + + /// + /// Extracts the video ID from a YouTube URL or returns the input if it's already a video ID. + /// + /// The YouTube URL or video ID + /// The extracted video ID or null if invalid + 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; + } + + /// + /// Validates if the input is a valid YouTube video ID. + /// + /// The video ID to validate + /// True if the input is a valid video ID + public static bool IsValidVideoId(string? input) + { + if (string.IsNullOrWhiteSpace(input)) + return false; + + return ShortUrlRegex.IsMatch(input); + } + + /// + /// Validates if the input is a valid YouTube URL or video ID. + /// + /// The URL or video ID to validate + /// True if the input is a valid YouTube URL or video ID + 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); + } +} \ No newline at end of file diff --git a/backend/src/Web/Features/Contents/Handlers/ChangePresentationInfos.cs b/backend/src/Web/Features/Contents/Handlers/ChangePresentationInfos.cs index 4df5042..366236f 100644 --- a/backend/src/Web/Features/Contents/Handlers/ChangePresentationInfos.cs +++ b/backend/src/Web/Features/Contents/Handlers/ChangePresentationInfos.cs @@ -1,4 +1,4 @@ -using Hutopy.Web.Common.BlobStorage; +using Hutopy.Web.Common.YouTube; using Hutopy.Web.Features.Contents.Data; namespace Hutopy.Web.Features.Contents.Handlers; @@ -11,10 +11,36 @@ public record ChangePresentationInfosRequest( string? PhoneNumber, string? Email); +[PublicAPI] +public sealed class ChangePresentationInfosRequestValidator : Validator +{ + 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] public class ChangePresentationInfosHandler( - ContentDbContext context, - AzureBlobStorage blobStorage) + ContentDbContext context) : Endpoint { public override void Configure() @@ -42,11 +68,14 @@ public class ChangePresentationInfosHandler( // Update the presentation info with the new values 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.Email = request.Email?.Trim(); await context.SaveChangesAsync(ct); + await SendOkAsync(ct); } } diff --git a/frontend/src/utils/youtube.js b/frontend/src/utils/youtube.js new file mode 100644 index 0000000..51de3a1 --- /dev/null +++ b/frontend/src/utils/youtube.js @@ -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}`; +} \ No newline at end of file diff --git a/frontend/src/views/creators/AboutCreator.vue b/frontend/src/views/creators/AboutCreator.vue index bc3ae6a..9fd55a3 100644 --- a/frontend/src/views/creators/AboutCreator.vue +++ b/frontend/src/views/creators/AboutCreator.vue @@ -83,6 +83,7 @@ :label="t('creator.fields.videoUrl')" type="text" variant="outlined" + :error-messages="videoUrlError" /> @@ -105,12 +106,13 @@ @@ -142,99 +219,143 @@ function handleReorder() { @apply w-full; } -.image-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 0.5rem; - width: 100%; +.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; } -.image-wrapper { - position: relative; - width: 100%; - aspect-ratio: 1; - overflow: hidden; +.drop-zone-content { + @apply flex; + @apply flex-col; + @apply items-center; + @apply text-gray-500; + @apply mb-8; + @apply relative; + @apply z-10; } -.upload-wrapper { - border: 2px dashed #ccc; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.3s ease; +.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; } -.upload-wrapper:hover { - border-color: #666; - background-color: #f5f5f5; +.photo-wrapper { + @apply relative; + @apply aspect-square; + @apply rounded-lg; + @apply overflow-hidden; + @apply bg-gray-100; } -.upload-content { - display: flex; - flex-direction: column; - align-items: center; - 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; +.photo-wrapper img { + @apply w-full; + @apply h-full; + @apply object-cover; } .action-btn { - background: none; - border: none; - color: white; - cursor: pointer; - padding: 0.25rem; - border-radius: 4px; - transition: background-color 0.2s; + @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; } -.action-btn:hover { - background-color: rgba(255, 255, 255, 0.2); +.photo-wrapper:hover .action-btn { + @apply opacity-100; +} + +.action-btn:hover:not(:disabled) { + @apply bg-opacity-75; + @apply scale-110; } .action-btn:disabled { - opacity: 0.5; - cursor: not-allowed; + @apply opacity-30; + @apply cursor-not-allowed; + @apply bg-gray-500; + @apply scale-90; } -/* Responsive adjustments */ -@media (min-width: 768px) { - .image-grid { - grid-template-columns: repeat(4, 1fr); - } +.left-btn { + @apply top-1/2; + @apply -translate-y-1/2; + @apply left-2; } -@media (min-width: 1024px) { - .image-grid { - grid-template-columns: repeat(5, 1fr); - } +.right-btn { + @apply top-1/2; + @apply -translate-y-1/2; + @apply right-2; } -@media (max-width: 640px) { - .image-grid { - gap: 0.25rem; - } +.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; } @@ -243,20 +364,45 @@ function handleReorder() { "en": { "upload": "Upload Photos", "delete": "Delete", - "moveUp": "Move Up", - "moveDown": "Move Down" + "dropzoneText": "Click or drag photos here to upload", + "moveUp": "Move Left", + "moveDown": "Move Right", + "uploading": "Uploading...", + "creator": { + "sections": { + "album": { + "title": "Photos" + } + } + } }, "fr": { "upload": "Télécharger des photos", "delete": "Supprimer", - "moveUp": "Déplacer vers le haut", - "moveDown": "Déplacer vers le bas" + "dropzoneText": "Cliquez ou glissez-déposez les photos ici", + "moveUp": "Déplacer à gauche", + "moveDown": "Déplacer à droite", + "creator": { + "sections": { + "album": { + "title": "Photos" + } + } + } }, "es": { "upload": "Subir fotos", "delete": "Eliminar", - "moveUp": "Mover arriba", - "moveDown": "Mover abajo" + "dropzoneText": "Haga clic o arrastre las fotos aquí", + "moveUp": "Mover a la izquierda", + "moveDown": "Mover a la derecha", + "creator": { + "sections": { + "album": { + "title": "Fotos" + } + } + } } } \ No newline at end of file diff --git a/frontend/src/views/creators/AlbumView.vue b/frontend/src/views/creators/AlbumView.vue index 2eb4ad0..2b5d451 100644 --- a/frontend/src/views/creators/AlbumView.vue +++ b/frontend/src/views/creators/AlbumView.vue @@ -72,7 +72,6 @@ const displayedImages = computed(() => { .image-grid { display: grid; grid-template-columns: repeat(3, 1fr); - gap: 0.5rem; width: 100%; } @@ -103,7 +102,7 @@ const displayedImages = computed(() => { @media (max-width: 640px) { .image-grid { - gap: 0.25rem; + grid-template-columns: repeat(3, 1fr); } }