From 9a75f1c9294c3fa9dfb1fc652b52f58f78b3ef26 Mon Sep 17 00:00:00 2001 From: paul Date: Fri, 28 Nov 2025 13:29:44 +0100 Subject: [PATCH] Add video support, media filters, and translations --- backend/src/routes/adminPhotos.js | 241 +++++--- backend/src/routes/gallery.js | 120 +++- backend/src/services/fileWatcher.js | 25 +- backend/src/services/imageProcessor.js | 52 +- backend/src/services/photoProcessor.js | 43 +- backend/src/utils/fileSecurityUtils.js | 40 +- frontend/package-lock.json | 572 +++++++++++++++++- .../admin/AdminAuthenticatedVideo.tsx | 77 +++ .../src/components/admin/AdminPhotoGrid.tsx | 28 +- .../src/components/admin/AdminPhotoViewer.tsx | 46 +- .../src/components/admin/PhotoFilters.tsx | 56 +- frontend/src/components/admin/PhotoUpload.tsx | 36 +- .../src/components/admin/PhotoUploadModal.tsx | 4 +- frontend/src/components/admin/index.ts | 3 +- .../components/common/AuthenticatedVideo.tsx | 125 ++++ frontend/src/components/common/index.ts | 3 +- .../src/components/gallery/GallerySidebar.tsx | 55 +- .../src/components/gallery/GalleryView.tsx | 84 ++- .../src/components/gallery/PhotoFilterBar.tsx | 44 +- .../src/components/gallery/PhotoLightbox.tsx | 171 +++--- .../gallery/layouts/GridGalleryLayout.tsx | 15 +- frontend/src/i18n/locales/de.json | 22 +- frontend/src/i18n/locales/en.json | 22 +- frontend/src/pages/admin/EventDetailsPage.tsx | 28 +- frontend/src/services/photos.service.ts | 8 +- frontend/src/types/index.ts | 8 +- video-test-data/bear-320x240.mp4 | 1 + video-test-data/bear-320x240.webm | Bin 0 -> 219229 bytes 28 files changed, 1635 insertions(+), 294 deletions(-) create mode 100644 frontend/src/components/admin/AdminAuthenticatedVideo.tsx create mode 100644 frontend/src/components/common/AuthenticatedVideo.tsx create mode 100644 video-test-data/bear-320x240.mp4 create mode 100644 video-test-data/bear-320x240.webm diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index 9c94122..d767955 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -4,12 +4,14 @@ const path = require('path'); const fs = require('fs').promises; const { db, logActivity } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); -const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor'); +const { generateThumbnail, ensureThumbnail, generateVideoPlaceholder } = require('../services/imageProcessor'); const { generatePhotoFilename } = require('../utils/filenameSanitizer'); const { escapeLikePattern } = require('../utils/sqlSecurity'); const { validateUploadedFiles } = require('../middleware/uploadValidation'); const { getMaxFilesPerUpload } = require('../services/uploadSettings'); const router = express.Router(); +const { isVideoMimeType, validateFileType, createFileUploadValidator } = require('../utils/fileSecurityUtils'); +const mime = require('mime-types'); // Get storage path from environment or default const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); @@ -61,8 +63,6 @@ const storage = multer.diskStorage({ } }); -const { validateFileType } = require('../utils/fileSecurityUtils'); - const upload = multer({ storage: storage, limits: { @@ -75,24 +75,22 @@ const upload = multer({ headerPairs: 2000 // Maximum number of header key-value pairs }, fileFilter: (req, file, cb) => { - // Accept images only with proper validation - const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp']; + // Accept images and common video formats with proper validation + const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'video/quicktime', 'video/webm']; if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) { return cb(null, true); } else { - cb(new Error('Only JPEG, PNG and WebP images are allowed')); + cb(new Error('Only JPEG, PNG, WebP images or MP4/MOV/WEBM videos are allowed')); } }, // Add abort on limit to stop processing when limits are exceeded abortOnLimit: true }); -const { createFileUploadValidator } = require('../utils/fileSecurityUtils'); - // Create content validator middleware const validateUploadContent = createFileUploadValidator({ - allowedTypes: ['image/jpeg', 'image/png', 'image/webp'], + allowedTypes: ['image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'video/quicktime', 'video/webm'], maxFileSize: 50 * 1024 * 1024, validateContent: true }); @@ -186,25 +184,13 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re // Parse category_id to number if provided const numericCategoryId = parseCategoryId(category_id); - - // Determine photo type from category_id parameter (for backwards compatibility) - let photoType = 'individual'; // default - let categoryName = 'individual'; - - if (numericCategoryId === 1 || category_id === 'collage') { - photoType = 'collage'; - categoryName = 'collages'; - } else if (numericCategoryId === 2 || category_id === 'individual') { - photoType = 'individual'; - categoryName = 'individual'; - } - - // For backwards compatibility, accept string values - if (category_id === 'collage') { - photoType = 'collage'; - categoryName = 'collages'; - } - + + const resolveCategoryName = (type) => { + if (type === 'collage') return 'collages'; + if (type === 'video') return 'videos'; + return 'individual'; + }; + // Create final destination directory const finalDestPath = path.join(getStoragePath(), 'events/active', event.slug); await fs.mkdir(finalDestPath, { recursive: true }); @@ -222,20 +208,49 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re const trx = await db.transaction(); try { - // Get initial counter for this batch based on photo type - const existingCount = await trx('photos') - .where({ event_id: eventId, type: photoType }) - .count('id as count') - .first(); - let batchCounter = (parseInt(existingCount.count) || 0) + 1; + const preparedBatch = batch.map((file) => { + const resolvedMime = file?.mimetype || mime.lookup(file?.originalname || '') || 'application/octet-stream'; + const video = isVideoMimeType(resolvedMime, file?.originalname); + let inferredType = video ? 'video' : 'individual'; + + if (!video) { + if (numericCategoryId === 1 || category_id === 'collage') { + inferredType = 'collage'; + } else if (numericCategoryId === 2 || category_id === 'individual') { + inferredType = 'individual'; + } + } + + return { + file, + resolvedMime, + isVideo: video, + photoType: inferredType + }; + }); + + const typesInBatch = Array.from(new Set(preparedBatch.map((item) => item.photoType))); + const typeCounters = {}; + + if (typesInBatch.length > 0) { + const existingCounts = await trx('photos') + .where({ event_id: eventId }) + .whereIn('type', typesInBatch) + .select('type') + .count('id as count') + .groupBy('type'); + + existingCounts.forEach((row) => { + typeCounters[row.type] = parseInt(row.count) || 0; + }); + } const batchPhotos = []; const fileRenameOperations = []; // Store rename operations to do after commit // First pass: prepare data and move files from temp to final location - for (let fileIndex = 0; fileIndex < batch.length; fileIndex++) { - const file = batch[fileIndex]; - const counter = batchCounter + fileIndex; + for (let fileIndex = 0; fileIndex < preparedBatch.length; fileIndex++) { + const { file, resolvedMime, isVideo, photoType } = preparedBatch[fileIndex]; const tempPath = file.path; // Original temp path try { @@ -244,12 +259,15 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re if (tempStats.size === 0) { throw new Error('File is empty - upload may have been interrupted'); } + + typeCounters[photoType] = (typeCounters[photoType] || 0) + 1; + const counter = typeCounters[photoType]; // Generate new filename const extension = path.extname(file.originalname); const newFilename = generatePhotoFilename( event.event_name, - categoryName, + resolveCategoryName(photoType), counter, extension ); @@ -268,7 +286,8 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re type: photoType, size_bytes: tempStats.size, // Use actual file size from stat category_id: numericCategoryId, - source_origin: 'managed' + source_origin: 'managed', + mime_type: resolvedMime }; batchPhotos.push(photoData); @@ -278,7 +297,8 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re tempPath: tempPath, finalPath: finalPath, filename: newFilename, - photoData: photoData + photoData: photoData, + isVideo }); } catch (error) { console.error(`Error preparing file ${file.originalname}:`, error); @@ -288,7 +308,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re // Insert all photos in this batch if (batchPhotos.length > 0) { - console.log(`Inserting batch of ${batchPhotos.length} photos with type: ${photoType}`); + console.log(`Inserting batch of ${batchPhotos.length} files with types: ${typesInBatch.join(', ')}`); const insertedIds = await trx('photos').insert(batchPhotos).returning('id'); @@ -313,27 +333,31 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re } // Generate thumbnail with final path - let thumbnailPath = null; - try { - thumbnailPath = await generateThumbnail(operation.finalPath); - - // Update the database with thumbnail path - if (thumbnailPath && insertedIds[idx]) { - const photoId = insertedIds[idx]?.id || insertedIds[idx]; - await db('photos') - .where({ id: photoId }) - .update({ thumbnail_path: thumbnailPath }); - } - } catch (thumbError) { - console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message); - } + let thumbnailPath = null; + try { + thumbnailPath = operation.isVideo + ? await generateVideoPlaceholder(operation.filename) + : await generateThumbnail(operation.finalPath); + + // Update the database with thumbnail path + if (thumbnailPath && insertedIds[idx]) { + const photoId = insertedIds[idx]?.id || insertedIds[idx]; + await db('photos') + .where({ id: photoId }) + .update({ thumbnail_path: thumbnailPath }); + } + } catch (thumbError) { + console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message); + } // Add to successful uploads uploadedPhotos.push({ id: insertedIds[idx]?.id || insertedIds[idx], filename: operation.filename, size: operation.photoData.size_bytes, - category_id: operation.photoData.category_id + category_id: operation.photoData.category_id, + type: operation.photoData.type, + mime_type: operation.photoData.mime_type }); } catch (moveError) { console.error(`Failed to move file ${operation.tempPath} to ${operation.finalPath}:`, moveError); @@ -400,7 +424,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re // Prepare response const totalAttempted = req.files.length + (req.invalidFiles ? req.invalidFiles.length : 0); const response = { - message: `Successfully uploaded ${uploadedPhotos.length} photos`, + message: `Successfully uploaded ${uploadedPhotos.length} files`, photos: uploadedPhotos, totalFiles: totalAttempted, successCount: uploadedPhotos.length, @@ -410,7 +434,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re // Include error details if any files failed if (totalInvalidFiles.length > 0) { response.errors = totalInvalidFiles; - response.message = `Uploaded ${uploadedPhotos.length} of ${totalAttempted} photos. ${totalInvalidFiles.length} failed.`; + response.message = `Uploaded ${uploadedPhotos.length} of ${totalAttempted} files. ${totalInvalidFiles.length} failed.`; } res.json(response); @@ -427,7 +451,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re } } - res.status(500).json({ error: 'Failed to upload photos' }); + res.status(500).json({ error: 'Failed to upload files' }); } }); @@ -457,7 +481,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => { // Delete thumbnail if exists if (photo.thumbnail_path) { - const thumbPath = path.join(storagePath, 'events/active', photo.thumbnail_path); + const thumbPath = path.join(storagePath, photo.thumbnail_path); try { // Check if file exists before attempting to delete await fs.access(thumbPath); @@ -710,7 +734,7 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) => router.get('/:eventId/photos', adminAuth, async (req, res) => { try { const { eventId } = req.params; - const { category_id, type, search, sort = 'date', order = 'desc' } = req.query; + const { category_id, type, media_type, search, sort = 'date', order = 'desc' } = req.query; let query = db('photos') .leftJoin('photo_categories as pc', 'pc.id', 'photos.category_id') @@ -738,6 +762,20 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => { if (type) { query = query.where({ 'photos.type': type }); } + + if (media_type === 'video') { + query = query.where((qb) => { + qb.where('photos.type', 'video') + .orWhere('photos.mime_type', 'like', 'video/%'); + }); + } else if (media_type === 'photo') { + query = query.where((qb) => { + qb.whereNot('photos.type', 'video') + .andWhere(function(inner) { + inner.whereNull('photos.mime_type').orWhere('photos.mime_type', 'not like', 'video/%'); + }); + }); + } // Search by filename if (search) { @@ -775,28 +813,37 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => { }); res.json({ - photos: photos.map(photo => ({ - id: photo.id, - filename: photo.filename, - // Use the correct admin photos router base for serving images - url: `/admin/photos/${eventId}/photo/${photo.id}`, - // Always expose a thumbnail URL; backend will generate on demand if missing - thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`, - type: photo.type, - category_id: photo.category_id !== null && photo.category_id !== undefined - ? Number(photo.category_id) - : null, - category_name: photo.category_display_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'), - category_slug: photo.category_display_slug || photo.type, - size: photo.size_bytes, - uploaded_at: photo.uploaded_at, - // Feedback data - has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0), - average_rating: photo.average_rating || 0, - comment_count: commentMap[photo.id] || 0, - like_count: photo.like_count || 0, - favorite_count: photo.favorite_count || 0 - })) + photos: photos.map(photo => { + const mediaType = (photo.mime_type && photo.mime_type.startsWith('video/')) || photo.type === 'video' ? 'video' : 'photo'; + const categoryName = photo.category_display_name + || (photo.type === 'individual' ? 'Individual Photos' : photo.type === 'video' ? 'Videos' : 'Collages'); + const normalizedCategoryId = photo.category_id !== null && photo.category_id !== undefined + ? (Number.isNaN(Number(photo.category_id)) ? photo.category_id : Number(photo.category_id)) + : null; + + return ({ + id: photo.id, + filename: photo.filename, + // Use the correct admin photos router base for serving images + url: `/admin/photos/${eventId}/photo/${photo.id}`, + // Always expose a thumbnail URL; backend will generate on demand if missing + thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`, + type: photo.type, + category_id: normalizedCategoryId, + mime_type: photo.mime_type, + media_type: mediaType, + category_name: categoryName, + category_slug: photo.category_display_slug || photo.type, + size: photo.size_bytes, + uploaded_at: photo.uploaded_at, + // Feedback data + has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0), + average_rating: photo.average_rating || 0, + comment_count: commentMap[photo.id] || 0, + like_count: photo.like_count || 0, + favorite_count: photo.favorite_count || 0 + }); + }) }); } catch (error) { console.error('Error fetching photos:', error); @@ -828,8 +875,10 @@ router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => { return res.status(404).json({ error: 'Photo file not found' }); } + const mimeType = photo.mime_type || `image/${path.extname(photo.filename).slice(1)}`; + // Set appropriate headers - res.setHeader('Content-Type', `image/${path.extname(photo.filename).slice(1)}`); + res.setHeader('Content-Type', mimeType); res.setHeader('Cache-Control', 'private, max-age=3600'); res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); @@ -855,8 +904,30 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => { return res.status(404).json({ error: 'Photo not found' }); } + const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename); // Ensure thumbnail exists and is valid, regenerate if needed - const thumbnailPath = await ensureThumbnail(photo); + let thumbnailPath = photo.thumbnail_path; + const thumbMissing = !thumbnailPath || !(await (async () => { + try { + const fs = require('fs').promises; + await fs.access(path.join(getStoragePath(), thumbnailPath)); + return true; + } catch { + return false; + } + })()); + + if (isVideo) { + if (!thumbnailPath || thumbMissing) { + const regenerated = await generateVideoPlaceholder(photo.filename, { regenerate: true }); + if (regenerated) { + thumbnailPath = regenerated; + await db('photos').where({ id: photo.id }).update({ thumbnail_path: regenerated }); + } + } + } else { + thumbnailPath = await ensureThumbnail(photo); + } if (!thumbnailPath) { console.error(`Failed to generate thumbnail for photo ${photoId}`); diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 1e45a61..2f9abd3 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -10,6 +10,8 @@ const secureImageService = require('../services/secureImageService'); const logger = require('../utils/logger'); const { resolvePhotoFilePath } = require('../services/photoResolver'); const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService'); +const { ensureThumbnail, generateVideoPlaceholder } = require('../services/imageProcessor'); +const { isVideoMimeType } = require('../utils/fileSecurityUtils'); // Get storage path from environment or default const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage'); @@ -248,10 +250,17 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { .distinct('type') .orderBy('type', 'asc'); + const resolveCategoryName = (type, mimeType, filename) => { + if (type === 'video' || isVideoMimeType(mimeType, filename)) return 'Videos'; + if (type === 'individual') return 'Individual Photos'; + if (type === 'collage') return 'Collages'; + return type || 'Uncategorized'; + }; + // Convert types to category-like objects const categories = categoryResults.map(result => ({ id: result.type, - name: result.type === 'individual' ? 'Individual Photos' : 'Collages', + name: resolveCategoryName(result.type), slug: result.type, is_global: false })); @@ -292,10 +301,13 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { }, categories: categories, photos: photos.map(photo => { - const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard'); + const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename); + const mediaType = isVideo ? 'video' : 'photo'; + const useJwtUrl = isVideo || (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard'); const photoUrl = useJwtUrl ? `/api/gallery/${req.params.slug}/photo/${photo.id}` : `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`; + const categoryName = resolveCategoryName(photo.type, photo.mime_type, photo.filename); return { id: photo.id, @@ -306,12 +318,14 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`, type: photo.type, category_id: photo.type, - category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages', + category_name: categoryName, category_slug: photo.type, size: photo.size_bytes, uploaded_at: photo.uploaded_at, + media_type: mediaType, + mime_type: photo.mime_type, // Fixed: Use the calculated useJwtUrl variable instead of recalculating - requires_token: !useJwtUrl, + requires_token: !useJwtUrl && !isVideo, // Feedback data has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0), average_rating: photo.average_rating || 0, @@ -345,6 +359,7 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => return res.status(404).json({ error: 'Photo not found' }); } + const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename); // Update download count await db('photos').where('id', photoId).increment('download_count', 1); @@ -373,7 +388,7 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => // Get watermark settings const watermarkSettings = await watermarkService.getWatermarkSettings(); - if (watermarkSettings && watermarkSettings.enabled) { + if (watermarkSettings && watermarkSettings.enabled && !isVideo) { // Apply watermark and send const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); @@ -386,6 +401,9 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => res.send(watermarkedBuffer); } else { // Send original file + if (isVideo) { + res.set({ 'Content-Type': photo.mime_type || 'application/octet-stream' }); + } res.download(filePath, photo.filename, (downloadError) => { if (downloadError) { logger.error('Error streaming gallery download', { @@ -463,14 +481,16 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => { let archiveName; if (hasMultipleTypes) { // Use photo type as folder - const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages'; + const folderName = photo.type === 'individual' ? 'Individual Photos' : photo.type === 'video' ? 'Videos' : 'Collages'; archiveName = path.join(folderName, photo.filename); } else { // No folders, just the filename archiveName = photo.filename; } - if (watermarkSettings && watermarkSettings.enabled) { + const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename); + + if (watermarkSettings && watermarkSettings.enabled && !isVideo) { try { const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); archive.append(watermarkedBuffer, { name: archiveName }); @@ -565,7 +585,9 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) => try { const filePath = resolvePhotoFilePath(req.event, photo); const name = photo.filename || `photo-${photo.id}.jpg`; - if (watermarkSettings && watermarkSettings.enabled) { + const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename); + + if (watermarkSettings && watermarkSettings.enabled && !isVideo) { try { const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); archive.append(watermarkedBuffer, { name }); @@ -615,9 +637,13 @@ router.get('/:slug/photo/:photoId', async (req, res) => { try { const { photoId } = req.params; + const numericPhotoId = parseInt(photoId, 10); + if (!Number.isInteger(numericPhotoId)) { + return res.status(400).json({ error: 'Invalid photo id' }); + } const photo = await db('photos') - .where({ id: photoId, event_id: req.event.id }) + .where({ id: numericPhotoId, event_id: req.event.id }) .first(); @@ -625,10 +651,11 @@ router.get('/:slug/photo/:photoId', return res.status(404).json({ error: 'Photo not found' }); } + const isVideo = (photo.mime_type && photo.mime_type.startsWith('video/')) || photo.type === 'video'; // Check protection level - basic and standard protection allow direct JWT access const protectionLevel = req.event.protection_level || 'standard'; - if (protectionLevel === 'enhanced' || protectionLevel === 'maximum') { + if (!isVideo && (protectionLevel === 'enhanced' || protectionLevel === 'maximum')) { // For enhanced/maximum protection, redirect to secure endpoint return res.status(302).json({ error: 'Secure access required', @@ -653,7 +680,7 @@ router.get('/:slug/photo/:photoId', // Get watermark settings const watermarkSettings = await watermarkService.getWatermarkSettings(); - if (watermarkSettings && watermarkSettings.enabled) { + if (watermarkSettings && watermarkSettings.enabled && !isVideo) { // Apply watermark and send const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); @@ -672,6 +699,9 @@ router.get('/:slug/photo/:photoId', }); // Ensure absolute path for res.sendFile const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath); + if (isVideo) { + res.set({ 'Content-Type': photo.mime_type || 'application/octet-stream' }); + } res.sendFile(absolutePath); } } catch (error) { @@ -692,32 +722,62 @@ router.get('/:slug/thumbnail/:photoId', async (req, res) => { try { const { photoId } = req.params; - - const photo = await db('photos') - .where({ id: photoId, event_id: req.event.id }) - .first(); - - if (!photo || !photo.thumbnail_path) { - return res.status(404).json({ error: 'Thumbnail not found' }); + const numericPhotoId = parseInt(photoId, 10); + if (!Number.isInteger(numericPhotoId)) { + return res.status(400).json({ error: 'Invalid photo id' }); } - const thumbPath = path.join(getStoragePath(), photo.thumbnail_path); + const photo = await db('photos') + .where({ id: numericPhotoId, event_id: req.event.id }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Thumbnail not found' }); + } + const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename); + + let thumbnailPath = photo.thumbnail_path; + let thumbFilePath = thumbnailPath ? path.join(getStoragePath(), thumbnailPath) : null; + + if (isVideo) { + const fs = require('fs').promises; + const missing = !thumbFilePath || !(await (async () => { try { await fs.access(thumbFilePath); return true; } catch { return false; } })()); + if (missing) { + const regenerated = await generateVideoPlaceholder(photo.filename, { regenerate: true }); + if (regenerated) { + thumbnailPath = regenerated; + thumbFilePath = path.join(getStoragePath(), regenerated); + await db('photos').where({ id: photo.id }).update({ thumbnail_path: regenerated }); + } + } + } else { + thumbnailPath = await ensureThumbnail(photo); + thumbFilePath = thumbnailPath ? path.join(getStoragePath(), thumbnailPath) : null; + } + + if (!thumbFilePath) { + return res.status(404).json({ error: 'Thumbnail not found' }); + } // Check if file exists const fs = require('fs').promises; try { - await fs.access(thumbPath); + await fs.access(thumbFilePath); } catch (error) { return res.status(404).json({ error: 'Thumbnail file not found' }); } // Log thumbnail access - await secureImageService.logImageAccess( - photoId, - req.event.id, - req.clientInfo, - 'thumbnail' - ); + try { + await secureImageService.logImageAccess( + numericPhotoId, + req.event.id, + req.clientInfo, + 'thumbnail' + ); + } catch (logErr) { + logger.warn('Thumbnail access log failed', { photoId, eventId: req.event.id, error: logErr.message }); + } // Set appropriate headers with enhanced security res.set({ @@ -729,8 +789,14 @@ router.get('/:slug/thumbnail/:photoId', }); // Send file - res.sendFile(path.resolve(thumbPath)); + res.sendFile(path.resolve(thumbFilePath)); } catch (error) { + console.error('Thumbnail route error', { + message: error?.message, + stack: error?.stack, + photoId: req.params.photoId, + eventId: req.event?.id, + }); logger.error('Error serving thumbnail:', { error: error.message, photoId: req.params.photoId, diff --git a/backend/src/services/fileWatcher.js b/backend/src/services/fileWatcher.js index 6940cde..b118d54 100644 --- a/backend/src/services/fileWatcher.js +++ b/backend/src/services/fileWatcher.js @@ -3,8 +3,10 @@ const path = require('path'); const fs = require('fs').promises; const { db } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); -const { generateThumbnail } = require('./imageProcessor'); +const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor'); const logger = require('../utils/logger'); +const { isVideoMimeType } = require('../utils/fileSecurityUtils'); +const mime = require('mime-types'); const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); const WATCH_PATH = () => path.join(getStoragePath(), 'events/active'); @@ -47,9 +49,11 @@ async function processNewPhoto(filePath) { const eventSlug = pathParts[0]; const photoType = pathParts[1] === 'collages' ? 'collage' : 'individual'; - // Check if this is an image file + // Check if this is an image or video file const ext = path.extname(filePath).toLowerCase(); - if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return; + const detectedMime = mime.lookup(filePath) || ''; + const isVideo = isVideoMimeType(detectedMime, filePath) || ['.mp4', '.mov', '.webm'].includes(ext); + if (!isVideo && !['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return; // Skip temporary upload files const filename = path.basename(filePath); @@ -65,11 +69,17 @@ async function processNewPhoto(filePath) { // Get file stats const stats = await fs.stat(filePath); - // Generate thumbnail - const thumbnailPath = await generateThumbnail(filePath); + // Generate thumbnail or placeholder + let thumbnailPath = null; + if (isVideo) { + thumbnailPath = await generateVideoPlaceholder(filename); + } else { + thumbnailPath = await generateThumbnail(filePath); + } // Calculate relative thumbnail path const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root + const mimeType = detectedMime || (isVideo ? 'video/mp4' : 'image/jpeg'); // Check if photo already exists const existingPhoto = await db('photos') @@ -83,8 +93,9 @@ async function processNewPhoto(filePath) { filename: path.basename(filePath), path: relativePath, thumbnail_path: relativeThumbPath, - type: photoType, - size_bytes: stats.size + type: isVideo ? 'video' : photoType, + size_bytes: stats.size, + mime_type: mimeType }); logger.info(`Added new photo: ${relativePath}`); diff --git a/backend/src/services/imageProcessor.js b/backend/src/services/imageProcessor.js index c729658..e22e490 100644 --- a/backend/src/services/imageProcessor.js +++ b/backend/src/services/imageProcessor.js @@ -237,4 +237,54 @@ async function ensureThumbnail(photo) { return null; } -module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail }; +async function generateVideoPlaceholder(originalFilename, options = {}) { + const parsed = path.parse(originalFilename || ''); + const baseName = parsed.name || 'video'; + const thumbnailDir = getThumbnailPath(); + const thumbnailFilename = `thumb_${baseName}.jpg`; + const thumbnailPath = path.join(thumbnailDir, thumbnailFilename); + + const settings = await getThumbnailSettings(); + const width = settings.width || DEFAULT_THUMBNAIL_WIDTH; + const height = settings.height || DEFAULT_THUMBNAIL_HEIGHT; + + if (options.regenerate) { + try { + await fs.unlink(thumbnailPath); + } catch (_) { + // ignore if missing + } + } + + try { + await fs.mkdir(thumbnailDir, { recursive: true }); + const svg = ` + + + + + + + + + + + + VIDEO + + + `; + + await sharp(Buffer.from(svg)) + .resize(width, height, { fit: 'cover' }) + .jpeg({ quality: settings.quality || DEFAULT_THUMBNAIL_QUALITY }) + .toFile(thumbnailPath); + + return path.relative(getStoragePath(), thumbnailPath); + } catch (error) { + logger.error('Failed to generate video placeholder thumbnail:', error.message); + return null; + } +} + +module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail, generateVideoPlaceholder }; diff --git a/backend/src/services/photoProcessor.js b/backend/src/services/photoProcessor.js index fd2762d..99d5832 100644 --- a/backend/src/services/photoProcessor.js +++ b/backend/src/services/photoProcessor.js @@ -1,8 +1,10 @@ const path = require('path'); const fs = require('fs').promises; const { db } = require('../database/db'); -const { generateThumbnail } = require('./imageProcessor'); +const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor'); const { generatePhotoFilename } = require('../utils/filenameSanitizer'); +const { isVideoMimeType } = require('../utils/fileSecurityUtils'); +const mime = require('mime-types'); // Get storage path from environment or default const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); @@ -70,12 +72,15 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ const trx = await db.transaction(); try { + const resolvedMime = file?.mimetype || mime.lookup(file?.originalname || '') || 'application/octet-stream'; + const isVideo = isVideoMimeType(resolvedMime, file?.originalname); + // Count existing photos to generate sequence number let counter = 1; - let photoType = 'individual'; // default type + let photoType = isVideo ? 'video' : 'individual'; // default type // If categoryId is provided and matches photo types, use it as type - if (categoryId === 'collage') { + if (!isVideo && categoryId === 'collage') { photoType = 'collage'; } @@ -90,7 +95,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ // Generate new filename const extension = path.extname(file.originalname); - const categoryName = photoType === 'collage' ? 'collages' : 'individual'; + const categoryName = photoType === 'collage' ? 'collages' : (isVideo ? 'videos' : 'individual'); const newFilename = generatePhotoFilename( event.event_name, categoryName, @@ -150,8 +155,13 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ } } - // Generate thumbnail - const thumbnailPath = await generateThumbnail(newPath); + // Generate thumbnail or placeholder + let thumbnailPath = null; + if (isVideo) { + thumbnailPath = await generateVideoPlaceholder(newFilename); + } else { + thumbnailPath = await generateThumbnail(newPath); + } // Calculate relative paths const storagePath = getStoragePath(); @@ -173,20 +183,22 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ type: photoType, size_bytes: file.size, uploaded_by: uploadedBy, - source_origin: 'managed' + source_origin: 'managed', + mime_type: resolvedMime }) .returning('id'); } else { insertResult = await trx('photos').insert({ event_id: eventId, filename: newFilename, - path: relativePath, - thumbnail_path: relativeThumbPath, - type: photoType, - size_bytes: file.size, - uploaded_by: uploadedBy, - source_origin: 'managed' - }); + path: relativePath, + thumbnail_path: relativeThumbPath, + type: photoType, + size_bytes: file.size, + uploaded_by: uploadedBy, + source_origin: 'managed', + mime_type: resolvedMime + }); } const insertedId = Array.isArray(insertResult) @@ -206,7 +218,8 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ id: photoId, filename: newFilename, size: file.size, - type: photoType + type: photoType, + media_type: isVideo ? 'video' : 'photo' }); console.log(`Successfully processed file ${file.originalname} (ID: ${photoId})`); diff --git a/backend/src/utils/fileSecurityUtils.js b/backend/src/utils/fileSecurityUtils.js index b7fb11d..162e1a3 100644 --- a/backend/src/utils/fileSecurityUtils.js +++ b/backend/src/utils/fileSecurityUtils.js @@ -78,6 +78,21 @@ const ALLOWED_IMAGE_TYPES = { extensions: ['.svg'], // SVG files are XML-based text files, so we skip magic number validation magicNumbers: null + }, + // Video types are included here to keep validation centralized + 'video/mp4': { + extensions: ['.mp4'], + magicNumbers: null + }, + 'video/quicktime': { + extensions: ['.mov', '.qt'], + magicNumbers: null + }, + 'video/webm': { + extensions: ['.webm'], + magicNumbers: [ + { offset: 0, bytes: [0x1A, 0x45, 0xDF, 0xA3] } // WebM/Matroska + ] } }; @@ -164,6 +179,26 @@ function getSafeFilename(originalFilename) { return `upload_${timestamp}_${randomString}${ext}`; } +function isVideoMimeType(mimeType, filename) { + const lowerMime = (mimeType || '').toLowerCase(); + if (lowerMime.startsWith('video/')) { + return true; + } + + const ext = filename ? path.extname(filename).toLowerCase() : ''; + const videoExts = ['.mp4', '.mov', '.webm', '.m4v', '.qt']; + + if (videoExts.includes(ext)) { + return true; + } + + if (lowerMime === 'application/mp4' || lowerMime === 'application/x-m4v' || lowerMime === 'application/octet-stream') { + return videoExts.includes(ext) || true; + } + + return false; +} + /** * Create a file upload validator middleware * @param {Object} options - Validation options @@ -229,5 +264,6 @@ module.exports = { validateFileContent, getSafeFilename, createFileUploadValidator, - ALLOWED_IMAGE_TYPES -}; \ No newline at end of file + ALLOWED_IMAGE_TYPES, + isVideoMimeType +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 710d533..89cf4b8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -102,6 +102,27 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -393,6 +414,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@epic-web/invariant": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", @@ -2273,6 +2409,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2353,6 +2499,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2774,12 +2930,84 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -2808,6 +3036,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -3653,6 +3888,16 @@ "node": ">= 0.4" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -3662,6 +3907,19 @@ "react-is": "^16.7.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -3671,6 +3929,34 @@ "void-elements": "3.1.0" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/i18next": { "version": "25.3.2", "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.2.tgz", @@ -3720,6 +4006,19 @@ "cross-fetch": "4.0.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3839,6 +4138,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3891,6 +4197,84 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4088,6 +4472,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4300,6 +4695,13 @@ "node": ">=0.10.0" } }, + "node_modules/nwsapi": { + "version": "2.2.22", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", + "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4395,6 +4797,32 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5281,6 +5709,13 @@ "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", "license": "MIT" }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5305,6 +5740,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -5590,6 +6045,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", @@ -5762,6 +6224,26 @@ "@popperjs/core": "^2.9.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5775,6 +6257,19 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -5818,7 +6313,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -6145,12 +6640,48 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -6299,6 +6830,45 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/src/components/admin/AdminAuthenticatedVideo.tsx b/frontend/src/components/admin/AdminAuthenticatedVideo.tsx new file mode 100644 index 0000000..eff6cf6 --- /dev/null +++ b/frontend/src/components/admin/AdminAuthenticatedVideo.tsx @@ -0,0 +1,77 @@ +import React, { useEffect, useState } from 'react'; +import { api } from '../../config/api'; + +interface AdminAuthenticatedVideoProps extends React.VideoHTMLAttributes { + src: string; + fallback?: React.ReactNode; +} + +export const AdminAuthenticatedVideo: React.FC = ({ + src, + fallback, + ...props +}) => { + const [videoSrc, setVideoSrc] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + let cancelled = false; + let objectUrl: string | null = null; + + const loadVideo = async () => { + try { + setLoading(true); + setError(false); + setVideoSrc(null); + + const response = await api.get(src, { responseType: 'blob' }); + + if (!cancelled) { + objectUrl = URL.createObjectURL(response.data); + setVideoSrc(objectUrl); + setLoading(false); + } + } catch { + if (!cancelled) { + setError(true); + setLoading(false); + } + } + }; + + if (src) { + loadVideo(); + } + + return () => { + cancelled = true; + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } + }; + }, [src]); + + if (loading) { + return
; + } + + if (error || !videoSrc) { + return fallback ? ( + <>{fallback} + ) : ( +
+ Failed to load +
+ ); + } + + return ( +
- {photos.length} photo{photos.length !== 1 ? 's' : ''} + {t('gallery.photosCount', { count: photos.length })}
@@ -170,6 +172,9 @@ export const AdminPhotoGrid: React.FC = ({ const commentCount = photo.comment_count ?? 0; const averageRating = photo.average_rating ?? 0; const likeCount = photo.like_count ?? 0; + const isVideo = (photo.media_type === 'video') || + (photo.mime_type && photo.mime_type.startsWith('video/')) || + photo.type === 'video'; return (
= ({
)} + + {isVideo && ( +
+ + +
+ )} {/* Feedback Indicators (moved to bottom-right to avoid covering category) */} {(commentCount > 0 || averageRating > 0 || likeCount > 0) && ( @@ -284,7 +298,7 @@ export const AdminPhotoGrid: React.FC = ({ {photos.length === 0 && (
-

No photos uploaded yet

+

{t('gallery.noMedia', 'No media uploaded yet')}

)} diff --git a/frontend/src/components/admin/AdminPhotoViewer.tsx b/frontend/src/components/admin/AdminPhotoViewer.tsx index 9eb22d0..ed43a22 100644 --- a/frontend/src/components/admin/AdminPhotoViewer.tsx +++ b/frontend/src/components/admin/AdminPhotoViewer.tsx @@ -9,6 +9,7 @@ import { photosService } from '../../services/photos.service'; import { feedbackService, type PhotoFeedback, type FeedbackSummary } from '../../services/feedback.service'; import { Button } from '../common'; import { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; +import { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo'; type AdminFeedbackResponse = { feedback: PhotoFeedback[]; @@ -39,6 +40,11 @@ export const AdminPhotoViewer: React.FC = ({ const queryClient = useQueryClient(); const currentPhoto = photos[currentIndex]; + const isVideo = currentPhoto + ? (currentPhoto.media_type === 'video' || + (currentPhoto.mime_type && String(currentPhoto.mime_type).startsWith('video/')) || + currentPhoto.type === 'video') + : false; const averageRating = currentPhoto?.average_rating ?? 0; const likeCount = currentPhoto?.like_count ?? 0; const favoriteCount = currentPhoto?.favorite_count ?? 0; @@ -191,19 +197,35 @@ export const AdminPhotoViewer: React.FC = ({
{/* Image */}
- -
- -

Failed to load image

+ {isVideo ? ( + +
+ +

Failed to load media

+
-
- } - /> + } + /> + ) : ( + +
+ +

Failed to load image

+
+
+ } + /> + )} {/* Sidebar */} diff --git a/frontend/src/components/admin/PhotoFilters.tsx b/frontend/src/components/admin/PhotoFilters.tsx index 26e82ed..128df14 100644 --- a/frontend/src/components/admin/PhotoFilters.tsx +++ b/frontend/src/components/admin/PhotoFilters.tsx @@ -1,16 +1,20 @@ import React from 'react'; import { Search, Filter, SortAsc, SortDesc } from 'lucide-react'; import { Input } from '../common'; +import { useTranslation } from 'react-i18next'; interface PhotoFiltersProps { - categories: Array<{ id: number; name: string; slug: string }>; - selectedCategory: number | null | undefined; + categories: Array<{ id: number | string; name: string; slug: string }>; + selectedCategory: number | string | null | undefined; searchTerm: string; sortBy: 'date' | 'name' | 'size' | 'rating'; sortOrder: 'asc' | 'desc'; - onCategoryChange: (categoryId: number | null | undefined) => void; + onCategoryChange: (categoryId: number | string | null | undefined) => void; onSearchChange: (search: string) => void; onSortChange: (sort: 'date' | 'name' | 'size' | 'rating', order: 'asc' | 'desc') => void; + mediaType?: 'all' | 'photo' | 'video'; + onMediaTypeChange?: (mediaType: 'all' | 'photo' | 'video') => void; + showMediaFilter?: boolean; } export const PhotoFilters: React.FC = ({ @@ -21,8 +25,12 @@ export const PhotoFilters: React.FC = ({ sortOrder, onCategoryChange, onSearchChange, - onSortChange + onSortChange, + mediaType = 'all', + onMediaTypeChange, + showMediaFilter = false }) => { + const { t } = useTranslation(); const handleSortToggle = () => { onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc'); }; @@ -34,7 +42,7 @@ export const PhotoFilters: React.FC = ({
onSearchChange(e.target.value)} leftIcon={} @@ -46,11 +54,16 @@ export const PhotoFilters: React.FC = ({
+ {showMediaFilter && onMediaTypeChange && ( +
+ + +
+ )} + {/* Sort Options */}
); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/admin/PhotoUpload.tsx b/frontend/src/components/admin/PhotoUpload.tsx index 154c6c0..a6adaa8 100644 --- a/frontend/src/components/admin/PhotoUpload.tsx +++ b/frontend/src/components/admin/PhotoUpload.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef } from 'react'; -import { Upload, X, Image, Loader2 } from 'lucide-react'; +import { Upload, X, Image, Loader2, Video } from 'lucide-react'; import { Button } from '../common'; import { clsx } from 'clsx'; import { api } from '../../config/api'; @@ -51,12 +51,18 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl const handleFileSelect = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); - const imageFiles = files.filter(file => - ['image/jpeg', 'image/png', 'image/webp'].includes(file.type) - ); + const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'video/quicktime', 'video/webm']; + const allowedFiles = files.filter(file => allowedTypes.includes(file.type)); + const rejectedFiles = files.filter(file => !allowedTypes.includes(file.type)); + + if (rejectedFiles.length > 0) { + toast.error( + t('upload.unsupportedFiles', 'Some files were skipped because the format is not supported (use JPEG/PNG/WebP/MP4/MOV/WEBM).') + ); + } // Check total file count with existing files - const totalFiles = selectedFiles.length + imageFiles.length; + const totalFiles = selectedFiles.length + allowedFiles.length; if (totalFiles > maxFilesPerUpload) { const allowedNewFiles = maxFilesPerUpload - selectedFiles.length; if (allowedNewFiles <= 0) { @@ -70,11 +76,11 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) || `Only ${allowedNewFiles} more files can be added (limit ${maxFilesPerUpload})` ); - setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]); + setSelectedFiles(prev => [...prev, ...allowedFiles.slice(0, allowedNewFiles)]); return; } - setSelectedFiles(prev => [...prev, ...imageFiles]); + setSelectedFiles(prev => [...prev, ...allowedFiles]); }; const removeFile = (index: number) => { @@ -186,7 +192,7 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl {/* Category Selection */}