Add video support, media filters, and translations
This commit is contained in:
@@ -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}`);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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})`);
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
|||||||
Generated
+571
-1
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
404: Not Found
|
||||||
Binary file not shown.
Reference in New Issue
Block a user