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);
}
}