Add video support, media filters, and translations
Build and Push Docker Images / build-backend (push) Failing after 14m2s
Build and Push Docker Images / build-frontend (push) Failing after 44m43s
Build and Push Docker Images / summary (push) Successful in 3s

This commit is contained in:
2025-11-28 13:29:44 +01:00
parent bce5f749b1
commit 9a75f1c929
28 changed files with 1635 additions and 294 deletions
+156 -85
View File
@@ -4,12 +4,14 @@ const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor'); const { generateThumbnail, ensureThumbnail, generateVideoPlaceholder } = require('../services/imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer'); const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { escapeLikePattern } = require('../utils/sqlSecurity'); const { escapeLikePattern } = require('../utils/sqlSecurity');
const { validateUploadedFiles } = require('../middleware/uploadValidation'); const { validateUploadedFiles } = require('../middleware/uploadValidation');
const { getMaxFilesPerUpload } = require('../services/uploadSettings'); const { getMaxFilesPerUpload } = require('../services/uploadSettings');
const router = express.Router(); const router = express.Router();
const { isVideoMimeType, validateFileType, createFileUploadValidator } = require('../utils/fileSecurityUtils');
const mime = require('mime-types');
// Get storage path from environment or default // Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); 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({ const upload = multer({
storage: storage, storage: storage,
limits: { limits: {
@@ -75,24 +75,22 @@ const upload = multer({
headerPairs: 2000 // Maximum number of header key-value pairs headerPairs: 2000 // Maximum number of header key-value pairs
}, },
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
// Accept images only with proper validation // Accept images and common video formats with proper validation
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp']; const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'video/quicktime', 'video/webm'];
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) { if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
return cb(null, true); return cb(null, true);
} else { } 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 // Add abort on limit to stop processing when limits are exceeded
abortOnLimit: true abortOnLimit: true
}); });
const { createFileUploadValidator } = require('../utils/fileSecurityUtils');
// Create content validator middleware // Create content validator middleware
const validateUploadContent = createFileUploadValidator({ 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, maxFileSize: 50 * 1024 * 1024,
validateContent: true validateContent: true
}); });
@@ -186,25 +184,13 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
// Parse category_id to number if provided // Parse category_id to number if provided
const numericCategoryId = parseCategoryId(category_id); const numericCategoryId = parseCategoryId(category_id);
// Determine photo type from category_id parameter (for backwards compatibility) const resolveCategoryName = (type) => {
let photoType = 'individual'; // default if (type === 'collage') return 'collages';
let categoryName = 'individual'; if (type === 'video') return 'videos';
return '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';
}
// Create final destination directory // Create final destination directory
const finalDestPath = path.join(getStoragePath(), 'events/active', event.slug); const finalDestPath = path.join(getStoragePath(), 'events/active', event.slug);
await fs.mkdir(finalDestPath, { recursive: true }); 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(); const trx = await db.transaction();
try { try {
// Get initial counter for this batch based on photo type const preparedBatch = batch.map((file) => {
const existingCount = await trx('photos') const resolvedMime = file?.mimetype || mime.lookup(file?.originalname || '') || 'application/octet-stream';
.where({ event_id: eventId, type: photoType }) const video = isVideoMimeType(resolvedMime, file?.originalname);
.count('id as count') let inferredType = video ? 'video' : 'individual';
.first();
let batchCounter = (parseInt(existingCount.count) || 0) + 1; 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 batchPhotos = [];
const fileRenameOperations = []; // Store rename operations to do after commit const fileRenameOperations = []; // Store rename operations to do after commit
// First pass: prepare data and move files from temp to final location // First pass: prepare data and move files from temp to final location
for (let fileIndex = 0; fileIndex < batch.length; fileIndex++) { for (let fileIndex = 0; fileIndex < preparedBatch.length; fileIndex++) {
const file = batch[fileIndex]; const { file, resolvedMime, isVideo, photoType } = preparedBatch[fileIndex];
const counter = batchCounter + fileIndex;
const tempPath = file.path; // Original temp path const tempPath = file.path; // Original temp path
try { try {
@@ -244,12 +259,15 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
if (tempStats.size === 0) { if (tempStats.size === 0) {
throw new Error('File is empty - upload may have been interrupted'); throw new Error('File is empty - upload may have been interrupted');
} }
typeCounters[photoType] = (typeCounters[photoType] || 0) + 1;
const counter = typeCounters[photoType];
// Generate new filename // Generate new filename
const extension = path.extname(file.originalname); const extension = path.extname(file.originalname);
const newFilename = generatePhotoFilename( const newFilename = generatePhotoFilename(
event.event_name, event.event_name,
categoryName, resolveCategoryName(photoType),
counter, counter,
extension extension
); );
@@ -268,7 +286,8 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
type: photoType, type: photoType,
size_bytes: tempStats.size, // Use actual file size from stat size_bytes: tempStats.size, // Use actual file size from stat
category_id: numericCategoryId, category_id: numericCategoryId,
source_origin: 'managed' source_origin: 'managed',
mime_type: resolvedMime
}; };
batchPhotos.push(photoData); batchPhotos.push(photoData);
@@ -278,7 +297,8 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
tempPath: tempPath, tempPath: tempPath,
finalPath: finalPath, finalPath: finalPath,
filename: newFilename, filename: newFilename,
photoData: photoData photoData: photoData,
isVideo
}); });
} catch (error) { } catch (error) {
console.error(`Error preparing file ${file.originalname}:`, 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 // Insert all photos in this batch
if (batchPhotos.length > 0) { 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'); 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 // Generate thumbnail with final path
let thumbnailPath = null; let thumbnailPath = null;
try { try {
thumbnailPath = await generateThumbnail(operation.finalPath); thumbnailPath = operation.isVideo
? await generateVideoPlaceholder(operation.filename)
// Update the database with thumbnail path : await generateThumbnail(operation.finalPath);
if (thumbnailPath && insertedIds[idx]) {
const photoId = insertedIds[idx]?.id || insertedIds[idx]; // Update the database with thumbnail path
await db('photos') if (thumbnailPath && insertedIds[idx]) {
.where({ id: photoId }) const photoId = insertedIds[idx]?.id || insertedIds[idx];
.update({ thumbnail_path: thumbnailPath }); await db('photos')
} .where({ id: photoId })
} catch (thumbError) { .update({ thumbnail_path: thumbnailPath });
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message); }
} } catch (thumbError) {
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
}
// Add to successful uploads // Add to successful uploads
uploadedPhotos.push({ uploadedPhotos.push({
id: insertedIds[idx]?.id || insertedIds[idx], id: insertedIds[idx]?.id || insertedIds[idx],
filename: operation.filename, filename: operation.filename,
size: operation.photoData.size_bytes, 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) { } catch (moveError) {
console.error(`Failed to move file ${operation.tempPath} to ${operation.finalPath}:`, 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 // Prepare response
const totalAttempted = req.files.length + (req.invalidFiles ? req.invalidFiles.length : 0); const totalAttempted = req.files.length + (req.invalidFiles ? req.invalidFiles.length : 0);
const response = { const response = {
message: `Successfully uploaded ${uploadedPhotos.length} photos`, message: `Successfully uploaded ${uploadedPhotos.length} files`,
photos: uploadedPhotos, photos: uploadedPhotos,
totalFiles: totalAttempted, totalFiles: totalAttempted,
successCount: uploadedPhotos.length, successCount: uploadedPhotos.length,
@@ -410,7 +434,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
// Include error details if any files failed // Include error details if any files failed
if (totalInvalidFiles.length > 0) { if (totalInvalidFiles.length > 0) {
response.errors = totalInvalidFiles; 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); 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 // Delete thumbnail if exists
if (photo.thumbnail_path) { if (photo.thumbnail_path) {
const thumbPath = path.join(storagePath, 'events/active', photo.thumbnail_path); const thumbPath = path.join(storagePath, photo.thumbnail_path);
try { try {
// Check if file exists before attempting to delete // Check if file exists before attempting to delete
await fs.access(thumbPath); 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) => { router.get('/:eventId/photos', adminAuth, async (req, res) => {
try { try {
const { eventId } = req.params; 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') let query = db('photos')
.leftJoin('photo_categories as pc', 'pc.id', 'photos.category_id') .leftJoin('photo_categories as pc', 'pc.id', 'photos.category_id')
@@ -738,6 +762,20 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
if (type) { if (type) {
query = query.where({ 'photos.type': 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 // Search by filename
if (search) { if (search) {
@@ -775,28 +813,37 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
}); });
res.json({ res.json({
photos: photos.map(photo => ({ photos: photos.map(photo => {
id: photo.id, const mediaType = (photo.mime_type && photo.mime_type.startsWith('video/')) || photo.type === 'video' ? 'video' : 'photo';
filename: photo.filename, const categoryName = photo.category_display_name
// Use the correct admin photos router base for serving images || (photo.type === 'individual' ? 'Individual Photos' : photo.type === 'video' ? 'Videos' : 'Collages');
url: `/admin/photos/${eventId}/photo/${photo.id}`, const normalizedCategoryId = photo.category_id !== null && photo.category_id !== undefined
// Always expose a thumbnail URL; backend will generate on demand if missing ? (Number.isNaN(Number(photo.category_id)) ? photo.category_id : Number(photo.category_id))
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`, : null;
type: photo.type,
category_id: photo.category_id !== null && photo.category_id !== undefined return ({
? Number(photo.category_id) id: photo.id,
: null, filename: photo.filename,
category_name: photo.category_display_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'), // Use the correct admin photos router base for serving images
category_slug: photo.category_display_slug || photo.type, url: `/admin/photos/${eventId}/photo/${photo.id}`,
size: photo.size_bytes, // Always expose a thumbnail URL; backend will generate on demand if missing
uploaded_at: photo.uploaded_at, thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
// Feedback data type: photo.type,
has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0), category_id: normalizedCategoryId,
average_rating: photo.average_rating || 0, mime_type: photo.mime_type,
comment_count: commentMap[photo.id] || 0, media_type: mediaType,
like_count: photo.like_count || 0, category_name: categoryName,
favorite_count: photo.favorite_count || 0 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) { } catch (error) {
console.error('Error fetching photos:', 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' }); 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 // 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('Cache-Control', 'private, max-age=3600');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); 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' }); 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 // 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) { if (!thumbnailPath) {
console.error(`Failed to generate thumbnail for photo ${photoId}`); console.error(`Failed to generate thumbnail for photo ${photoId}`);
+93 -27
View File
@@ -10,6 +10,8 @@ const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { resolvePhotoFilePath } = require('../services/photoResolver'); const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService'); 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 // Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage'); const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
@@ -248,10 +250,17 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
.distinct('type') .distinct('type')
.orderBy('type', 'asc'); .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 // Convert types to category-like objects
const categories = categoryResults.map(result => ({ const categories = categoryResults.map(result => ({
id: result.type, id: result.type,
name: result.type === 'individual' ? 'Individual Photos' : 'Collages', name: resolveCategoryName(result.type),
slug: result.type, slug: result.type,
is_global: false is_global: false
})); }));
@@ -292,10 +301,13 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
}, },
categories: categories, categories: categories,
photos: photos.map(photo => { 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 ? const photoUrl = useJwtUrl ?
`/api/gallery/${req.params.slug}/photo/${photo.id}` : `/api/gallery/${req.params.slug}/photo/${photo.id}` :
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`; `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
const categoryName = resolveCategoryName(photo.type, photo.mime_type, photo.filename);
return { return {
id: photo.id, 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}}`, download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
type: photo.type, type: photo.type,
category_id: photo.type, category_id: photo.type,
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages', category_name: categoryName,
category_slug: photo.type, category_slug: photo.type,
size: photo.size_bytes, size: photo.size_bytes,
uploaded_at: photo.uploaded_at, uploaded_at: photo.uploaded_at,
media_type: mediaType,
mime_type: photo.mime_type,
// Fixed: Use the calculated useJwtUrl variable instead of recalculating // Fixed: Use the calculated useJwtUrl variable instead of recalculating
requires_token: !useJwtUrl, requires_token: !useJwtUrl && !isVideo,
// Feedback data // Feedback data
has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0), has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0),
average_rating: photo.average_rating || 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' }); return res.status(404).json({ error: 'Photo not found' });
} }
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename);
// Update download count // Update download count
await db('photos').where('id', photoId).increment('download_count', 1); 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 // Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings(); const watermarkSettings = await watermarkService.getWatermarkSettings();
if (watermarkSettings && watermarkSettings.enabled) { if (watermarkSettings && watermarkSettings.enabled && !isVideo) {
// Apply watermark and send // Apply watermark and send
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
@@ -386,6 +401,9 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
res.send(watermarkedBuffer); res.send(watermarkedBuffer);
} else { } else {
// Send original file // Send original file
if (isVideo) {
res.set({ 'Content-Type': photo.mime_type || 'application/octet-stream' });
}
res.download(filePath, photo.filename, (downloadError) => { res.download(filePath, photo.filename, (downloadError) => {
if (downloadError) { if (downloadError) {
logger.error('Error streaming gallery download', { logger.error('Error streaming gallery download', {
@@ -463,14 +481,16 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
let archiveName; let archiveName;
if (hasMultipleTypes) { if (hasMultipleTypes) {
// Use photo type as folder // 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); archiveName = path.join(folderName, photo.filename);
} else { } else {
// No folders, just the filename // No folders, just the filename
archiveName = photo.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 { try {
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
archive.append(watermarkedBuffer, { name: archiveName }); archive.append(watermarkedBuffer, { name: archiveName });
@@ -565,7 +585,9 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
try { try {
const filePath = resolvePhotoFilePath(req.event, photo); const filePath = resolvePhotoFilePath(req.event, photo);
const name = photo.filename || `photo-${photo.id}.jpg`; 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 { try {
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
archive.append(watermarkedBuffer, { name }); archive.append(watermarkedBuffer, { name });
@@ -615,9 +637,13 @@ router.get('/:slug/photo/:photoId',
async (req, res) => { async (req, res) => {
try { try {
const { photoId } = req.params; 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') const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id }) .where({ id: numericPhotoId, event_id: req.event.id })
.first(); .first();
@@ -625,10 +651,11 @@ router.get('/:slug/photo/:photoId',
return res.status(404).json({ error: 'Photo not found' }); 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 // Check protection level - basic and standard protection allow direct JWT access
const protectionLevel = req.event.protection_level || 'standard'; 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 // For enhanced/maximum protection, redirect to secure endpoint
return res.status(302).json({ return res.status(302).json({
error: 'Secure access required', error: 'Secure access required',
@@ -653,7 +680,7 @@ router.get('/:slug/photo/:photoId',
// Get watermark settings // Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings(); const watermarkSettings = await watermarkService.getWatermarkSettings();
if (watermarkSettings && watermarkSettings.enabled) { if (watermarkSettings && watermarkSettings.enabled && !isVideo) {
// Apply watermark and send // Apply watermark and send
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
@@ -672,6 +699,9 @@ router.get('/:slug/photo/:photoId',
}); });
// Ensure absolute path for res.sendFile // Ensure absolute path for res.sendFile
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath); const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
if (isVideo) {
res.set({ 'Content-Type': photo.mime_type || 'application/octet-stream' });
}
res.sendFile(absolutePath); res.sendFile(absolutePath);
} }
} catch (error) { } catch (error) {
@@ -692,32 +722,62 @@ router.get('/:slug/thumbnail/:photoId',
async (req, res) => { async (req, res) => {
try { try {
const { photoId } = req.params; const { photoId } = req.params;
const numericPhotoId = parseInt(photoId, 10);
const photo = await db('photos') if (!Number.isInteger(numericPhotoId)) {
.where({ id: photoId, event_id: req.event.id }) return res.status(400).json({ error: 'Invalid photo id' });
.first();
if (!photo || !photo.thumbnail_path) {
return res.status(404).json({ error: 'Thumbnail not found' });
} }
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 // Check if file exists
const fs = require('fs').promises; const fs = require('fs').promises;
try { try {
await fs.access(thumbPath); await fs.access(thumbFilePath);
} catch (error) { } catch (error) {
return res.status(404).json({ error: 'Thumbnail file not found' }); return res.status(404).json({ error: 'Thumbnail file not found' });
} }
// Log thumbnail access // Log thumbnail access
await secureImageService.logImageAccess( try {
photoId, await secureImageService.logImageAccess(
req.event.id, numericPhotoId,
req.clientInfo, req.event.id,
'thumbnail' 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 // Set appropriate headers with enhanced security
res.set({ res.set({
@@ -729,8 +789,14 @@ router.get('/:slug/thumbnail/:photoId',
}); });
// Send file // Send file
res.sendFile(path.resolve(thumbPath)); res.sendFile(path.resolve(thumbFilePath));
} catch (error) { } 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:', { logger.error('Error serving thumbnail:', {
error: error.message, error: error.message,
photoId: req.params.photoId, photoId: req.params.photoId,
+18 -7
View File
@@ -3,8 +3,10 @@ const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { generateThumbnail } = require('./imageProcessor'); const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
const logger = require('../utils/logger'); 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 getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const WATCH_PATH = () => path.join(getStoragePath(), 'events/active'); const WATCH_PATH = () => path.join(getStoragePath(), 'events/active');
@@ -47,9 +49,11 @@ async function processNewPhoto(filePath) {
const eventSlug = pathParts[0]; const eventSlug = pathParts[0];
const photoType = pathParts[1] === 'collages' ? 'collage' : 'individual'; 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(); 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 // Skip temporary upload files
const filename = path.basename(filePath); const filename = path.basename(filePath);
@@ -65,11 +69,17 @@ async function processNewPhoto(filePath) {
// Get file stats // Get file stats
const stats = await fs.stat(filePath); const stats = await fs.stat(filePath);
// Generate thumbnail // Generate thumbnail or placeholder
const thumbnailPath = await generateThumbnail(filePath); let thumbnailPath = null;
if (isVideo) {
thumbnailPath = await generateVideoPlaceholder(filename);
} else {
thumbnailPath = await generateThumbnail(filePath);
}
// Calculate relative thumbnail path // Calculate relative thumbnail path
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
const mimeType = detectedMime || (isVideo ? 'video/mp4' : 'image/jpeg');
// Check if photo already exists // Check if photo already exists
const existingPhoto = await db('photos') const existingPhoto = await db('photos')
@@ -83,8 +93,9 @@ async function processNewPhoto(filePath) {
filename: path.basename(filePath), filename: path.basename(filePath),
path: relativePath, path: relativePath,
thumbnail_path: relativeThumbPath, thumbnail_path: relativeThumbPath,
type: photoType, type: isVideo ? 'video' : photoType,
size_bytes: stats.size size_bytes: stats.size,
mime_type: mimeType
}); });
logger.info(`Added new photo: ${relativePath}`); logger.info(`Added new photo: ${relativePath}`);
+51 -1
View File
@@ -237,4 +237,54 @@ async function ensureThumbnail(photo) {
return null; 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 = `
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0f172a" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#1e293b" stop-opacity="0.9"/>
</linearGradient>
</defs>
<rect width="${width}" height="${height}" rx="18" fill="url(#grad)"/>
<circle cx="${width / 2}" cy="${height / 2}" r="${Math.min(width, height) / 6}" fill="rgba(255,255,255,0.85)"/>
<polygon points="${width / 2 - 10},${height / 2 - 14} ${width / 2 - 10},${height / 2 + 14} ${width / 2 + 16},${height / 2}" fill="#0f172a"/>
<text x="50%" y="${height - 18}" font-family="Arial, sans-serif" font-size="16" fill="rgba(255,255,255,0.9)" text-anchor="middle">
VIDEO
</text>
</svg>
`;
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 };
+28 -15
View File
@@ -1,8 +1,10 @@
const path = require('path'); const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const { db } = require('../database/db'); const { db } = require('../database/db');
const { generateThumbnail } = require('./imageProcessor'); const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer'); const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { isVideoMimeType } = require('../utils/fileSecurityUtils');
const mime = require('mime-types');
// Get storage path from environment or default // Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); 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(); const trx = await db.transaction();
try { 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 // Count existing photos to generate sequence number
let counter = 1; 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 is provided and matches photo types, use it as type
if (categoryId === 'collage') { if (!isVideo && categoryId === 'collage') {
photoType = 'collage'; photoType = 'collage';
} }
@@ -90,7 +95,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
// Generate new filename // Generate new filename
const extension = path.extname(file.originalname); const extension = path.extname(file.originalname);
const categoryName = photoType === 'collage' ? 'collages' : 'individual'; const categoryName = photoType === 'collage' ? 'collages' : (isVideo ? 'videos' : 'individual');
const newFilename = generatePhotoFilename( const newFilename = generatePhotoFilename(
event.event_name, event.event_name,
categoryName, categoryName,
@@ -150,8 +155,13 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
} }
} }
// Generate thumbnail // Generate thumbnail or placeholder
const thumbnailPath = await generateThumbnail(newPath); let thumbnailPath = null;
if (isVideo) {
thumbnailPath = await generateVideoPlaceholder(newFilename);
} else {
thumbnailPath = await generateThumbnail(newPath);
}
// Calculate relative paths // Calculate relative paths
const storagePath = getStoragePath(); const storagePath = getStoragePath();
@@ -173,20 +183,22 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
type: photoType, type: photoType,
size_bytes: file.size, size_bytes: file.size,
uploaded_by: uploadedBy, uploaded_by: uploadedBy,
source_origin: 'managed' source_origin: 'managed',
mime_type: resolvedMime
}) })
.returning('id'); .returning('id');
} else { } else {
insertResult = await trx('photos').insert({ insertResult = await trx('photos').insert({
event_id: eventId, event_id: eventId,
filename: newFilename, filename: newFilename,
path: relativePath, path: relativePath,
thumbnail_path: relativeThumbPath, thumbnail_path: relativeThumbPath,
type: photoType, type: photoType,
size_bytes: file.size, size_bytes: file.size,
uploaded_by: uploadedBy, uploaded_by: uploadedBy,
source_origin: 'managed' source_origin: 'managed',
}); mime_type: resolvedMime
});
} }
const insertedId = Array.isArray(insertResult) const insertedId = Array.isArray(insertResult)
@@ -206,7 +218,8 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
id: photoId, id: photoId,
filename: newFilename, filename: newFilename,
size: file.size, size: file.size,
type: photoType type: photoType,
media_type: isVideo ? 'video' : 'photo'
}); });
console.log(`Successfully processed file ${file.originalname} (ID: ${photoId})`); console.log(`Successfully processed file ${file.originalname} (ID: ${photoId})`);
+38 -2
View File
@@ -78,6 +78,21 @@ const ALLOWED_IMAGE_TYPES = {
extensions: ['.svg'], extensions: ['.svg'],
// SVG files are XML-based text files, so we skip magic number validation // SVG files are XML-based text files, so we skip magic number validation
magicNumbers: null 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}`; 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 * Create a file upload validator middleware
* @param {Object} options - Validation options * @param {Object} options - Validation options
@@ -229,5 +264,6 @@ module.exports = {
validateFileContent, validateFileContent,
getSafeFilename, getSafeFilename,
createFileUploadValidator, createFileUploadValidator,
ALLOWED_IMAGE_TYPES ALLOWED_IMAGE_TYPES,
}; isVideoMimeType
};
+571 -1
View File
@@ -102,6 +102,27 @@
"node": ">=6.0.0" "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": { "node_modules/@babel/code-frame": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -393,6 +414,121 @@
"node": ">=6.9.0" "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": { "node_modules/@epic-web/invariant": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", "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" "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": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -2353,6 +2499,16 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0" "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": { "node_modules/assertion-error": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -2774,12 +2930,84 @@
"node": ">=4" "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": { "node_modules/csstype": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT" "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": { "node_modules/date-fns": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "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": { "node_modules/deep-eql": {
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@@ -3653,6 +3888,16 @@
"node": ">= 0.4" "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": { "node_modules/hoist-non-react-statics": {
"version": "3.3.2", "version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", "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" "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": { "node_modules/html-parse-stringify": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
@@ -3671,6 +3929,34 @@
"void-elements": "3.1.0" "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": { "node_modules/i18next": {
"version": "25.3.2", "version": "25.3.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.2.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.2.tgz",
@@ -3720,6 +4006,19 @@
"cross-fetch": "4.0.0" "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": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3839,6 +4138,13 @@
"node": ">=0.12.0" "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": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -3891,6 +4197,84 @@
"js-yaml": "bin/js-yaml.js" "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": { "node_modules/jsesc": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "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" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -4300,6 +4695,13 @@
"node": ">=0.10.0" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -4395,6 +4797,32 @@
"node": ">=6" "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": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -5281,6 +5709,13 @@
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT" "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": { "node_modules/run-parallel": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -5305,6 +5740,26 @@
"queue-microtask": "^1.2.2" "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": { "node_modules/scheduler": {
"version": "0.23.2", "version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -5590,6 +6045,13 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/tailwind-merge": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
@@ -5762,6 +6224,26 @@
"@popperjs/core": "^2.9.0" "@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": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -5775,6 +6257,19 @@
"node": ">=8.0" "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": { "node_modules/tr46": {
"version": "0.0.3", "version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -5818,7 +6313,7 @@
"version": "5.8.3", "version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -6145,12 +6640,48 @@
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT" "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": { "node_modules/webidl-conversions": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause" "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": { "node_modules/whatwg-url": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "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" "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": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -0,0 +1,77 @@
import React, { useEffect, useState } from 'react';
import { api } from '../../config/api';
interface AdminAuthenticatedVideoProps extends React.VideoHTMLAttributes<HTMLVideoElement> {
src: string;
fallback?: React.ReactNode;
}
export const AdminAuthenticatedVideo: React.FC<AdminAuthenticatedVideoProps> = ({
src,
fallback,
...props
}) => {
const [videoSrc, setVideoSrc] = useState<string | null>(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 <div className="w-full h-full bg-neutral-200 animate-pulse" />;
}
if (error || !videoSrc) {
return fallback ? (
<>{fallback}</>
) : (
<div className="w-full h-full bg-neutral-100 flex items-center justify-center text-neutral-400">
<span className="text-xs">Failed to load</span>
</div>
);
}
return (
<video
src={videoSrc}
controls
preload="metadata"
{...props}
/>
);
};
@@ -1,6 +1,7 @@
import React, { useState } from 'react'; 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 { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import { AdminPhoto } from '../../services/photos.service'; import { AdminPhoto } from '../../services/photos.service';
import { photosService } from '../../services/photos.service'; import { photosService } from '../../services/photos.service';
@@ -20,6 +21,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
onPhotoClick, onPhotoClick,
onPhotosDeleted onPhotosDeleted
}) => { }) => {
const { t } = useTranslation();
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set()); const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
const [isSelectionMode, setIsSelectionMode] = useState(false); const [isSelectionMode, setIsSelectionMode] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
@@ -126,7 +128,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
onClick={toggleSelectionMode} onClick={toggleSelectionMode}
leftIcon={<Package className="w-4 h-4" />} leftIcon={<Package className="w-4 h-4" />}
> >
{isSelectionMode ? 'Cancel Selection' : 'Select Photos'} {isSelectionMode ? t('gallery.cancelSelection', 'Cancel Selection') : t('gallery.selectPhotos', 'Select Photos')}
</Button> </Button>
{(isSelectionMode || selectedPhotos.size > 0) && ( {(isSelectionMode || selectedPhotos.size > 0) && (
@@ -136,13 +138,13 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
size="sm" size="sm"
onClick={handleSelectAll} onClick={handleSelectAll}
> >
{selectedPhotos.size === photos.length ? 'Deselect All' : 'Select All'} {selectedPhotos.size === photos.length ? t('gallery.deselectAll', 'Deselect All') : t('gallery.selectAll', 'Select All')}
</Button> </Button>
{selectedPhotos.size > 0 && ( {selectedPhotos.size > 0 && (
<> <>
<span className="text-sm text-neutral-600"> <span className="text-sm text-neutral-600">
{selectedPhotos.size} selected {t('gallery.photosSelected', { count: selectedPhotos.size })}
</span> </span>
<button <button
onClick={handleDeleteSelected} onClick={handleDeleteSelected}
@@ -150,7 +152,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
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" 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"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
Delete Selected {t('gallery.deleteSelected', 'Delete Selected')}
</button> </button>
</> </>
)} )}
@@ -159,7 +161,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
</div> </div>
<div className="text-sm text-neutral-600"> <div className="text-sm text-neutral-600">
{photos.length} photo{photos.length !== 1 ? 's' : ''} {t('gallery.photosCount', { count: photos.length })}
</div> </div>
</div> </div>
@@ -170,6 +172,9 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
const commentCount = photo.comment_count ?? 0; const commentCount = photo.comment_count ?? 0;
const averageRating = photo.average_rating ?? 0; const averageRating = photo.average_rating ?? 0;
const likeCount = photo.like_count ?? 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 ( return (
<div <div
key={photo.id} key={photo.id}
@@ -259,6 +264,15 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
</span> </span>
</div> </div>
)} )}
{isVideo && (
<div className="absolute bottom-2 left-2 pointer-events-none">
<span className="px-2 py-1 text-[11px] font-semibold bg-black/70 text-white rounded flex items-center gap-1">
<Video className="w-3 h-3" />
{t('common.video', 'Video')}
</span>
</div>
)}
{/* Feedback Indicators (moved to bottom-right to avoid covering category) */} {/* Feedback Indicators (moved to bottom-right to avoid covering category) */}
{(commentCount > 0 || averageRating > 0 || likeCount > 0) && ( {(commentCount > 0 || averageRating > 0 || likeCount > 0) && (
@@ -284,7 +298,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
{photos.length === 0 && ( {photos.length === 0 && (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-neutral-500">No photos uploaded yet</p> <p className="text-neutral-500">{t('gallery.noMedia', 'No media uploaded yet')}</p>
</div> </div>
)} )}
</div> </div>
@@ -9,6 +9,7 @@ import { photosService } from '../../services/photos.service';
import { feedbackService, type PhotoFeedback, type FeedbackSummary } from '../../services/feedback.service'; import { feedbackService, type PhotoFeedback, type FeedbackSummary } from '../../services/feedback.service';
import { Button } from '../common'; import { Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
import { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
type AdminFeedbackResponse = { type AdminFeedbackResponse = {
feedback: PhotoFeedback[]; feedback: PhotoFeedback[];
@@ -39,6 +40,11 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const currentPhoto = photos[currentIndex]; 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 averageRating = currentPhoto?.average_rating ?? 0;
const likeCount = currentPhoto?.like_count ?? 0; const likeCount = currentPhoto?.like_count ?? 0;
const favoriteCount = currentPhoto?.favorite_count ?? 0; const favoriteCount = currentPhoto?.favorite_count ?? 0;
@@ -191,19 +197,35 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
<div className="flex flex-col lg:flex-row gap-6 max-w-7xl mx-auto p-4 w-full h-full"> <div className="flex flex-col lg:flex-row gap-6 max-w-7xl mx-auto p-4 w-full h-full">
{/* Image */} {/* Image */}
<div className="flex-1 flex items-center justify-center min-h-0"> <div className="flex-1 flex items-center justify-center min-h-0">
<AdminAuthenticatedImage {isVideo ? (
src={currentPhoto.url} <AdminAuthenticatedVideo
alt={currentPhoto.filename} src={currentPhoto.url}
className="max-w-full max-h-full object-contain" className="max-w-full max-h-full bg-black"
fallback={ poster={currentPhoto.thumbnail_url || undefined}
<div className="flex items-center justify-center text-neutral-400"> fallback={
<div className="text-center"> <div className="flex items-center justify-center text-neutral-400">
<Eye className="w-12 h-12 mx-auto mb-2" /> <div className="text-center">
<p className="text-sm">Failed to load image</p> <Eye className="w-12 h-12 mx-auto mb-2" />
<p className="text-sm">Failed to load media</p>
</div>
</div> </div>
</div> }
} />
/> ) : (
<AdminAuthenticatedImage
src={currentPhoto.url}
alt={currentPhoto.filename}
className="max-w-full max-h-full object-contain"
fallback={
<div className="flex items-center justify-center text-neutral-400">
<div className="text-center">
<Eye className="w-12 h-12 mx-auto mb-2" />
<p className="text-sm">Failed to load image</p>
</div>
</div>
}
/>
)}
</div> </div>
{/* Sidebar */} {/* Sidebar */}
+42 -14
View File
@@ -1,16 +1,20 @@
import React from 'react'; import React from 'react';
import { Search, Filter, SortAsc, SortDesc } from 'lucide-react'; import { Search, Filter, SortAsc, SortDesc } from 'lucide-react';
import { Input } from '../common'; import { Input } from '../common';
import { useTranslation } from 'react-i18next';
interface PhotoFiltersProps { interface PhotoFiltersProps {
categories: Array<{ id: number; name: string; slug: string }>; categories: Array<{ id: number | string; name: string; slug: string }>;
selectedCategory: number | null | undefined; selectedCategory: number | string | null | undefined;
searchTerm: string; searchTerm: string;
sortBy: 'date' | 'name' | 'size' | 'rating'; sortBy: 'date' | 'name' | 'size' | 'rating';
sortOrder: 'asc' | 'desc'; sortOrder: 'asc' | 'desc';
onCategoryChange: (categoryId: number | null | undefined) => void; onCategoryChange: (categoryId: number | string | null | undefined) => void;
onSearchChange: (search: string) => void; onSearchChange: (search: string) => void;
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating', order: 'asc' | 'desc') => 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<PhotoFiltersProps> = ({ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
@@ -21,8 +25,12 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
sortOrder, sortOrder,
onCategoryChange, onCategoryChange,
onSearchChange, onSearchChange,
onSortChange onSortChange,
mediaType = 'all',
onMediaTypeChange,
showMediaFilter = false
}) => { }) => {
const { t } = useTranslation();
const handleSortToggle = () => { const handleSortToggle = () => {
onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc'); onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc');
}; };
@@ -34,7 +42,7 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
<div className="flex-1"> <div className="flex-1">
<Input <Input
type="text" type="text"
placeholder="Search by filename..." placeholder={t('gallery.searchByFilename', 'Search by filename...')}
value={searchTerm} value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)} onChange={(e) => onSearchChange(e.target.value)}
leftIcon={<Search className="w-5 h-5 text-neutral-400" />} leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
@@ -46,11 +54,16 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
<Filter className="w-5 h-5 text-neutral-400" /> <Filter className="w-5 h-5 text-neutral-400" />
<select <select
value={selectedCategory === null ? '' : selectedCategory || ''} value={selectedCategory === null ? '' : selectedCategory || ''}
onChange={(e) => 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" className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
> >
<option value="">All Categories</option> <option value="">{t('gallery.allCategories', 'All Categories')}</option>
<option value="0">Uncategorized</option> <option value="0">{t('gallery.uncategorized', 'Uncategorized')}</option>
{categories.map(cat => ( {categories.map(cat => (
<option key={cat.id} value={cat.id}> <option key={cat.id} value={cat.id}>
{cat.name} {cat.name}
@@ -59,6 +72,21 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
</select> </select>
</div> </div>
{showMediaFilter && onMediaTypeChange && (
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-neutral-400" />
<select
value={mediaType}
onChange={(e) => 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"
>
<option value="all">{t('gallery.allMedia', 'All media')}</option>
<option value="photo">{t('gallery.photosOnly', 'Photos only')}</option>
<option value="video">{t('gallery.videosOnly', 'Videos only')}</option>
</select>
</div>
)}
{/* Sort Options */} {/* Sort Options */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<select <select
@@ -66,16 +94,16 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size' | 'rating', sortOrder)} 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" className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
> >
<option value="date">Sort by Date</option> <option value="date">{t('gallery.sortByDate', 'Sort by Date')}</option>
<option value="name">Sort by Name</option> <option value="name">{t('gallery.sortByName', 'Sort by Name')}</option>
<option value="size">Sort by Size</option> <option value="size">{t('gallery.sortBySize', 'Sort by Size')}</option>
<option value="rating">Sort by Rating</option> <option value="rating">{t('gallery.sortByRating', 'Sort by Rating')}</option>
</select> </select>
<button <button
onClick={handleSortToggle} onClick={handleSortToggle}
className="p-2 border border-neutral-300 rounded-lg hover:bg-neutral-50 transition-colors" className="p-2 border border-neutral-300 rounded-lg hover:bg-neutral-50 transition-colors"
aria-label={sortOrder === 'asc' ? 'Sort descending' : 'Sort ascending'} aria-label={sortOrder === 'asc' ? t('gallery.sortDescending', 'Sort descending') : t('gallery.sortAscending', 'Sort ascending')}
> >
{sortOrder === 'asc' ? ( {sortOrder === 'asc' ? (
<SortAsc className="w-5 h-5 text-neutral-600" /> <SortAsc className="w-5 h-5 text-neutral-600" />
@@ -87,4 +115,4 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
</div> </div>
</div> </div>
); );
}; };
+24 -12
View File
@@ -1,5 +1,5 @@
import React, { useState, useRef } from 'react'; 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 { Button } from '../common';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { api } from '../../config/api'; import { api } from '../../config/api';
@@ -51,12 +51,18 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
const imageFiles = files.filter(file => const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'video/quicktime', 'video/webm'];
['image/jpeg', 'image/png', 'image/webp'].includes(file.type) 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 // Check total file count with existing files
const totalFiles = selectedFiles.length + imageFiles.length; const totalFiles = selectedFiles.length + allowedFiles.length;
if (totalFiles > maxFilesPerUpload) { if (totalFiles > maxFilesPerUpload) {
const allowedNewFiles = maxFilesPerUpload - selectedFiles.length; const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
if (allowedNewFiles <= 0) { if (allowedNewFiles <= 0) {
@@ -70,11 +76,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) || t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) ||
`Only ${allowedNewFiles} more files can be added (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; return;
} }
setSelectedFiles(prev => [...prev, ...imageFiles]); setSelectedFiles(prev => [...prev, ...allowedFiles]);
}; };
const removeFile = (index: number) => { const removeFile = (index: number) => {
@@ -186,7 +192,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
{/* Category Selection */} {/* Category Selection */}
<div> <div>
<label className="block text-sm font-medium text-neutral-700 mb-2"> <label className="block text-sm font-medium text-neutral-700 mb-2">
{t('upload.photoCategory')} {t('upload.mediaCategory', 'Media category')}
</label> </label>
<select <select
value={selectedCategoryId || ''} value={selectedCategoryId || ''}
@@ -216,7 +222,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
{t('upload.clickToUpload')} {t('upload.clickToUpload')}
</p> </p>
<p className="text-sm text-neutral-500"> <p className="text-sm text-neutral-500">
{t('upload.fileRequirements', { limit: maxFilesPerUpload })} {t('upload.fileRequirementsMedia', { limit: maxFilesPerUpload }) || t('upload.fileRequirements', { limit: maxFilesPerUpload })}
</p> </p>
<p <p
className={clsx( className={clsx(
@@ -236,7 +242,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
multiple multiple
accept="image/jpeg,image/png,image/webp" accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime,video/webm"
onChange={handleFileSelect} onChange={handleFileSelect}
className="hidden" className="hidden"
/> />
@@ -255,7 +261,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
className="flex items-center justify-between p-2 bg-neutral-50 rounded-lg" className="flex items-center justify-between p-2 bg-neutral-50 rounded-lg"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Image className="w-5 h-5 text-neutral-400" /> {file.type.startsWith('video/') ? (
<Video className="w-5 h-5 text-neutral-400" />
) : (
<Image className="w-5 h-5 text-neutral-400" />
)}
<div> <div>
<p className="text-sm font-medium text-neutral-700 truncate max-w-xs"> <p className="text-sm font-medium text-neutral-700 truncate max-w-xs">
{file.name} {file.name}
@@ -288,7 +298,9 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
disabled={selectedFiles.length === 0 || isUploading} disabled={selectedFiles.length === 0 || isUploading}
leftIcon={isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />} leftIcon={isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
> >
{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`}
</Button> </Button>
</div> </div>
@@ -33,7 +33,7 @@ export const PhotoUploadModal: React.FC<PhotoUploadModalProps> = ({
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl flex flex-col max-h-[90vh]"> <div className="bg-white rounded-lg shadow-xl w-full max-w-2xl flex flex-col max-h-[90vh]">
{/* Fixed Header */} {/* Fixed Header */}
<div className="flex items-center justify-between p-6 border-b border-neutral-200"> <div className="flex items-center justify-between p-6 border-b border-neutral-200">
<h2 className="text-xl font-semibold text-neutral-900">{t('events.uploadPhotos')}</h2> <h2 className="text-xl font-semibold text-neutral-900">{t('upload.uploadMedia', t('events.uploadPhotos'))}</h2>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -56,4 +56,4 @@ export const PhotoUploadModal: React.FC<PhotoUploadModalProps> = ({
); );
}; };
PhotoUploadModal.displayName = 'PhotoUploadModal'; PhotoUploadModal.displayName = 'PhotoUploadModal';
+2 -1
View File
@@ -17,6 +17,7 @@ export { AdminPhotoViewer } from './AdminPhotoViewer';
export { PhotoFilters } from './PhotoFilters'; export { PhotoFilters } from './PhotoFilters';
export { PasswordResetModal } from './PasswordResetModal'; export { PasswordResetModal } from './PasswordResetModal';
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
export { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced'; export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
export { ThemeDisplay } from './ThemeDisplay'; export { ThemeDisplay } from './ThemeDisplay';
export { ThemeEditorModal } from './ThemeEditorModal'; export { ThemeEditorModal } from './ThemeEditorModal';
@@ -29,4 +30,4 @@ export { BackupHistory } from './BackupHistory';
export { RestoreWizard } from './RestoreWizard'; export { RestoreWizard } from './RestoreWizard';
export { FeedbackSettings } from './FeedbackSettings'; export { FeedbackSettings } from './FeedbackSettings';
export { FeedbackModerationPanel } from './FeedbackModerationPanel'; export { FeedbackModerationPanel } from './FeedbackModerationPanel';
export { WordFilterManager } from './WordFilterManager'; export { WordFilterManager } from './WordFilterManager';
@@ -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<HTMLVideoElement> {
src: string;
fallbackSrc?: string;
slug?: string;
}
export const AuthenticatedVideo: React.FC<AuthenticatedVideoProps> = ({
src,
fallbackSrc,
slug,
...props
}) => {
const [videoSrc, setVideoSrc] = useState<string>('');
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<string> => {
if (!rawUrl) {
throw new Error('No URL provided');
}
const fullUrl = rawUrl.startsWith('/')
? buildResourceUrl(rawUrl)
: rawUrl;
const headers: Record<string, string> = {};
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 (
<video
src={videoSrc}
controls
preload="metadata"
{...props}
/>
);
};
+2 -1
View File
@@ -16,7 +16,8 @@ export { SkipLink } from './SkipLink';
export { DynamicFavicon } from './DynamicFavicon'; export { DynamicFavicon } from './DynamicFavicon';
export { LanguageSelector } from './LanguageSelector'; export { LanguageSelector } from './LanguageSelector';
export { AuthenticatedImage } from './AuthenticatedImage'; export { AuthenticatedImage } from './AuthenticatedImage';
export { AuthenticatedVideo } from './AuthenticatedVideo';
export { ProtectedImage } from './ProtectedImage'; export { ProtectedImage } from './ProtectedImage';
export { ProtectionWarning } from './ProtectionWarning'; export { ProtectionWarning } from './ProtectionWarning';
export { ReCaptcha } from './ReCaptcha'; export { ReCaptcha } from './ReCaptcha';
export { PasswordGenerator } from './PasswordGenerator'; export { PasswordGenerator } from './PasswordGenerator';
@@ -9,8 +9,8 @@ interface GallerySidebarProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
categories: PhotoCategory[]; categories: PhotoCategory[];
selectedCategoryId: number | null; selectedCategoryId: number | string | null;
onCategoryChange: (categoryId: number | null) => void; onCategoryChange: (categoryId: number | string | null) => void;
searchTerm: string; searchTerm: string;
onSearchChange: (term: string) => void; onSearchChange: (term: string) => void;
sortBy: 'date' | 'name' | 'size' | 'rating'; sortBy: 'date' | 'name' | 'size' | 'rating';
@@ -22,7 +22,7 @@ interface GallerySidebarProps {
onDownloadSelected: () => void; onDownloadSelected: () => void;
isDownloading: boolean; isDownloading: boolean;
allowDownloads?: boolean; allowDownloads?: boolean;
photoCounts?: Record<number, number>; photoCounts?: Record<number | string, number>;
totalPhotos: number; totalPhotos: number;
isMobile: boolean; isMobile: boolean;
galleryLayout?: string; galleryLayout?: string;
@@ -34,6 +34,9 @@ interface GallerySidebarProps {
likeCount?: number; likeCount?: number;
favoriteCount?: number; favoriteCount?: number;
ratedCount?: number; ratedCount?: number;
mediaFilter?: 'all' | 'photo' | 'video';
onMediaFilterChange?: (filter: 'all' | 'photo' | 'video') => void;
showMediaFilter?: boolean;
} }
export const GallerySidebar: React.FC<GallerySidebarProps> = ({ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
@@ -64,7 +67,10 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
onFilterChange, onFilterChange,
likeCount = 0, likeCount = 0,
favoriteCount = 0, favoriteCount = 0,
ratedCount = 0 ratedCount = 0,
mediaFilter = 'all',
onMediaFilterChange,
showMediaFilter = false
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const sidebarRef = useRef<HTMLDivElement>(null); const sidebarRef = useRef<HTMLDivElement>(null);
@@ -288,6 +294,47 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
</div> </div>
)} )}
{showMediaFilter && onMediaFilterChange && (
<div className="p-4 border-b border-neutral-200">
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
<Filter className="w-4 h-4" />
{t('gallery.mediaType', 'Media')}
</h3>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant={mediaFilter === 'all' ? 'primary' : 'outline'}
size="sm"
onClick={() => {
onMediaFilterChange('all');
if (isMobile) onClose();
}}
>
{t('gallery.allMedia', 'All')}
</Button>
<Button
variant={mediaFilter === 'photo' ? 'primary' : 'outline'}
size="sm"
onClick={() => {
onMediaFilterChange('photo');
if (isMobile) onClose();
}}
>
{t('gallery.photosOnly', 'Photos')}
</Button>
<Button
variant={mediaFilter === 'video' ? 'primary' : 'outline'}
size="sm"
onClick={() => {
onMediaFilterChange('video');
if (isMobile) onClose();
}}
>
{t('gallery.videosOnly', 'Videos')}
</Button>
</div>
</div>
)}
{/* Sort Section - Hidden for carousel and timeline layouts */} {/* Sort Section - Hidden for carousel and timeline layouts */}
{galleryLayout !== 'carousel' && galleryLayout !== 'timeline' && ( {galleryLayout !== 'carousel' && galleryLayout !== 'timeline' && (
<div className="p-4"> <div className="p-4">
+68 -16
View File
@@ -44,7 +44,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { logout } = useGalleryAuth(); const { logout } = useGalleryAuth();
const { setTheme, theme } = useTheme(); const { setTheme, theme } = useTheme();
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null); const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating'>('date'); const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating'>('date');
const [brandingSettings, setBrandingSettings] = useState<any>(null); const [brandingSettings, setBrandingSettings] = useState<any>(null);
@@ -57,8 +57,22 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const { watermarkEnabled } = useWatermarkSettings(); const { watermarkEnabled } = useWatermarkSettings();
const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard'); const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard');
const [filterType, setFilterType] = useState<FilterType>('all'); const [filterType, setFilterType] = useState<FilterType>('all');
const [mediaFilter, setMediaFilter] = useState<'all' | 'photo' | 'video'>('all');
const [guestId, setGuestId] = useState<string>(''); const [guestId, setGuestId] = useState<string>('');
const [staticHeroPhoto, setStaticHeroPhoto] = useState<Photo | null>(null); const [staticHeroPhoto, setStaticHeroPhoto] = useState<Photo | null>(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 // Generate a unique guest ID for this session
useEffect(() => { useEffect(() => {
@@ -176,6 +190,25 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
} }
}, [settingsData]); }, [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 // Determine a stable hero photo from the initial (unfiltered) load
useEffect(() => { useEffect(() => {
if (!staticHeroPhoto && data?.photos && filterType === 'all') { if (!staticHeroPhoto && data?.photos && filterType === 'all') {
@@ -185,7 +218,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
hero = data.photos.find(p => p.id === heroId) || null; hero = data.photos.find(p => p.id === heroId) || null;
} }
if (!hero && data.photos.length > 0) { 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) { if (hero) {
setStaticHeroPhoto(hero); setStaticHeroPhoto(hero);
@@ -259,6 +293,12 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
if (!data?.photos) return []; if (!data?.photos) return [];
let photos = [...data.photos]; 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 // Apply category filter
if (selectedCategoryId) { if (selectedCategoryId) {
@@ -323,7 +363,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
} }
return photos; return photos;
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug, filterType]); }, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug, filterType, mediaFilter]);
const likeCount = useMemo( const likeCount = useMemo(
() => data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0, () => data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0,
@@ -388,14 +428,20 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
// Calculate photo counts per category // Calculate photo counts per category
const photoCounts = useMemo(() => { const photoCounts = useMemo(() => {
if (!data?.photos) return {}; if (!data?.photos) return {};
const counts: Record<number, number> = {}; const counts: Record<number | string, number> = {};
data.photos.forEach(photo => { 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) { if (photo.category_id) {
counts[photo.category_id] = (counts[photo.category_id] || 0) + 1; counts[photo.category_id] = (counts[photo.category_id] || 0) + 1;
} }
}); });
return counts; return counts;
}, [data?.photos]); }, [data?.photos, mediaFilter]);
// Track search usage with debouncing // Track search usage with debouncing
useEffect(() => { useEffect(() => {
@@ -497,6 +543,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
feedbackEnabled={feedbackEnabled} feedbackEnabled={feedbackEnabled}
filterType={filterType} filterType={filterType}
onFilterChange={setFilterType} onFilterChange={setFilterType}
mediaFilter={mediaFilter}
onMediaFilterChange={setMediaFilter}
showMediaFilter={showMediaFilter}
likeCount={likeCount} likeCount={likeCount}
favoriteCount={favoriteCount} favoriteCount={favoriteCount}
ratedCount={ratedCount} ratedCount={ratedCount}
@@ -582,16 +631,19 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
onCategoryChange={setSelectedCategoryId} onCategoryChange={setSelectedCategoryId}
searchTerm={searchTerm} searchTerm={searchTerm}
onSearchChange={setSearchTerm} onSearchChange={setSearchTerm}
sortBy={sortBy} sortBy={sortBy}
onSortChange={setSortBy} onSortChange={setSortBy}
photoCount={filteredPhotos.length} photoCount={filteredPhotos.length}
// Feedback filter props // Feedback filter props
feedbackEnabled={feedbackEnabled} feedbackEnabled={feedbackEnabled}
currentFilter={filterType} currentFilter={filterType}
onFilterChange={setFilterType} onFilterChange={setFilterType}
/> mediaFilter={mediaFilter}
</div> onMediaFilterChange={setMediaFilter}
) : null} showMediaFilter={showMediaFilter}
/>
</div>
) : null}
{/* Photo Grid */} {/* Photo Grid */}
<div className={showSidebar ? "mt-6" : "mt-6"}> <div className={showSidebar ? "mt-6" : "mt-6"}>
@@ -32,6 +32,9 @@ interface PhotoFilterBarProps {
feedbackEnabled?: boolean; feedbackEnabled?: boolean;
currentFilter?: FilterType; currentFilter?: FilterType;
onFilterChange?: (filter: FilterType) => void; onFilterChange?: (filter: FilterType) => void;
mediaFilter?: 'all' | 'photo' | 'video';
onMediaFilterChange?: (filter: 'all' | 'photo' | 'video') => void;
showMediaFilter?: boolean;
} }
export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
@@ -47,6 +50,9 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
feedbackEnabled = false, feedbackEnabled = false,
currentFilter = 'all', currentFilter = 'all',
onFilterChange, onFilterChange,
mediaFilter = 'all',
onMediaFilterChange,
showMediaFilter = false
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [showSortMenu, setShowSortMenu] = useState(false); const [showSortMenu, setShowSortMenu] = useState(false);
@@ -148,7 +154,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
leftIcon={<Grid className="w-3 h-3 md:w-4 md:h-4" />} leftIcon={<Grid className="w-3 h-3 md:w-4 md:h-4" />}
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0" 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})
</Button> </Button>
{categories.map((category) => { {categories.map((category) => {
const categoryPhotoCount = photos.filter(p => p.category_id === category.id).length; const categoryPhotoCount = photos.filter(p => p.category_id === category.id).length;
@@ -226,10 +232,44 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
)} )}
<p className="text-xs md:text-sm text-neutral-600 flex-shrink-0 ml-auto"> <p className="text-xs md:text-sm text-neutral-600 flex-shrink-0 ml-auto">
{photoCount} {photoCount === 1 ? t('common.photo') : t('common.photos')} {photoCount} {t('common.media', 'media')}
</p> </p>
</div> </div>
)} )}
{showMediaFilter && onMediaFilterChange && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs md:text-sm text-neutral-600 whitespace-nowrap">
{t('gallery.mediaType', 'Media')}
</span>
<div className="flex items-center gap-2">
<Button
variant={mediaFilter === 'all' ? 'primary' : 'outline'}
size="sm"
onClick={() => onMediaFilterChange('all')}
className="text-xs md:text-sm"
>
{t('gallery.allMedia', 'All')}
</Button>
<Button
variant={mediaFilter === 'photo' ? 'primary' : 'outline'}
size="sm"
onClick={() => onMediaFilterChange('photo')}
className="text-xs md:text-sm"
>
{t('gallery.photosOnly', 'Photos')}
</Button>
<Button
variant={mediaFilter === 'video' ? 'primary' : 'outline'}
size="sm"
onClick={() => onMediaFilterChange('video')}
className="text-xs md:text-sm"
>
{t('gallery.videosOnly', 'Videos')}
</Button>
</div>
</div>
)}
{/* Mobile/Tablet: compact horizontal icons with headline below categories */} {/* Mobile/Tablet: compact horizontal icons with headline below categories */}
{feedbackEnabled && onFilterChange && ( {feedbackEnabled && onFilterChange && (
@@ -3,7 +3,7 @@ import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react'; import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react';
import type { Photo } from '../../types'; import type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery'; import { useDownloadPhoto } from '../../hooks/useGallery';
import { AuthenticatedImage } from '../common'; import { AuthenticatedImage, AuthenticatedVideo } from '../common';
import { PhotoFeedback } from './PhotoFeedback'; import { PhotoFeedback } from './PhotoFeedback';
import { feedbackService } from '../../services/feedback.service'; import { feedbackService } from '../../services/feedback.service';
import { FeedbackIdentityModal } from './FeedbackIdentityModal'; import { FeedbackIdentityModal } from './FeedbackIdentityModal';
@@ -64,6 +64,11 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const downloadPhotoMutation = useDownloadPhoto(); const downloadPhotoMutation = useDownloadPhoto();
const currentPhoto = photos[currentIndex]; 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 // DevTools protection for the lightbox when enhanced protection is enabled
useDevToolsProtection({ useDevToolsProtection({
@@ -361,27 +366,31 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button {!isVideo && (
onClick={handleZoomOut} <>
disabled={zoom <= 1} <button
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed" onClick={handleZoomOut}
aria-label="Zoom out" disabled={zoom <= 1}
> className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
<ZoomOut className="w-5 h-5 text-white" /> aria-label="Zoom out"
</button> >
<span className="text-white text-sm w-12 text-center"> <ZoomOut className="w-5 h-5 text-white" />
{Math.round(zoom * 100)}% </button>
</span> <span className="text-white text-sm w-12 text-center">
<button {Math.round(zoom * 100)}%
onClick={handleZoomIn} </span>
disabled={zoom >= 3} <button
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed" onClick={handleZoomIn}
aria-label="Zoom in" disabled={zoom >= 3}
> className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
<ZoomIn className="w-5 h-5 text-white" /> aria-label="Zoom in"
</button> >
<ZoomIn className="w-5 h-5 text-white" />
<div className="w-px h-6 bg-white/20 mx-2" /> </button>
<div className="w-px h-6 bg-white/20 mx-2" />
</>
)}
{allowDownloads && ( {allowDownloads && (
<button <button
@@ -451,64 +460,74 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
{/* Image container */} {/* Image container */}
<div <div
className="absolute top-0 left-0 bottom-0 flex items-center justify-center z-0" className="absolute top-0 left-0 bottom-0 flex items-center justify-center z-0"
onClick={handleImageClick} onClick={isVideo ? undefined : handleImageClick}
onMouseDown={handleMouseDown} onMouseDown={isVideo ? undefined : handleMouseDown}
onMouseMove={handleMouseMove} onMouseMove={isVideo ? undefined : handleMouseMove}
onMouseUp={handleMouseUp} onMouseUp={isVideo ? undefined : handleMouseUp}
onMouseLeave={handleMouseUp} onMouseLeave={isVideo ? undefined : handleMouseUp}
onTouchStart={handleTouchStart} onTouchStart={isVideo ? undefined : handleTouchStart}
onTouchMove={handleTouchMove} onTouchMove={isVideo ? undefined : handleTouchMove}
onTouchEnd={handleTouchEnd} onTouchEnd={isVideo ? undefined : handleTouchEnd}
style={{ style={{
cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default', cursor: isVideo ? 'default' : zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default',
right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0, right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0,
}} }}
> >
<AuthenticatedImage {isVideo ? (
src={currentPhoto.url} <AuthenticatedVideo
alt={currentPhoto.filename} src={currentPhoto.url}
fallbackSrc={currentPhoto.thumbnail_url || undefined} fallbackSrc={currentPhoto.thumbnail_url || undefined}
className="max-w-full max-h-full object-contain select-none" className="max-w-full max-h-full object-contain bg-black"
style={{ slug={slug}
transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`, poster={currentPhoto.thumbnail_url || undefined}
transition: isDragging ? 'none' : 'transform 0.2s', />
}} ) : (
draggable={false} <AuthenticatedImage
useWatermark={useEnhancedProtection} src={currentPhoto.url}
watermarkText={useEnhancedProtection ? `${currentPhoto.filename} - Protected` : undefined} alt={currentPhoto.filename}
isGallery={true} fallbackSrc={currentPhoto.thumbnail_url || undefined}
slug={slug} className="max-w-full max-h-full object-contain select-none"
photoId={currentPhoto.id} style={{
requiresToken={currentPhoto.requires_token} transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`,
secureUrlTemplate={currentPhoto.secure_url_template} transition: isDragging ? 'none' : 'transform 0.2s',
protectFromDownload={!allowDownloads || useEnhancedProtection} }}
protectionLevel={protectionLevel} draggable={false}
useEnhancedProtection={useEnhancedProtection} useWatermark={useEnhancedProtection}
useCanvasRendering={protectionLevel === 'maximum'} watermarkText={useEnhancedProtection ? `${currentPhoto.filename} - Protected` : undefined}
fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'} isGallery={true}
blockKeyboardShortcuts={useEnhancedProtection} slug={slug}
detectPrintScreen={useEnhancedProtection} photoId={currentPhoto.id}
detectDevTools={protectionLevel === 'enhanced' || protectionLevel === 'maximum'} requiresToken={currentPhoto.requires_token}
onProtectionViolation={(violationType) => { secureUrlTemplate={currentPhoto.secure_url_template}
console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`); protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
// Track analytics useEnhancedProtection={useEnhancedProtection}
if (typeof window !== 'undefined' && (window as any).umami) { useCanvasRendering={protectionLevel === 'maximum'}
(window as any).umami.track('lightbox_protection_violation', { fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
photoId: currentPhoto.id, blockKeyboardShortcuts={useEnhancedProtection}
violationType, detectPrintScreen={useEnhancedProtection}
protectionLevel, detectDevTools={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
zoom onProtectionViolation={(violationType) => {
}); console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`);
}
// Track analytics
// For maximum protection, close lightbox on violation if (typeof window !== 'undefined' && (window as any).umami) {
if (protectionLevel === 'maximum' && (window as any).umami.track('lightbox_protection_violation', {
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) { photoId: currentPhoto.id,
onClose(); violationType,
} protectionLevel,
}} zoom
/> });
}
// For maximum protection, close lightbox on violation
if (protectionLevel === 'maximum' &&
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {
onClose();
}
}}
/>
)}
</div> </div>
{/* Touch/swipe indicators for mobile */} {/* Touch/swipe indicators for mobile */}
@@ -1,5 +1,5 @@
import React from 'react'; 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 { useInView } from 'react-intersection-observer';
import { useTheme } from '../../../contexts/ThemeContext'; import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common'; import { AuthenticatedImage } from '../../common';
@@ -155,6 +155,10 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
? 'opacity-100 md:opacity-100' ? 'opacity-100 md:opacity-100'
: 'opacity-0 md:opacity-0'; : '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<HTMLDivElement>) => { const handlePhotoClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (isTouchDevice && !overlayVisible && !isSelectionMode) { if (isTouchDevice && !overlayVisible && !isSelectionMode) {
e.preventDefault(); e.preventDefault();
@@ -318,6 +322,15 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
</div> </div>
)} )}
{isVideo && (
<div className="absolute bottom-2 right-2">
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded flex items-center gap-1">
<Video className="w-3 h-3" />
{t('common.video', 'Video')}
</span>
</div>
)}
{photo.type === 'collage' && ( {photo.type === 'collage' && (
<div className="absolute bottom-2 right-2"> <div className="absolute bottom-2 right-2">
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded"> <span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
+21 -1
View File
@@ -26,6 +26,9 @@
"uploaded": "Hochgeladen", "uploaded": "Hochgeladen",
"photo": "Foto", "photo": "Foto",
"photos": "Fotos", "photos": "Fotos",
"video": "Video",
"videos": "Videos",
"media": "Medien",
"restore": "Wiederherstellen", "restore": "Wiederherstellen",
"actions": "Aktionen", "actions": "Aktionen",
"refresh": "Aktualisieren", "refresh": "Aktualisieren",
@@ -49,12 +52,15 @@
"eventSpecific": "(Veranstaltungsspezifisch)", "eventSpecific": "(Veranstaltungsspezifisch)",
"clickToUpload": "Klicken zum Hochladen oder per Drag & Drop", "clickToUpload": "Klicken zum Hochladen oder per Drag & Drop",
"fileRequirements": "JPEG, PNG oder WebP (max. 50MB pro Datei, {{limit}} Dateien pro Upload)", "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", "selectedFiles": "Ausgewählte Dateien",
"uploading": "Wird hochgeladen...", "uploading": "Wird hochgeladen...",
"uploadComplete": "Upload abgeschlossen!", "uploadComplete": "Upload abgeschlossen!",
"uploadFailed": "Upload fehlgeschlagen", "uploadFailed": "Upload fehlgeschlagen",
"someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden", "someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden",
"uploadPhotos": "Fotos hochladen", "uploadPhotos": "Fotos hochladen",
"uploadMedia": "Fotos & Videos hochladen",
"importExternal": "Aus externem Ordner importieren", "importExternal": "Aus externem Ordner importieren",
"externalImportInfo": "Alle Bilder aus dem ausgewählten Ordner werden importiert.", "externalImportInfo": "Alle Bilder aus dem ausgewählten Ordner werden importiert.",
"selectExternalFolder": "Externen Ordner unter /external-media auswählen", "selectExternalFolder": "Externen Ordner unter /external-media auswählen",
@@ -64,7 +70,9 @@
"tooManyFiles": "Maximal {{limit}} Dateien können gleichzeitig hochgeladen werden", "tooManyFiles": "Maximal {{limit}} Dateien können gleichzeitig hochgeladen werden",
"limitInfo": "{{selected}} von {{limit}} Dateien ausgewählt ({{remaining}} verbleibend)", "limitInfo": "{{selected}} von {{limit}} Dateien ausgewählt ({{remaining}} verbleibend)",
"limitReached": "Upload-Limit erreicht ({{limit}} Dateien pro Vorgang)", "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": { "navigation": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -513,6 +521,13 @@
"selectAll": "Alle auswählen", "selectAll": "Alle auswählen",
"deselectAll": "Auswahl aufheben", "deselectAll": "Auswahl aufheben",
"downloadSelected": "{{count}} ausgewählte herunterladen", "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", "remaining": "verbleibend",
"selectPhotosHint": "Tipp: Verwenden Sie Strg+Klick (Cmd+Klick auf Mac), um schnell mehrere Fotos auszuwählen", "selectPhotosHint": "Tipp: Verwenden Sie Strg+Klick (Cmd+Klick auf Mac), um schnell mehrere Fotos auszuwählen",
"filters": "Filter", "filters": "Filter",
@@ -521,7 +536,12 @@
"toggleMenu": "Menü umschalten", "toggleMenu": "Menü umschalten",
"allCategories": "Alle Kategorien", "allCategories": "Alle Kategorien",
"categories": "Kategorien", "categories": "Kategorien",
"mediaType": "Medien",
"allMedia": "Alle Medien",
"photosOnly": "Fotos",
"videosOnly": "Videos",
"download": "Herunterladen", "download": "Herunterladen",
"noMedia": "Noch keine Medien hochgeladen",
"searchPlaceholder": "Fotos suchen...", "searchPlaceholder": "Fotos suchen...",
"sortBy": "Sortieren nach", "sortBy": "Sortieren nach",
"sortByDate": "Nach Datum sortieren", "sortByDate": "Nach Datum sortieren",
+21 -1
View File
@@ -26,6 +26,9 @@
"uploaded": "Uploaded", "uploaded": "Uploaded",
"photo": "photo", "photo": "photo",
"photos": "photos", "photos": "photos",
"video": "video",
"videos": "videos",
"media": "media",
"restore": "Restore", "restore": "Restore",
"actions": "Actions", "actions": "Actions",
"refresh": "Refresh", "refresh": "Refresh",
@@ -49,12 +52,15 @@
"eventSpecific": "(Event specific)", "eventSpecific": "(Event specific)",
"clickToUpload": "Click to upload or drag and drop", "clickToUpload": "Click to upload or drag and drop",
"fileRequirements": "JPEG, PNG or WebP (max 50MB per file, {{limit}} files per upload)", "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", "selectedFiles": "Selected files",
"uploading": "Uploading...", "uploading": "Uploading...",
"uploadComplete": "Upload complete!", "uploadComplete": "Upload complete!",
"uploadFailed": "Upload failed", "uploadFailed": "Upload failed",
"someFilesFailed": "Some files failed to upload", "someFilesFailed": "Some files failed to upload",
"uploadPhotos": "Upload Photos", "uploadPhotos": "Upload Photos",
"uploadMedia": "Upload Photos & Videos",
"importExternal": "Import from External Folder", "importExternal": "Import from External Folder",
"externalImportInfo": "All pictures from the selected folder will be imported.", "externalImportInfo": "All pictures from the selected folder will be imported.",
"selectExternalFolder": "Select external folder under /external-media", "selectExternalFolder": "Select external folder under /external-media",
@@ -64,7 +70,9 @@
"tooManyFiles": "Maximum {{limit}} files can be uploaded at once", "tooManyFiles": "Maximum {{limit}} files can be uploaded at once",
"limitInfo": "{{selected}} of {{limit}} files selected ({{remaining}} remaining)", "limitInfo": "{{selected}} of {{limit}} files selected ({{remaining}} remaining)",
"limitReached": "Upload limit reached ({{limit}} files per batch)", "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": { "navigation": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -178,6 +186,13 @@
"selectAll": "Select All", "selectAll": "Select All",
"deselectAll": "Deselect All", "deselectAll": "Deselect All",
"downloadSelected": "Download {{count}} Selected", "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", "remaining": "remaining",
"selectPhotosHint": "Tip: Use Ctrl+Click (Cmd+Click on Mac) to quickly select multiple photos", "selectPhotosHint": "Tip: Use Ctrl+Click (Cmd+Click on Mac) to quickly select multiple photos",
"filters": "Filters", "filters": "Filters",
@@ -186,7 +201,12 @@
"toggleMenu": "Toggle menu", "toggleMenu": "Toggle menu",
"allCategories": "All Categories", "allCategories": "All Categories",
"categories": "Categories", "categories": "Categories",
"mediaType": "Media",
"allMedia": "All media",
"photosOnly": "Photos",
"videosOnly": "Videos",
"download": "Download", "download": "Download",
"noMedia": "No media uploaded yet",
"searchPlaceholder": "Search photos...", "searchPlaceholder": "Search photos...",
"sortBy": "Sort By", "sortBy": "Sort By",
"sortByDate": "Sort by Date", "sortByDate": "Sort by Date",
+27 -1
View File
@@ -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 { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -208,6 +208,26 @@ export const EventDetailsPage: React.FC = () => {
enabled: !!id && (activeTab === 'photos' || isEditing), 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 // Fetch categories for the event
const { data: categories = [] } = useQuery({ const { data: categories = [] } = useQuery({
queryKey: ['admin-event-categories', id], queryKey: ['admin-event-categories', id],
@@ -1237,6 +1257,12 @@ export const EventDetailsPage: React.FC = () => {
onCategoryChange={(categoryId) => setPhotoFilters(prev => ({ ...prev, category_id: categoryId }))} onCategoryChange={(categoryId) => setPhotoFilters(prev => ({ ...prev, category_id: categoryId }))}
onSearchChange={(search) => setPhotoFilters(prev => ({ ...prev, search }))} onSearchChange={(search) => setPhotoFilters(prev => ({ ...prev, search }))}
onSortChange={(sort, order) => setPhotoFilters(prev => ({ ...prev, sort, order }))} 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 */} {/* Actions Bar */}
+6 -2
View File
@@ -7,11 +7,13 @@ export interface AdminPhoto {
url: string; url: string;
thumbnail_url: string | null; thumbnail_url: string | null;
type: string; type: string;
category_id: number | null; category_id: number | string | null;
category_name: string | null; category_name: string | null;
category_slug: string | null; category_slug: string | null;
size: number; size: number;
uploaded_at: string; uploaded_at: string;
media_type?: 'photo' | 'video';
mime_type?: string | null;
view_count?: number; view_count?: number;
download_count?: number; download_count?: number;
// Feedback fields // Feedback fields
@@ -23,8 +25,9 @@ export interface AdminPhoto {
} }
export interface PhotoFilters { export interface PhotoFilters {
category_id?: number | null; category_id?: number | string | null;
type?: string; type?: string;
media_type?: 'photo' | 'video';
search?: string; search?: string;
sort?: 'date' | 'name' | 'size' | 'rating'; sort?: 'date' | 'name' | 'size' | 'rating';
order?: 'asc' | 'desc'; order?: 'asc' | 'desc';
@@ -39,6 +42,7 @@ class PhotosService {
params.append('category_id', filters.category_id?.toString() || ''); params.append('category_id', filters.category_id?.toString() || '');
} }
if (filters.type) params.append('type', filters.type); 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.search) params.append('search', filters.search);
if (filters.sort) params.append('sort', filters.sort); if (filters.sort) params.append('sort', filters.sort);
if (filters.order) params.append('order', filters.order); if (filters.order) params.append('order', filters.order);
+5 -3
View File
@@ -55,8 +55,10 @@ export interface Photo {
secure_url_template?: string; secure_url_template?: string;
download_url_template?: string; download_url_template?: string;
requires_token?: boolean; requires_token?: boolean;
type: 'collage' | 'individual'; type: 'collage' | 'individual' | 'video';
category_id?: number; media_type?: 'photo' | 'video';
mime_type?: string;
category_id?: number | string | null;
category_name?: string; category_name?: string;
category_slug?: string; category_slug?: string;
size: number; size: number;
@@ -71,7 +73,7 @@ export interface Photo {
} }
export interface PhotoCategory { export interface PhotoCategory {
id: number; id: number | string;
name: string; name: string;
slug: string; slug: string;
is_global: boolean; is_global: boolean;
+1
View File
@@ -0,0 +1 @@
404: Not Found
Binary file not shown.