feat: Add validation for YouTube URL and enhance image upload experience in creator's album editor

This commit is contained in:
2025-04-24 03:20:08 -04:00
parent 7503f89e3f
commit c16dddb8dd
6 changed files with 483 additions and 142 deletions

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

View File

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

View 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}`;
}

View File

@@ -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"
} }
} }
} }

View File

@@ -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>

View File

@@ -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>