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 ( + + ); +}; diff --git a/frontend/src/components/admin/AdminPhotoGrid.tsx b/frontend/src/components/admin/AdminPhotoGrid.tsx index 639eaee..a48bc4e 100644 --- a/frontend/src/components/admin/AdminPhotoGrid.tsx +++ b/frontend/src/components/admin/AdminPhotoGrid.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; -import { Check, Download, Trash2, Eye, Package, MessageSquare, Star } from 'lucide-react'; +import { Check, Download, Trash2, Eye, Package, MessageSquare, Star, Video } from 'lucide-react'; import { toast } from 'react-toastify'; +import { useTranslation } from 'react-i18next'; import { AdminPhoto } from '../../services/photos.service'; import { photosService } from '../../services/photos.service'; @@ -20,6 +21,7 @@ export const AdminPhotoGrid: React.FC = ({ onPhotoClick, onPhotosDeleted }) => { + const { t } = useTranslation(); const [selectedPhotos, setSelectedPhotos] = useState>(new Set()); const [isSelectionMode, setIsSelectionMode] = useState(false); const [isDeleting, setIsDeleting] = useState(false); @@ -126,7 +128,7 @@ export const AdminPhotoGrid: React.FC = ({ onClick={toggleSelectionMode} leftIcon={} > - {isSelectionMode ? 'Cancel Selection' : 'Select Photos'} + {isSelectionMode ? t('gallery.cancelSelection', 'Cancel Selection') : t('gallery.selectPhotos', 'Select Photos')} {(isSelectionMode || selectedPhotos.size > 0) && ( @@ -136,13 +138,13 @@ export const AdminPhotoGrid: React.FC = ({ size="sm" onClick={handleSelectAll} > - {selectedPhotos.size === photos.length ? 'Deselect All' : 'Select All'} + {selectedPhotos.size === photos.length ? t('gallery.deselectAll', 'Deselect All') : t('gallery.selectAll', 'Select All')} {selectedPhotos.size > 0 && ( <> - {selectedPhotos.size} selected + {t('gallery.photosSelected', { count: selectedPhotos.size })} = ({ className="px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:bg-red-400 rounded-lg flex items-center gap-2" > - Delete Selected + {t('gallery.deleteSelected', 'Delete Selected')} > )} @@ -159,7 +161,7 @@ export const AdminPhotoGrid: React.FC = ({ - {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 && ( + + + + {t('common.video', 'Video')} + + + )} {/* 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 = ({ onCategoryChange(e.target.value === '' ? null : Number(e.target.value) || undefined)} + onChange={(e) => { + const raw = e.target.value; + if (raw === '') return onCategoryChange(null); + const numeric = Number(raw); + onCategoryChange(Number.isNaN(numeric) ? raw : numeric); + }} className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" > - All Categories - Uncategorized + {t('gallery.allCategories', 'All Categories')} + {t('gallery.uncategorized', 'Uncategorized')} {categories.map(cat => ( {cat.name} @@ -59,6 +72,21 @@ export const PhotoFilters: React.FC = ({ + {showMediaFilter && onMediaTypeChange && ( + + + onMediaTypeChange(e.target.value as 'all' | 'photo' | 'video')} + className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + > + {t('gallery.allMedia', 'All media')} + {t('gallery.photosOnly', 'Photos only')} + {t('gallery.videosOnly', 'Videos only')} + + + )} + {/* Sort Options */} = ({ onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size' | 'rating', sortOrder)} className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" > - Sort by Date - Sort by Name - Sort by Size - Sort by Rating + {t('gallery.sortByDate', 'Sort by Date')} + {t('gallery.sortByName', 'Sort by Name')} + {t('gallery.sortBySize', 'Sort by Size')} + {t('gallery.sortByRating', 'Sort by Rating')} {sortOrder === 'asc' ? ( @@ -87,4 +115,4 @@ export const PhotoFilters: React.FC = ({ ); -}; \ 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 */} - {t('upload.photoCategory')} + {t('upload.mediaCategory', 'Media category')} = ({ eventId, onUploadCompl {t('upload.clickToUpload')} - {t('upload.fileRequirements', { limit: maxFilesPerUpload })} + {t('upload.fileRequirementsMedia', { limit: maxFilesPerUpload }) || t('upload.fileRequirements', { limit: maxFilesPerUpload })} = ({ eventId, onUploadCompl ref={fileInputRef} type="file" multiple - accept="image/jpeg,image/png,image/webp" + accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime,video/webm" onChange={handleFileSelect} className="hidden" /> @@ -255,7 +261,11 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl className="flex items-center justify-between p-2 bg-neutral-50 rounded-lg" > - + {file.type.startsWith('video/') ? ( + + ) : ( + + )} {file.name} @@ -288,7 +298,9 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl disabled={selectedFiles.length === 0 || isUploading} leftIcon={isUploading ? : } > - {isUploading ? t('upload.uploading') : t('common.upload') + ` ${selectedFiles.length} ${t(selectedFiles.length === 1 ? 'common.photo' : 'common.photos')}`} + {isUploading + ? t('upload.uploading') + : t('upload.uploadAction', { count: selectedFiles.length }) || `Upload ${selectedFiles.length} files`} diff --git a/frontend/src/components/admin/PhotoUploadModal.tsx b/frontend/src/components/admin/PhotoUploadModal.tsx index fe92ace..939b0bb 100644 --- a/frontend/src/components/admin/PhotoUploadModal.tsx +++ b/frontend/src/components/admin/PhotoUploadModal.tsx @@ -33,7 +33,7 @@ export const PhotoUploadModal: React.FC = ({ {/* Fixed Header */} - {t('events.uploadPhotos')} + {t('upload.uploadMedia', t('events.uploadPhotos'))} = ({ ); }; -PhotoUploadModal.displayName = 'PhotoUploadModal'; \ No newline at end of file +PhotoUploadModal.displayName = 'PhotoUploadModal'; diff --git a/frontend/src/components/admin/index.ts b/frontend/src/components/admin/index.ts index 21ae03b..9a75a32 100644 --- a/frontend/src/components/admin/index.ts +++ b/frontend/src/components/admin/index.ts @@ -17,6 +17,7 @@ export { AdminPhotoViewer } from './AdminPhotoViewer'; export { PhotoFilters } from './PhotoFilters'; export { PasswordResetModal } from './PasswordResetModal'; export { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; +export { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo'; export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced'; export { ThemeDisplay } from './ThemeDisplay'; export { ThemeEditorModal } from './ThemeEditorModal'; @@ -29,4 +30,4 @@ export { BackupHistory } from './BackupHistory'; export { RestoreWizard } from './RestoreWizard'; export { FeedbackSettings } from './FeedbackSettings'; export { FeedbackModerationPanel } from './FeedbackModerationPanel'; -export { WordFilterManager } from './WordFilterManager'; \ No newline at end of file +export { WordFilterManager } from './WordFilterManager'; diff --git a/frontend/src/components/common/AuthenticatedVideo.tsx b/frontend/src/components/common/AuthenticatedVideo.tsx new file mode 100644 index 0000000..90532c1 --- /dev/null +++ b/frontend/src/components/common/AuthenticatedVideo.tsx @@ -0,0 +1,125 @@ +import React, { useEffect, useState } from 'react'; +import { buildResourceUrl } from '../../utils/url'; +import { + getActiveGallerySlug, + getGalleryToken, + inferGallerySlugFromLocation, + resolveSlugFromRequestUrl, +} from '../../utils/galleryAuthStorage'; + +interface AuthenticatedVideoProps extends React.VideoHTMLAttributes { + src: string; + fallbackSrc?: string; + slug?: string; +} + +export const AuthenticatedVideo: React.FC = ({ + src, + fallbackSrc, + slug, + ...props +}) => { + const [videoSrc, setVideoSrc] = useState(''); + const [error, setError] = useState(false); + + useEffect(() => { + let aborted = false; + const objectUrls: string[] = []; + + if (!src) { + setVideoSrc(''); + setError(true); + return; + } + + const resolveSlug = (candidateSrc?: string): string | null => { + if (slug) { + return slug; + } + const fromUrl = candidateSrc ? resolveSlugFromRequestUrl(candidateSrc) : null; + if (fromUrl) { + return fromUrl; + } + return getActiveGallerySlug() || inferGallerySlugFromLocation(); + }; + + const fetchWithAuth = async (rawUrl: string | undefined | null): Promise => { + if (!rawUrl) { + throw new Error('No URL provided'); + } + + const fullUrl = rawUrl.startsWith('/') + ? buildResourceUrl(rawUrl) + : rawUrl; + + const headers: Record = {}; + const slugForRequest = resolveSlug(rawUrl); + const token = getGalleryToken(slugForRequest); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(fullUrl, { + credentials: 'include', + headers: Object.keys(headers).length ? headers : undefined, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch media: ${response.status} ${response.statusText}`); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + objectUrls.push(objectUrl); + return objectUrl; + }; + + const load = async () => { + try { + const primaryUrl = await fetchWithAuth(src); + if (!aborted) { + setVideoSrc(primaryUrl); + setError(false); + } + } catch (err) { + if (fallbackSrc && fallbackSrc !== src) { + try { + const fallbackUrl = await fetchWithAuth(fallbackSrc); + if (!aborted) { + setVideoSrc(fallbackUrl); + setError(false); + } + return; + } catch (_) { + // ignore and set error below + } + } + if (!aborted) { + setError(true); + setVideoSrc(''); + } + } + }; + + load(); + + return () => { + aborted = true; + objectUrls.forEach((url) => URL.revokeObjectURL(url)); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [src, fallbackSrc, slug]); + + if (error || !videoSrc) { + return null; + } + + return ( + + ); +}; diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts index 7e84bdc..4ea4299 100644 --- a/frontend/src/components/common/index.ts +++ b/frontend/src/components/common/index.ts @@ -16,7 +16,8 @@ export { SkipLink } from './SkipLink'; export { DynamicFavicon } from './DynamicFavicon'; export { LanguageSelector } from './LanguageSelector'; export { AuthenticatedImage } from './AuthenticatedImage'; +export { AuthenticatedVideo } from './AuthenticatedVideo'; export { ProtectedImage } from './ProtectedImage'; export { ProtectionWarning } from './ProtectionWarning'; export { ReCaptcha } from './ReCaptcha'; -export { PasswordGenerator } from './PasswordGenerator'; \ No newline at end of file +export { PasswordGenerator } from './PasswordGenerator'; diff --git a/frontend/src/components/gallery/GallerySidebar.tsx b/frontend/src/components/gallery/GallerySidebar.tsx index f86660a..43a87ca 100644 --- a/frontend/src/components/gallery/GallerySidebar.tsx +++ b/frontend/src/components/gallery/GallerySidebar.tsx @@ -9,8 +9,8 @@ interface GallerySidebarProps { isOpen: boolean; onClose: () => void; categories: PhotoCategory[]; - selectedCategoryId: number | null; - onCategoryChange: (categoryId: number | null) => void; + selectedCategoryId: number | string | null; + onCategoryChange: (categoryId: number | string | null) => void; searchTerm: string; onSearchChange: (term: string) => void; sortBy: 'date' | 'name' | 'size' | 'rating'; @@ -22,7 +22,7 @@ interface GallerySidebarProps { onDownloadSelected: () => void; isDownloading: boolean; allowDownloads?: boolean; - photoCounts?: Record; + photoCounts?: Record; totalPhotos: number; isMobile: boolean; galleryLayout?: string; @@ -34,6 +34,9 @@ interface GallerySidebarProps { likeCount?: number; favoriteCount?: number; ratedCount?: number; + mediaFilter?: 'all' | 'photo' | 'video'; + onMediaFilterChange?: (filter: 'all' | 'photo' | 'video') => void; + showMediaFilter?: boolean; } export const GallerySidebar: React.FC = ({ @@ -64,7 +67,10 @@ export const GallerySidebar: React.FC = ({ onFilterChange, likeCount = 0, favoriteCount = 0, - ratedCount = 0 + ratedCount = 0, + mediaFilter = 'all', + onMediaFilterChange, + showMediaFilter = false }) => { const { t } = useTranslation(); const sidebarRef = useRef(null); @@ -288,6 +294,47 @@ export const GallerySidebar: React.FC = ({ )} + {showMediaFilter && onMediaFilterChange && ( + + + + {t('gallery.mediaType', 'Media')} + + + { + onMediaFilterChange('all'); + if (isMobile) onClose(); + }} + > + {t('gallery.allMedia', 'All')} + + { + onMediaFilterChange('photo'); + if (isMobile) onClose(); + }} + > + {t('gallery.photosOnly', 'Photos')} + + { + onMediaFilterChange('video'); + if (isMobile) onClose(); + }} + > + {t('gallery.videosOnly', 'Videos')} + + + + )} + {/* Sort Section - Hidden for carousel and timeline layouts */} {galleryLayout !== 'carousel' && galleryLayout !== 'timeline' && ( diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 65fd57f..c2a352d 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -44,7 +44,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { const { t } = useTranslation(); const { logout } = useGalleryAuth(); const { setTheme, theme } = useTheme(); - const [selectedCategoryId, setSelectedCategoryId] = useState(null); + const [selectedCategoryId, setSelectedCategoryId] = useState(null); const [searchTerm, setSearchTerm] = useState(''); const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating'>('date'); const [brandingSettings, setBrandingSettings] = useState(null); @@ -57,8 +57,22 @@ export const GalleryView: React.FC = ({ slug, event }) => { const { watermarkEnabled } = useWatermarkSettings(); const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard'); const [filterType, setFilterType] = useState('all'); + const [mediaFilter, setMediaFilter] = useState<'all' | 'photo' | 'video'>('all'); const [guestId, setGuestId] = useState(''); const [staticHeroPhoto, setStaticHeroPhoto] = useState(null); + + const resolveMediaType = (photo: Photo) => { + if (photo.media_type === 'video' || photo.media_type === 'photo') { + return photo.media_type; + } + if (photo.mime_type && photo.mime_type.startsWith('video/')) { + return 'video'; + } + if ((photo as any).type === 'video') { + return 'video'; + } + return 'photo'; + }; // Generate a unique guest ID for this session useEffect(() => { @@ -176,6 +190,25 @@ export const GalleryView: React.FC = ({ slug, event }) => { } }, [settingsData]); + const availableMediaTypes = useMemo(() => { + const types = new Set<'photo' | 'video'>(); + (data?.photos || []).forEach((photo) => { + const mediaType = resolveMediaType(photo); + if (mediaType === 'photo' || mediaType === 'video') { + types.add(mediaType); + } + }); + return types; + }, [data?.photos]); + + const showMediaFilter = availableMediaTypes.has('photo') && availableMediaTypes.has('video'); + + useEffect(() => { + if (!showMediaFilter && mediaFilter !== 'all') { + setMediaFilter('all'); + } + }, [showMediaFilter, mediaFilter]); + // Determine a stable hero photo from the initial (unfiltered) load useEffect(() => { if (!staticHeroPhoto && data?.photos && filterType === 'all') { @@ -185,7 +218,8 @@ export const GalleryView: React.FC = ({ slug, event }) => { hero = data.photos.find(p => p.id === heroId) || null; } if (!hero && data.photos.length > 0) { - hero = data.photos[0]; + const firstPhoto = data.photos.find(p => resolveMediaType(p) === 'photo'); + hero = firstPhoto || data.photos[0]; } if (hero) { setStaticHeroPhoto(hero); @@ -259,6 +293,12 @@ export const GalleryView: React.FC = ({ slug, event }) => { if (!data?.photos) return []; let photos = [...data.photos]; + + if (mediaFilter === 'photo') { + photos = photos.filter(photo => resolveMediaType(photo) !== 'video'); + } else if (mediaFilter === 'video') { + photos = photos.filter(photo => resolveMediaType(photo) === 'video'); + } // Apply category filter if (selectedCategoryId) { @@ -323,7 +363,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { } return photos; - }, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug, filterType]); + }, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug, filterType, mediaFilter]); const likeCount = useMemo( () => data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0, @@ -388,14 +428,20 @@ export const GalleryView: React.FC = ({ slug, event }) => { // Calculate photo counts per category const photoCounts = useMemo(() => { if (!data?.photos) return {}; - const counts: Record = {}; - data.photos.forEach(photo => { + const counts: Record = {}; + data.photos + .filter(photo => { + if (mediaFilter === 'photo') return resolveMediaType(photo) !== 'video'; + if (mediaFilter === 'video') return resolveMediaType(photo) === 'video'; + return true; + }) + .forEach(photo => { if (photo.category_id) { counts[photo.category_id] = (counts[photo.category_id] || 0) + 1; } }); return counts; - }, [data?.photos]); + }, [data?.photos, mediaFilter]); // Track search usage with debouncing useEffect(() => { @@ -497,6 +543,9 @@ export const GalleryView: React.FC = ({ slug, event }) => { feedbackEnabled={feedbackEnabled} filterType={filterType} onFilterChange={setFilterType} + mediaFilter={mediaFilter} + onMediaFilterChange={setMediaFilter} + showMediaFilter={showMediaFilter} likeCount={likeCount} favoriteCount={favoriteCount} ratedCount={ratedCount} @@ -582,16 +631,19 @@ export const GalleryView: React.FC = ({ slug, event }) => { onCategoryChange={setSelectedCategoryId} searchTerm={searchTerm} onSearchChange={setSearchTerm} - sortBy={sortBy} - onSortChange={setSortBy} - photoCount={filteredPhotos.length} - // Feedback filter props - feedbackEnabled={feedbackEnabled} - currentFilter={filterType} - onFilterChange={setFilterType} - /> - - ) : null} + sortBy={sortBy} + onSortChange={setSortBy} + photoCount={filteredPhotos.length} + // Feedback filter props + feedbackEnabled={feedbackEnabled} + currentFilter={filterType} + onFilterChange={setFilterType} + mediaFilter={mediaFilter} + onMediaFilterChange={setMediaFilter} + showMediaFilter={showMediaFilter} + /> + + ) : null} {/* Photo Grid */} diff --git a/frontend/src/components/gallery/PhotoFilterBar.tsx b/frontend/src/components/gallery/PhotoFilterBar.tsx index ba5b1a3..6caec6f 100644 --- a/frontend/src/components/gallery/PhotoFilterBar.tsx +++ b/frontend/src/components/gallery/PhotoFilterBar.tsx @@ -32,6 +32,9 @@ interface PhotoFilterBarProps { feedbackEnabled?: boolean; currentFilter?: FilterType; onFilterChange?: (filter: FilterType) => void; + mediaFilter?: 'all' | 'photo' | 'video'; + onMediaFilterChange?: (filter: 'all' | 'photo' | 'video') => void; + showMediaFilter?: boolean; } export const PhotoFilterBar: React.FC = ({ @@ -47,6 +50,9 @@ export const PhotoFilterBar: React.FC = ({ feedbackEnabled = false, currentFilter = 'all', onFilterChange, + mediaFilter = 'all', + onMediaFilterChange, + showMediaFilter = false }) => { const { t } = useTranslation(); const [showSortMenu, setShowSortMenu] = useState(false); @@ -148,7 +154,7 @@ export const PhotoFilterBar: React.FC = ({ leftIcon={} className="text-xs md:text-sm whitespace-nowrap flex-shrink-0" > - {t('gallery.allPhotos')} ({photos.length}) + {showMediaFilter ? t('gallery.allMedia', 'All media') : t('gallery.allPhotos')} ({photos.length}) {categories.map((category) => { const categoryPhotoCount = photos.filter(p => p.category_id === category.id).length; @@ -226,10 +232,44 @@ export const PhotoFilterBar: React.FC = ({ )} - {photoCount} {photoCount === 1 ? t('common.photo') : t('common.photos')} + {photoCount} {t('common.media', 'media')} )} + + {showMediaFilter && onMediaFilterChange && ( + + + {t('gallery.mediaType', 'Media')} + + + onMediaFilterChange('all')} + className="text-xs md:text-sm" + > + {t('gallery.allMedia', 'All')} + + onMediaFilterChange('photo')} + className="text-xs md:text-sm" + > + {t('gallery.photosOnly', 'Photos')} + + onMediaFilterChange('video')} + className="text-xs md:text-sm" + > + {t('gallery.videosOnly', 'Videos')} + + + + )} {/* Mobile/Tablet: compact horizontal icons with headline below categories */} {feedbackEnabled && onFilterChange && ( diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index 8d07881..d4f65e8 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -3,7 +3,7 @@ import { useDevToolsProtection } from '../../hooks/useDevToolsProtection'; import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react'; import type { Photo } from '../../types'; import { useDownloadPhoto } from '../../hooks/useGallery'; -import { AuthenticatedImage } from '../common'; +import { AuthenticatedImage, AuthenticatedVideo } from '../common'; import { PhotoFeedback } from './PhotoFeedback'; import { feedbackService } from '../../services/feedback.service'; import { FeedbackIdentityModal } from './FeedbackIdentityModal'; @@ -64,6 +64,11 @@ export const PhotoLightbox: React.FC = ({ const downloadPhotoMutation = useDownloadPhoto(); const currentPhoto = photos[currentIndex]; + const isVideo = currentPhoto + ? (currentPhoto.media_type === 'video' || + (currentPhoto.mime_type && currentPhoto.mime_type.startsWith('video/')) || + currentPhoto.type === 'video') + : false; // DevTools protection for the lightbox when enhanced protection is enabled useDevToolsProtection({ @@ -361,27 +366,31 @@ export const PhotoLightbox: React.FC = ({ - - - - - {Math.round(zoom * 100)}% - - = 3} - className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed" - aria-label="Zoom in" - > - - - - + {!isVideo && ( + <> + + + + + {Math.round(zoom * 100)}% + + = 3} + className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + aria-label="Zoom in" + > + + + + + > + )} {allowDownloads && ( = ({ {/* Image container */} 1 ? (isDragging ? 'grabbing' : 'grab') : 'default', + cursor: isVideo ? 'default' : zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default', right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0, }} > - { - console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`); - - // Track analytics - if (typeof window !== 'undefined' && (window as any).umami) { - (window as any).umami.track('lightbox_protection_violation', { - photoId: currentPhoto.id, - violationType, - protectionLevel, - zoom - }); - } - - // For maximum protection, close lightbox on violation - if (protectionLevel === 'maximum' && - ['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) { - onClose(); - } - }} - /> + {isVideo ? ( + + ) : ( + { + console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`); + + // Track analytics + if (typeof window !== 'undefined' && (window as any).umami) { + (window as any).umami.track('lightbox_protection_violation', { + photoId: currentPhoto.id, + violationType, + protectionLevel, + zoom + }); + } + + // For maximum protection, close lightbox on violation + if (protectionLevel === 'maximum' && + ['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) { + onClose(); + } + }} + /> + )} {/* Touch/swipe indicators for mobile */} diff --git a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx index 01763fb..ffb828a 100644 --- a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Download, Maximize2, Check, MessageSquare, Star, Heart } from 'lucide-react'; +import { Download, Maximize2, Check, MessageSquare, Star, Heart, Video } from 'lucide-react'; import { useInView } from 'react-intersection-observer'; import { useTheme } from '../../../contexts/ThemeContext'; import { AuthenticatedImage } from '../../common'; @@ -155,6 +155,10 @@ const GridPhoto: React.FC = ({ ? 'opacity-100 md:opacity-100' : 'opacity-0 md:opacity-0'; + const isVideo = (photo.media_type === 'video') || + (photo.mime_type && photo.mime_type.startsWith('video/')) || + photo.type === 'video'; + const handlePhotoClick = (e: React.MouseEvent) => { if (isTouchDevice && !overlayVisible && !isSelectionMode) { e.preventDefault(); @@ -318,6 +322,15 @@ const GridPhoto: React.FC = ({ )} + {isVideo && ( + + + + {t('common.video', 'Video')} + + + )} + {photo.type === 'collage' && ( diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 5e52533..e3ef0f2 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -26,6 +26,9 @@ "uploaded": "Hochgeladen", "photo": "Foto", "photos": "Fotos", + "video": "Video", + "videos": "Videos", + "media": "Medien", "restore": "Wiederherstellen", "actions": "Aktionen", "refresh": "Aktualisieren", @@ -49,12 +52,15 @@ "eventSpecific": "(Veranstaltungsspezifisch)", "clickToUpload": "Klicken zum Hochladen oder per Drag & Drop", "fileRequirements": "JPEG, PNG oder WebP (max. 50MB pro Datei, {{limit}} Dateien pro Upload)", + "fileRequirementsMedia": "JPEG-, PNG- oder WebP-Bilder sowie MP4/MOV/WEBM-Videos (max. 50MB pro Datei, {{limit}} Dateien pro Upload)", + "unsupportedFiles": "Einige Dateien wurden übersprungen, da das Format nicht unterstützt wird (JPEG/PNG/WebP/MP4/MOV/WEBM verwenden).", "selectedFiles": "Ausgewählte Dateien", "uploading": "Wird hochgeladen...", "uploadComplete": "Upload abgeschlossen!", "uploadFailed": "Upload fehlgeschlagen", "someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden", "uploadPhotos": "Fotos hochladen", + "uploadMedia": "Fotos & Videos hochladen", "importExternal": "Aus externem Ordner importieren", "externalImportInfo": "Alle Bilder aus dem ausgewählten Ordner werden importiert.", "selectExternalFolder": "Externen Ordner unter /external-media auswählen", @@ -64,7 +70,9 @@ "tooManyFiles": "Maximal {{limit}} Dateien können gleichzeitig hochgeladen werden", "limitInfo": "{{selected}} von {{limit}} Dateien ausgewählt ({{remaining}} verbleibend)", "limitReached": "Upload-Limit erreicht ({{limit}} Dateien pro Vorgang)", - "uploadingChunks": "Lade {{count}} Dateien in {{total}} Teilen hoch..." + "uploadingChunks": "Lade {{count}} Dateien in {{total}} Teilen hoch...", + "mediaCategory": "Medienkategorie", + "uploadAction": "{{count}} Dateien hochladen" }, "navigation": { "dashboard": "Dashboard", @@ -513,6 +521,13 @@ "selectAll": "Alle auswählen", "deselectAll": "Auswahl aufheben", "downloadSelected": "{{count}} ausgewählte herunterladen", + "deleteSelected": "Ausgewählte löschen", + "photosCount": "{{count}} Foto", + "photosCount_plural": "{{count}} Fotos", + "searchByFilename": "Nach Dateinamen suchen...", + "uncategorized": "Ohne Kategorie", + "sortAscending": "Aufsteigend sortieren", + "sortDescending": "Absteigend sortieren", "remaining": "verbleibend", "selectPhotosHint": "Tipp: Verwenden Sie Strg+Klick (Cmd+Klick auf Mac), um schnell mehrere Fotos auszuwählen", "filters": "Filter", @@ -521,7 +536,12 @@ "toggleMenu": "Menü umschalten", "allCategories": "Alle Kategorien", "categories": "Kategorien", + "mediaType": "Medien", + "allMedia": "Alle Medien", + "photosOnly": "Fotos", + "videosOnly": "Videos", "download": "Herunterladen", + "noMedia": "Noch keine Medien hochgeladen", "searchPlaceholder": "Fotos suchen...", "sortBy": "Sortieren nach", "sortByDate": "Nach Datum sortieren", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index f94b2b9..d6e69e2 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -26,6 +26,9 @@ "uploaded": "Uploaded", "photo": "photo", "photos": "photos", + "video": "video", + "videos": "videos", + "media": "media", "restore": "Restore", "actions": "Actions", "refresh": "Refresh", @@ -49,12 +52,15 @@ "eventSpecific": "(Event specific)", "clickToUpload": "Click to upload or drag and drop", "fileRequirements": "JPEG, PNG or WebP (max 50MB per file, {{limit}} files per upload)", + "fileRequirementsMedia": "JPEG, PNG or WebP images, plus MP4/MOV/WEBM videos (max 50MB per file, {{limit}} files per upload)", + "unsupportedFiles": "Some files were skipped because the format is not supported (use JPEG/PNG/WebP/MP4/MOV/WEBM).", "selectedFiles": "Selected files", "uploading": "Uploading...", "uploadComplete": "Upload complete!", "uploadFailed": "Upload failed", "someFilesFailed": "Some files failed to upload", "uploadPhotos": "Upload Photos", + "uploadMedia": "Upload Photos & Videos", "importExternal": "Import from External Folder", "externalImportInfo": "All pictures from the selected folder will be imported.", "selectExternalFolder": "Select external folder under /external-media", @@ -64,7 +70,9 @@ "tooManyFiles": "Maximum {{limit}} files can be uploaded at once", "limitInfo": "{{selected}} of {{limit}} files selected ({{remaining}} remaining)", "limitReached": "Upload limit reached ({{limit}} files per batch)", - "uploadingChunks": "Uploading {{count}} files in {{total}} batches..." + "uploadingChunks": "Uploading {{count}} files in {{total}} batches...", + "mediaCategory": "Media category", + "uploadAction": "Upload {{count}} files" }, "navigation": { "dashboard": "Dashboard", @@ -178,6 +186,13 @@ "selectAll": "Select All", "deselectAll": "Deselect All", "downloadSelected": "Download {{count}} Selected", + "deleteSelected": "Delete Selected", + "photosCount": "{{count}} photo", + "photosCount_plural": "{{count}} photos", + "searchByFilename": "Search by filename...", + "uncategorized": "Uncategorized", + "sortAscending": "Sort ascending", + "sortDescending": "Sort descending", "remaining": "remaining", "selectPhotosHint": "Tip: Use Ctrl+Click (Cmd+Click on Mac) to quickly select multiple photos", "filters": "Filters", @@ -186,7 +201,12 @@ "toggleMenu": "Toggle menu", "allCategories": "All Categories", "categories": "Categories", + "mediaType": "Media", + "allMedia": "All media", + "photosOnly": "Photos", + "videosOnly": "Videos", "download": "Download", + "noMedia": "No media uploaded yet", "searchPlaceholder": "Search photos...", "sortBy": "Sort By", "sortByDate": "Sort by Date", diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index 6e2033b..fd1c63d 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { @@ -208,6 +208,26 @@ export const EventDetailsPage: React.FC = () => { enabled: !!id && (activeTab === 'photos' || isEditing), }); + const mediaTypes = useMemo(() => { + const types = new Set<'photo' | 'video'>(); + photos.forEach((p: any) => { + const mediaType = (p.media_type as 'photo' | 'video' | undefined) + || ((p.mime_type && String(p.mime_type).startsWith('video/')) || p.type === 'video' ? 'video' : 'photo'); + if (mediaType === 'video' || mediaType === 'photo') { + types.add(mediaType); + } + }); + return types; + }, [photos]); + + const showMediaFilter = mediaTypes.has('photo') && mediaTypes.has('video'); + + useEffect(() => { + if (!showMediaFilter && photoFilters.media_type) { + setPhotoFilters(prev => ({ ...prev, media_type: undefined })); + } + }, [showMediaFilter, photoFilters.media_type]); + // Fetch categories for the event const { data: categories = [] } = useQuery({ queryKey: ['admin-event-categories', id], @@ -1237,6 +1257,12 @@ export const EventDetailsPage: React.FC = () => { onCategoryChange={(categoryId) => setPhotoFilters(prev => ({ ...prev, category_id: categoryId }))} onSearchChange={(search) => setPhotoFilters(prev => ({ ...prev, search }))} onSortChange={(sort, order) => setPhotoFilters(prev => ({ ...prev, sort, order }))} + mediaType={photoFilters.media_type || 'all'} + onMediaTypeChange={(mediaType) => setPhotoFilters(prev => ({ + ...prev, + media_type: mediaType === 'all' ? undefined : mediaType + }))} + showMediaFilter={showMediaFilter} /> {/* Actions Bar */} diff --git a/frontend/src/services/photos.service.ts b/frontend/src/services/photos.service.ts index 6556af1..839c6eb 100644 --- a/frontend/src/services/photos.service.ts +++ b/frontend/src/services/photos.service.ts @@ -7,11 +7,13 @@ export interface AdminPhoto { url: string; thumbnail_url: string | null; type: string; - category_id: number | null; + category_id: number | string | null; category_name: string | null; category_slug: string | null; size: number; uploaded_at: string; + media_type?: 'photo' | 'video'; + mime_type?: string | null; view_count?: number; download_count?: number; // Feedback fields @@ -23,8 +25,9 @@ export interface AdminPhoto { } export interface PhotoFilters { - category_id?: number | null; + category_id?: number | string | null; type?: string; + media_type?: 'photo' | 'video'; search?: string; sort?: 'date' | 'name' | 'size' | 'rating'; order?: 'asc' | 'desc'; @@ -39,6 +42,7 @@ class PhotosService { params.append('category_id', filters.category_id?.toString() || ''); } if (filters.type) params.append('type', filters.type); + if (filters.media_type) params.append('media_type', filters.media_type); if (filters.search) params.append('search', filters.search); if (filters.sort) params.append('sort', filters.sort); if (filters.order) params.append('order', filters.order); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 3281c64..a9d88b0 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -55,8 +55,10 @@ export interface Photo { secure_url_template?: string; download_url_template?: string; requires_token?: boolean; - type: 'collage' | 'individual'; - category_id?: number; + type: 'collage' | 'individual' | 'video'; + media_type?: 'photo' | 'video'; + mime_type?: string; + category_id?: number | string | null; category_name?: string; category_slug?: string; size: number; @@ -71,7 +73,7 @@ export interface Photo { } export interface PhotoCategory { - id: number; + id: number | string; name: string; slug: string; is_global: boolean; diff --git a/video-test-data/bear-320x240.mp4 b/video-test-data/bear-320x240.mp4 new file mode 100644 index 0000000..1becba2 --- /dev/null +++ b/video-test-data/bear-320x240.mp4 @@ -0,0 +1 @@ +404: Not Found \ No newline at end of file diff --git a/video-test-data/bear-320x240.webm b/video-test-data/bear-320x240.webm new file mode 100644 index 0000000..a1b4150 Binary files /dev/null and b/video-test-data/bear-320x240.webm differ
No photos uploaded yet
{t('gallery.noMedia', 'No media uploaded yet')}
Failed to load image
Failed to load media
- {t('upload.fileRequirements', { limit: maxFilesPerUpload })} + {t('upload.fileRequirementsMedia', { limit: maxFilesPerUpload }) || t('upload.fileRequirements', { limit: maxFilesPerUpload })}
= ({ eventId, onUploadCompl ref={fileInputRef} type="file" multiple - accept="image/jpeg,image/png,image/webp" + accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime,video/webm" onChange={handleFileSelect} className="hidden" /> @@ -255,7 +261,11 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl className="flex items-center justify-between p-2 bg-neutral-50 rounded-lg" > - + {file.type.startsWith('video/') ? ( + + ) : ( + + )} {file.name} @@ -288,7 +298,9 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl disabled={selectedFiles.length === 0 || isUploading} leftIcon={isUploading ? : } > - {isUploading ? t('upload.uploading') : t('common.upload') + ` ${selectedFiles.length} ${t(selectedFiles.length === 1 ? 'common.photo' : 'common.photos')}`} + {isUploading + ? t('upload.uploading') + : t('upload.uploadAction', { count: selectedFiles.length }) || `Upload ${selectedFiles.length} files`} diff --git a/frontend/src/components/admin/PhotoUploadModal.tsx b/frontend/src/components/admin/PhotoUploadModal.tsx index fe92ace..939b0bb 100644 --- a/frontend/src/components/admin/PhotoUploadModal.tsx +++ b/frontend/src/components/admin/PhotoUploadModal.tsx @@ -33,7 +33,7 @@ export const PhotoUploadModal: React.FC = ({ {/* Fixed Header */} - {t('events.uploadPhotos')} + {t('upload.uploadMedia', t('events.uploadPhotos'))} = ({ ); }; -PhotoUploadModal.displayName = 'PhotoUploadModal'; \ No newline at end of file +PhotoUploadModal.displayName = 'PhotoUploadModal'; diff --git a/frontend/src/components/admin/index.ts b/frontend/src/components/admin/index.ts index 21ae03b..9a75a32 100644 --- a/frontend/src/components/admin/index.ts +++ b/frontend/src/components/admin/index.ts @@ -17,6 +17,7 @@ export { AdminPhotoViewer } from './AdminPhotoViewer'; export { PhotoFilters } from './PhotoFilters'; export { PasswordResetModal } from './PasswordResetModal'; export { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; +export { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo'; export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced'; export { ThemeDisplay } from './ThemeDisplay'; export { ThemeEditorModal } from './ThemeEditorModal'; @@ -29,4 +30,4 @@ export { BackupHistory } from './BackupHistory'; export { RestoreWizard } from './RestoreWizard'; export { FeedbackSettings } from './FeedbackSettings'; export { FeedbackModerationPanel } from './FeedbackModerationPanel'; -export { WordFilterManager } from './WordFilterManager'; \ No newline at end of file +export { WordFilterManager } from './WordFilterManager'; diff --git a/frontend/src/components/common/AuthenticatedVideo.tsx b/frontend/src/components/common/AuthenticatedVideo.tsx new file mode 100644 index 0000000..90532c1 --- /dev/null +++ b/frontend/src/components/common/AuthenticatedVideo.tsx @@ -0,0 +1,125 @@ +import React, { useEffect, useState } from 'react'; +import { buildResourceUrl } from '../../utils/url'; +import { + getActiveGallerySlug, + getGalleryToken, + inferGallerySlugFromLocation, + resolveSlugFromRequestUrl, +} from '../../utils/galleryAuthStorage'; + +interface AuthenticatedVideoProps extends React.VideoHTMLAttributes { + src: string; + fallbackSrc?: string; + slug?: string; +} + +export const AuthenticatedVideo: React.FC = ({ + src, + fallbackSrc, + slug, + ...props +}) => { + const [videoSrc, setVideoSrc] = useState(''); + const [error, setError] = useState(false); + + useEffect(() => { + let aborted = false; + const objectUrls: string[] = []; + + if (!src) { + setVideoSrc(''); + setError(true); + return; + } + + const resolveSlug = (candidateSrc?: string): string | null => { + if (slug) { + return slug; + } + const fromUrl = candidateSrc ? resolveSlugFromRequestUrl(candidateSrc) : null; + if (fromUrl) { + return fromUrl; + } + return getActiveGallerySlug() || inferGallerySlugFromLocation(); + }; + + const fetchWithAuth = async (rawUrl: string | undefined | null): Promise => { + if (!rawUrl) { + throw new Error('No URL provided'); + } + + const fullUrl = rawUrl.startsWith('/') + ? buildResourceUrl(rawUrl) + : rawUrl; + + const headers: Record = {}; + const slugForRequest = resolveSlug(rawUrl); + const token = getGalleryToken(slugForRequest); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(fullUrl, { + credentials: 'include', + headers: Object.keys(headers).length ? headers : undefined, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch media: ${response.status} ${response.statusText}`); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + objectUrls.push(objectUrl); + return objectUrl; + }; + + const load = async () => { + try { + const primaryUrl = await fetchWithAuth(src); + if (!aborted) { + setVideoSrc(primaryUrl); + setError(false); + } + } catch (err) { + if (fallbackSrc && fallbackSrc !== src) { + try { + const fallbackUrl = await fetchWithAuth(fallbackSrc); + if (!aborted) { + setVideoSrc(fallbackUrl); + setError(false); + } + return; + } catch (_) { + // ignore and set error below + } + } + if (!aborted) { + setError(true); + setVideoSrc(''); + } + } + }; + + load(); + + return () => { + aborted = true; + objectUrls.forEach((url) => URL.revokeObjectURL(url)); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [src, fallbackSrc, slug]); + + if (error || !videoSrc) { + return null; + } + + return ( + + ); +}; diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts index 7e84bdc..4ea4299 100644 --- a/frontend/src/components/common/index.ts +++ b/frontend/src/components/common/index.ts @@ -16,7 +16,8 @@ export { SkipLink } from './SkipLink'; export { DynamicFavicon } from './DynamicFavicon'; export { LanguageSelector } from './LanguageSelector'; export { AuthenticatedImage } from './AuthenticatedImage'; +export { AuthenticatedVideo } from './AuthenticatedVideo'; export { ProtectedImage } from './ProtectedImage'; export { ProtectionWarning } from './ProtectionWarning'; export { ReCaptcha } from './ReCaptcha'; -export { PasswordGenerator } from './PasswordGenerator'; \ No newline at end of file +export { PasswordGenerator } from './PasswordGenerator'; diff --git a/frontend/src/components/gallery/GallerySidebar.tsx b/frontend/src/components/gallery/GallerySidebar.tsx index f86660a..43a87ca 100644 --- a/frontend/src/components/gallery/GallerySidebar.tsx +++ b/frontend/src/components/gallery/GallerySidebar.tsx @@ -9,8 +9,8 @@ interface GallerySidebarProps { isOpen: boolean; onClose: () => void; categories: PhotoCategory[]; - selectedCategoryId: number | null; - onCategoryChange: (categoryId: number | null) => void; + selectedCategoryId: number | string | null; + onCategoryChange: (categoryId: number | string | null) => void; searchTerm: string; onSearchChange: (term: string) => void; sortBy: 'date' | 'name' | 'size' | 'rating'; @@ -22,7 +22,7 @@ interface GallerySidebarProps { onDownloadSelected: () => void; isDownloading: boolean; allowDownloads?: boolean; - photoCounts?: Record; + photoCounts?: Record; totalPhotos: number; isMobile: boolean; galleryLayout?: string; @@ -34,6 +34,9 @@ interface GallerySidebarProps { likeCount?: number; favoriteCount?: number; ratedCount?: number; + mediaFilter?: 'all' | 'photo' | 'video'; + onMediaFilterChange?: (filter: 'all' | 'photo' | 'video') => void; + showMediaFilter?: boolean; } export const GallerySidebar: React.FC = ({ @@ -64,7 +67,10 @@ export const GallerySidebar: React.FC = ({ onFilterChange, likeCount = 0, favoriteCount = 0, - ratedCount = 0 + ratedCount = 0, + mediaFilter = 'all', + onMediaFilterChange, + showMediaFilter = false }) => { const { t } = useTranslation(); const sidebarRef = useRef(null); @@ -288,6 +294,47 @@ export const GallerySidebar: React.FC = ({ )} + {showMediaFilter && onMediaFilterChange && ( + + + + {t('gallery.mediaType', 'Media')} + + + { + onMediaFilterChange('all'); + if (isMobile) onClose(); + }} + > + {t('gallery.allMedia', 'All')} + + { + onMediaFilterChange('photo'); + if (isMobile) onClose(); + }} + > + {t('gallery.photosOnly', 'Photos')} + + { + onMediaFilterChange('video'); + if (isMobile) onClose(); + }} + > + {t('gallery.videosOnly', 'Videos')} + + + + )} + {/* Sort Section - Hidden for carousel and timeline layouts */} {galleryLayout !== 'carousel' && galleryLayout !== 'timeline' && ( diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 65fd57f..c2a352d 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -44,7 +44,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { const { t } = useTranslation(); const { logout } = useGalleryAuth(); const { setTheme, theme } = useTheme(); - const [selectedCategoryId, setSelectedCategoryId] = useState(null); + const [selectedCategoryId, setSelectedCategoryId] = useState(null); const [searchTerm, setSearchTerm] = useState(''); const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating'>('date'); const [brandingSettings, setBrandingSettings] = useState(null); @@ -57,8 +57,22 @@ export const GalleryView: React.FC = ({ slug, event }) => { const { watermarkEnabled } = useWatermarkSettings(); const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard'); const [filterType, setFilterType] = useState('all'); + const [mediaFilter, setMediaFilter] = useState<'all' | 'photo' | 'video'>('all'); const [guestId, setGuestId] = useState(''); const [staticHeroPhoto, setStaticHeroPhoto] = useState(null); + + const resolveMediaType = (photo: Photo) => { + if (photo.media_type === 'video' || photo.media_type === 'photo') { + return photo.media_type; + } + if (photo.mime_type && photo.mime_type.startsWith('video/')) { + return 'video'; + } + if ((photo as any).type === 'video') { + return 'video'; + } + return 'photo'; + }; // Generate a unique guest ID for this session useEffect(() => { @@ -176,6 +190,25 @@ export const GalleryView: React.FC = ({ slug, event }) => { } }, [settingsData]); + const availableMediaTypes = useMemo(() => { + const types = new Set<'photo' | 'video'>(); + (data?.photos || []).forEach((photo) => { + const mediaType = resolveMediaType(photo); + if (mediaType === 'photo' || mediaType === 'video') { + types.add(mediaType); + } + }); + return types; + }, [data?.photos]); + + const showMediaFilter = availableMediaTypes.has('photo') && availableMediaTypes.has('video'); + + useEffect(() => { + if (!showMediaFilter && mediaFilter !== 'all') { + setMediaFilter('all'); + } + }, [showMediaFilter, mediaFilter]); + // Determine a stable hero photo from the initial (unfiltered) load useEffect(() => { if (!staticHeroPhoto && data?.photos && filterType === 'all') { @@ -185,7 +218,8 @@ export const GalleryView: React.FC = ({ slug, event }) => { hero = data.photos.find(p => p.id === heroId) || null; } if (!hero && data.photos.length > 0) { - hero = data.photos[0]; + const firstPhoto = data.photos.find(p => resolveMediaType(p) === 'photo'); + hero = firstPhoto || data.photos[0]; } if (hero) { setStaticHeroPhoto(hero); @@ -259,6 +293,12 @@ export const GalleryView: React.FC = ({ slug, event }) => { if (!data?.photos) return []; let photos = [...data.photos]; + + if (mediaFilter === 'photo') { + photos = photos.filter(photo => resolveMediaType(photo) !== 'video'); + } else if (mediaFilter === 'video') { + photos = photos.filter(photo => resolveMediaType(photo) === 'video'); + } // Apply category filter if (selectedCategoryId) { @@ -323,7 +363,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { } return photos; - }, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug, filterType]); + }, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug, filterType, mediaFilter]); const likeCount = useMemo( () => data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0, @@ -388,14 +428,20 @@ export const GalleryView: React.FC = ({ slug, event }) => { // Calculate photo counts per category const photoCounts = useMemo(() => { if (!data?.photos) return {}; - const counts: Record = {}; - data.photos.forEach(photo => { + const counts: Record = {}; + data.photos + .filter(photo => { + if (mediaFilter === 'photo') return resolveMediaType(photo) !== 'video'; + if (mediaFilter === 'video') return resolveMediaType(photo) === 'video'; + return true; + }) + .forEach(photo => { if (photo.category_id) { counts[photo.category_id] = (counts[photo.category_id] || 0) + 1; } }); return counts; - }, [data?.photos]); + }, [data?.photos, mediaFilter]); // Track search usage with debouncing useEffect(() => { @@ -497,6 +543,9 @@ export const GalleryView: React.FC = ({ slug, event }) => { feedbackEnabled={feedbackEnabled} filterType={filterType} onFilterChange={setFilterType} + mediaFilter={mediaFilter} + onMediaFilterChange={setMediaFilter} + showMediaFilter={showMediaFilter} likeCount={likeCount} favoriteCount={favoriteCount} ratedCount={ratedCount} @@ -582,16 +631,19 @@ export const GalleryView: React.FC = ({ slug, event }) => { onCategoryChange={setSelectedCategoryId} searchTerm={searchTerm} onSearchChange={setSearchTerm} - sortBy={sortBy} - onSortChange={setSortBy} - photoCount={filteredPhotos.length} - // Feedback filter props - feedbackEnabled={feedbackEnabled} - currentFilter={filterType} - onFilterChange={setFilterType} - /> - - ) : null} + sortBy={sortBy} + onSortChange={setSortBy} + photoCount={filteredPhotos.length} + // Feedback filter props + feedbackEnabled={feedbackEnabled} + currentFilter={filterType} + onFilterChange={setFilterType} + mediaFilter={mediaFilter} + onMediaFilterChange={setMediaFilter} + showMediaFilter={showMediaFilter} + /> + + ) : null} {/* Photo Grid */} diff --git a/frontend/src/components/gallery/PhotoFilterBar.tsx b/frontend/src/components/gallery/PhotoFilterBar.tsx index ba5b1a3..6caec6f 100644 --- a/frontend/src/components/gallery/PhotoFilterBar.tsx +++ b/frontend/src/components/gallery/PhotoFilterBar.tsx @@ -32,6 +32,9 @@ interface PhotoFilterBarProps { feedbackEnabled?: boolean; currentFilter?: FilterType; onFilterChange?: (filter: FilterType) => void; + mediaFilter?: 'all' | 'photo' | 'video'; + onMediaFilterChange?: (filter: 'all' | 'photo' | 'video') => void; + showMediaFilter?: boolean; } export const PhotoFilterBar: React.FC = ({ @@ -47,6 +50,9 @@ export const PhotoFilterBar: React.FC = ({ feedbackEnabled = false, currentFilter = 'all', onFilterChange, + mediaFilter = 'all', + onMediaFilterChange, + showMediaFilter = false }) => { const { t } = useTranslation(); const [showSortMenu, setShowSortMenu] = useState(false); @@ -148,7 +154,7 @@ export const PhotoFilterBar: React.FC = ({ leftIcon={} className="text-xs md:text-sm whitespace-nowrap flex-shrink-0" > - {t('gallery.allPhotos')} ({photos.length}) + {showMediaFilter ? t('gallery.allMedia', 'All media') : t('gallery.allPhotos')} ({photos.length}) {categories.map((category) => { const categoryPhotoCount = photos.filter(p => p.category_id === category.id).length; @@ -226,10 +232,44 @@ export const PhotoFilterBar: React.FC = ({ )} - {photoCount} {photoCount === 1 ? t('common.photo') : t('common.photos')} + {photoCount} {t('common.media', 'media')} )} + + {showMediaFilter && onMediaFilterChange && ( + + + {t('gallery.mediaType', 'Media')} + + + onMediaFilterChange('all')} + className="text-xs md:text-sm" + > + {t('gallery.allMedia', 'All')} + + onMediaFilterChange('photo')} + className="text-xs md:text-sm" + > + {t('gallery.photosOnly', 'Photos')} + + onMediaFilterChange('video')} + className="text-xs md:text-sm" + > + {t('gallery.videosOnly', 'Videos')} + + + + )} {/* Mobile/Tablet: compact horizontal icons with headline below categories */} {feedbackEnabled && onFilterChange && ( diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index 8d07881..d4f65e8 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -3,7 +3,7 @@ import { useDevToolsProtection } from '../../hooks/useDevToolsProtection'; import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react'; import type { Photo } from '../../types'; import { useDownloadPhoto } from '../../hooks/useGallery'; -import { AuthenticatedImage } from '../common'; +import { AuthenticatedImage, AuthenticatedVideo } from '../common'; import { PhotoFeedback } from './PhotoFeedback'; import { feedbackService } from '../../services/feedback.service'; import { FeedbackIdentityModal } from './FeedbackIdentityModal'; @@ -64,6 +64,11 @@ export const PhotoLightbox: React.FC = ({ const downloadPhotoMutation = useDownloadPhoto(); const currentPhoto = photos[currentIndex]; + const isVideo = currentPhoto + ? (currentPhoto.media_type === 'video' || + (currentPhoto.mime_type && currentPhoto.mime_type.startsWith('video/')) || + currentPhoto.type === 'video') + : false; // DevTools protection for the lightbox when enhanced protection is enabled useDevToolsProtection({ @@ -361,27 +366,31 @@ export const PhotoLightbox: React.FC = ({ - - - - - {Math.round(zoom * 100)}% - - = 3} - className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed" - aria-label="Zoom in" - > - - - - + {!isVideo && ( + <> + + + + + {Math.round(zoom * 100)}% + + = 3} + className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + aria-label="Zoom in" + > + + + + + > + )} {allowDownloads && ( = ({ {/* Image container */} 1 ? (isDragging ? 'grabbing' : 'grab') : 'default', + cursor: isVideo ? 'default' : zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default', right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0, }} > - { - console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`); - - // Track analytics - if (typeof window !== 'undefined' && (window as any).umami) { - (window as any).umami.track('lightbox_protection_violation', { - photoId: currentPhoto.id, - violationType, - protectionLevel, - zoom - }); - } - - // For maximum protection, close lightbox on violation - if (protectionLevel === 'maximum' && - ['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) { - onClose(); - } - }} - /> + {isVideo ? ( + + ) : ( + { + console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`); + + // Track analytics + if (typeof window !== 'undefined' && (window as any).umami) { + (window as any).umami.track('lightbox_protection_violation', { + photoId: currentPhoto.id, + violationType, + protectionLevel, + zoom + }); + } + + // For maximum protection, close lightbox on violation + if (protectionLevel === 'maximum' && + ['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) { + onClose(); + } + }} + /> + )} {/* Touch/swipe indicators for mobile */} diff --git a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx index 01763fb..ffb828a 100644 --- a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Download, Maximize2, Check, MessageSquare, Star, Heart } from 'lucide-react'; +import { Download, Maximize2, Check, MessageSquare, Star, Heart, Video } from 'lucide-react'; import { useInView } from 'react-intersection-observer'; import { useTheme } from '../../../contexts/ThemeContext'; import { AuthenticatedImage } from '../../common'; @@ -155,6 +155,10 @@ const GridPhoto: React.FC = ({ ? 'opacity-100 md:opacity-100' : 'opacity-0 md:opacity-0'; + const isVideo = (photo.media_type === 'video') || + (photo.mime_type && photo.mime_type.startsWith('video/')) || + photo.type === 'video'; + const handlePhotoClick = (e: React.MouseEvent) => { if (isTouchDevice && !overlayVisible && !isSelectionMode) { e.preventDefault(); @@ -318,6 +322,15 @@ const GridPhoto: React.FC = ({ )} + {isVideo && ( + + + + {t('common.video', 'Video')} + + + )} + {photo.type === 'collage' && ( diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 5e52533..e3ef0f2 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -26,6 +26,9 @@ "uploaded": "Hochgeladen", "photo": "Foto", "photos": "Fotos", + "video": "Video", + "videos": "Videos", + "media": "Medien", "restore": "Wiederherstellen", "actions": "Aktionen", "refresh": "Aktualisieren", @@ -49,12 +52,15 @@ "eventSpecific": "(Veranstaltungsspezifisch)", "clickToUpload": "Klicken zum Hochladen oder per Drag & Drop", "fileRequirements": "JPEG, PNG oder WebP (max. 50MB pro Datei, {{limit}} Dateien pro Upload)", + "fileRequirementsMedia": "JPEG-, PNG- oder WebP-Bilder sowie MP4/MOV/WEBM-Videos (max. 50MB pro Datei, {{limit}} Dateien pro Upload)", + "unsupportedFiles": "Einige Dateien wurden übersprungen, da das Format nicht unterstützt wird (JPEG/PNG/WebP/MP4/MOV/WEBM verwenden).", "selectedFiles": "Ausgewählte Dateien", "uploading": "Wird hochgeladen...", "uploadComplete": "Upload abgeschlossen!", "uploadFailed": "Upload fehlgeschlagen", "someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden", "uploadPhotos": "Fotos hochladen", + "uploadMedia": "Fotos & Videos hochladen", "importExternal": "Aus externem Ordner importieren", "externalImportInfo": "Alle Bilder aus dem ausgewählten Ordner werden importiert.", "selectExternalFolder": "Externen Ordner unter /external-media auswählen", @@ -64,7 +70,9 @@ "tooManyFiles": "Maximal {{limit}} Dateien können gleichzeitig hochgeladen werden", "limitInfo": "{{selected}} von {{limit}} Dateien ausgewählt ({{remaining}} verbleibend)", "limitReached": "Upload-Limit erreicht ({{limit}} Dateien pro Vorgang)", - "uploadingChunks": "Lade {{count}} Dateien in {{total}} Teilen hoch..." + "uploadingChunks": "Lade {{count}} Dateien in {{total}} Teilen hoch...", + "mediaCategory": "Medienkategorie", + "uploadAction": "{{count}} Dateien hochladen" }, "navigation": { "dashboard": "Dashboard", @@ -513,6 +521,13 @@ "selectAll": "Alle auswählen", "deselectAll": "Auswahl aufheben", "downloadSelected": "{{count}} ausgewählte herunterladen", + "deleteSelected": "Ausgewählte löschen", + "photosCount": "{{count}} Foto", + "photosCount_plural": "{{count}} Fotos", + "searchByFilename": "Nach Dateinamen suchen...", + "uncategorized": "Ohne Kategorie", + "sortAscending": "Aufsteigend sortieren", + "sortDescending": "Absteigend sortieren", "remaining": "verbleibend", "selectPhotosHint": "Tipp: Verwenden Sie Strg+Klick (Cmd+Klick auf Mac), um schnell mehrere Fotos auszuwählen", "filters": "Filter", @@ -521,7 +536,12 @@ "toggleMenu": "Menü umschalten", "allCategories": "Alle Kategorien", "categories": "Kategorien", + "mediaType": "Medien", + "allMedia": "Alle Medien", + "photosOnly": "Fotos", + "videosOnly": "Videos", "download": "Herunterladen", + "noMedia": "Noch keine Medien hochgeladen", "searchPlaceholder": "Fotos suchen...", "sortBy": "Sortieren nach", "sortByDate": "Nach Datum sortieren", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index f94b2b9..d6e69e2 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -26,6 +26,9 @@ "uploaded": "Uploaded", "photo": "photo", "photos": "photos", + "video": "video", + "videos": "videos", + "media": "media", "restore": "Restore", "actions": "Actions", "refresh": "Refresh", @@ -49,12 +52,15 @@ "eventSpecific": "(Event specific)", "clickToUpload": "Click to upload or drag and drop", "fileRequirements": "JPEG, PNG or WebP (max 50MB per file, {{limit}} files per upload)", + "fileRequirementsMedia": "JPEG, PNG or WebP images, plus MP4/MOV/WEBM videos (max 50MB per file, {{limit}} files per upload)", + "unsupportedFiles": "Some files were skipped because the format is not supported (use JPEG/PNG/WebP/MP4/MOV/WEBM).", "selectedFiles": "Selected files", "uploading": "Uploading...", "uploadComplete": "Upload complete!", "uploadFailed": "Upload failed", "someFilesFailed": "Some files failed to upload", "uploadPhotos": "Upload Photos", + "uploadMedia": "Upload Photos & Videos", "importExternal": "Import from External Folder", "externalImportInfo": "All pictures from the selected folder will be imported.", "selectExternalFolder": "Select external folder under /external-media", @@ -64,7 +70,9 @@ "tooManyFiles": "Maximum {{limit}} files can be uploaded at once", "limitInfo": "{{selected}} of {{limit}} files selected ({{remaining}} remaining)", "limitReached": "Upload limit reached ({{limit}} files per batch)", - "uploadingChunks": "Uploading {{count}} files in {{total}} batches..." + "uploadingChunks": "Uploading {{count}} files in {{total}} batches...", + "mediaCategory": "Media category", + "uploadAction": "Upload {{count}} files" }, "navigation": { "dashboard": "Dashboard", @@ -178,6 +186,13 @@ "selectAll": "Select All", "deselectAll": "Deselect All", "downloadSelected": "Download {{count}} Selected", + "deleteSelected": "Delete Selected", + "photosCount": "{{count}} photo", + "photosCount_plural": "{{count}} photos", + "searchByFilename": "Search by filename...", + "uncategorized": "Uncategorized", + "sortAscending": "Sort ascending", + "sortDescending": "Sort descending", "remaining": "remaining", "selectPhotosHint": "Tip: Use Ctrl+Click (Cmd+Click on Mac) to quickly select multiple photos", "filters": "Filters", @@ -186,7 +201,12 @@ "toggleMenu": "Toggle menu", "allCategories": "All Categories", "categories": "Categories", + "mediaType": "Media", + "allMedia": "All media", + "photosOnly": "Photos", + "videosOnly": "Videos", "download": "Download", + "noMedia": "No media uploaded yet", "searchPlaceholder": "Search photos...", "sortBy": "Sort By", "sortByDate": "Sort by Date", diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index 6e2033b..fd1c63d 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { @@ -208,6 +208,26 @@ export const EventDetailsPage: React.FC = () => { enabled: !!id && (activeTab === 'photos' || isEditing), }); + const mediaTypes = useMemo(() => { + const types = new Set<'photo' | 'video'>(); + photos.forEach((p: any) => { + const mediaType = (p.media_type as 'photo' | 'video' | undefined) + || ((p.mime_type && String(p.mime_type).startsWith('video/')) || p.type === 'video' ? 'video' : 'photo'); + if (mediaType === 'video' || mediaType === 'photo') { + types.add(mediaType); + } + }); + return types; + }, [photos]); + + const showMediaFilter = mediaTypes.has('photo') && mediaTypes.has('video'); + + useEffect(() => { + if (!showMediaFilter && photoFilters.media_type) { + setPhotoFilters(prev => ({ ...prev, media_type: undefined })); + } + }, [showMediaFilter, photoFilters.media_type]); + // Fetch categories for the event const { data: categories = [] } = useQuery({ queryKey: ['admin-event-categories', id], @@ -1237,6 +1257,12 @@ export const EventDetailsPage: React.FC = () => { onCategoryChange={(categoryId) => setPhotoFilters(prev => ({ ...prev, category_id: categoryId }))} onSearchChange={(search) => setPhotoFilters(prev => ({ ...prev, search }))} onSortChange={(sort, order) => setPhotoFilters(prev => ({ ...prev, sort, order }))} + mediaType={photoFilters.media_type || 'all'} + onMediaTypeChange={(mediaType) => setPhotoFilters(prev => ({ + ...prev, + media_type: mediaType === 'all' ? undefined : mediaType + }))} + showMediaFilter={showMediaFilter} /> {/* Actions Bar */} diff --git a/frontend/src/services/photos.service.ts b/frontend/src/services/photos.service.ts index 6556af1..839c6eb 100644 --- a/frontend/src/services/photos.service.ts +++ b/frontend/src/services/photos.service.ts @@ -7,11 +7,13 @@ export interface AdminPhoto { url: string; thumbnail_url: string | null; type: string; - category_id: number | null; + category_id: number | string | null; category_name: string | null; category_slug: string | null; size: number; uploaded_at: string; + media_type?: 'photo' | 'video'; + mime_type?: string | null; view_count?: number; download_count?: number; // Feedback fields @@ -23,8 +25,9 @@ export interface AdminPhoto { } export interface PhotoFilters { - category_id?: number | null; + category_id?: number | string | null; type?: string; + media_type?: 'photo' | 'video'; search?: string; sort?: 'date' | 'name' | 'size' | 'rating'; order?: 'asc' | 'desc'; @@ -39,6 +42,7 @@ class PhotosService { params.append('category_id', filters.category_id?.toString() || ''); } if (filters.type) params.append('type', filters.type); + if (filters.media_type) params.append('media_type', filters.media_type); if (filters.search) params.append('search', filters.search); if (filters.sort) params.append('sort', filters.sort); if (filters.order) params.append('order', filters.order); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 3281c64..a9d88b0 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -55,8 +55,10 @@ export interface Photo { secure_url_template?: string; download_url_template?: string; requires_token?: boolean; - type: 'collage' | 'individual'; - category_id?: number; + type: 'collage' | 'individual' | 'video'; + media_type?: 'photo' | 'video'; + mime_type?: string; + category_id?: number | string | null; category_name?: string; category_slug?: string; size: number; @@ -71,7 +73,7 @@ export interface Photo { } export interface PhotoCategory { - id: number; + id: number | string; name: string; slug: string; is_global: boolean; diff --git a/video-test-data/bear-320x240.mp4 b/video-test-data/bear-320x240.mp4 new file mode 100644 index 0000000..1becba2 --- /dev/null +++ b/video-test-data/bear-320x240.mp4 @@ -0,0 +1 @@ +404: Not Found \ No newline at end of file diff --git a/video-test-data/bear-320x240.webm b/video-test-data/bear-320x240.webm new file mode 100644 index 0000000..a1b4150 Binary files /dev/null and b/video-test-data/bear-320x240.webm differ
{file.name} @@ -288,7 +298,9 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl disabled={selectedFiles.length === 0 || isUploading} leftIcon={isUploading ? : } > - {isUploading ? t('upload.uploading') : t('common.upload') + ` ${selectedFiles.length} ${t(selectedFiles.length === 1 ? 'common.photo' : 'common.photos')}`} + {isUploading + ? t('upload.uploading') + : t('upload.uploadAction', { count: selectedFiles.length }) || `Upload ${selectedFiles.length} files`}
- {photoCount} {photoCount === 1 ? t('common.photo') : t('common.photos')} + {photoCount} {t('common.media', 'media')}