Compare commits
14 Commits
bce5f749b1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c7498385f | |||
| 1ae63890ff | |||
| 5f1affafd8 | |||
| 8315c11d34 | |||
| 0043f2aaf4 | |||
| d494eda301 | |||
| a59a4232ff | |||
| 77326a91ca | |||
| 0d95eab86a | |||
| f3482a9a78 | |||
| 68a9dc5749 | |||
| 8c87f1537b | |||
| 97e54355fb | |||
| 9a75f1c929 |
@@ -135,6 +135,31 @@ Perfect for:
|
||||
- **Docker**: v20.10.0+
|
||||
- **Docker Compose**: v2.0.0+
|
||||
|
||||
### Video Support Requirements
|
||||
When enabling video uploads, consider these additional resources:
|
||||
|
||||
| Resource | Recommendation | Notes |
|
||||
|----------|----------------|-------|
|
||||
| **RAM** | 4GB+ recommended | FFmpeg processing requires more memory |
|
||||
| **Storage** | Plan for 10-100x more | Videos are significantly larger than images |
|
||||
| **CPU** | Additional cores help | Video thumbnail extraction is CPU-intensive |
|
||||
| **Bandwidth** | Higher throughput | Video streaming requires more bandwidth |
|
||||
|
||||
**Technical Notes:**
|
||||
- FFmpeg is bundled via npm (`@ffmpeg-installer/ffmpeg`) - no system installation required
|
||||
- Maximum upload size: **10GB per video file**
|
||||
- Chunked upload support for files >100MB (resumable uploads)
|
||||
- Supported formats: MP4, WebM, MOV, AVI
|
||||
- Video thumbnails are automatically generated from the first few seconds
|
||||
|
||||
**For Nginx/Reverse Proxy:**
|
||||
If using Nginx, increase the client max body size:
|
||||
```nginx
|
||||
client_max_body_size 10G;
|
||||
proxy_read_timeout 3600;
|
||||
proxy_send_timeout 3600;
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
|
||||
@@ -222,7 +247,7 @@ These features are currently in beta testing and may have limited functionality
|
||||
| **Gallery Templates** | Additional gallery layouts and themes (masonry, slideshow, story-style) for different event types | Medium | 🔄 Open |
|
||||
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
|
||||
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
|
||||
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | 🔄 Open |
|
||||
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | ✅ Implemented |
|
||||
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | 📋 Planned |
|
||||
| **Filtering & Export Options** | Add filters to show only rated, liked, or marked photos and export filtered selections for Capture One or Lightroom workflows | Low | 🔄 Open |
|
||||
|
||||
|
||||
+8
-2
@@ -11,13 +11,16 @@ LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
|
||||
LABEL org.opencontainers.image.description="PicPeak Backend Service"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
||||
RUN npm install -g npm@latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production
|
||||
# Install dependencies (--omit=dev replaces deprecated --only=production)
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
@@ -30,6 +33,9 @@ WORKDIR /app
|
||||
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
||||
RUN npm install -g npm@latest
|
||||
|
||||
# Install dumb-init for proper signal handling and postgresql-client for database checks
|
||||
RUN apk add --no-cache dumb-init postgresql-client
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
const { addColumnIfNotExists } = require('../helpers');
|
||||
|
||||
/**
|
||||
* Migration: Add video support to photos table
|
||||
* - Adds columns for video metadata (media_type, duration, codecs, dimensions)
|
||||
* - Updates existing photos to have media_type 'image'
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Running migration: 042_add_video_support');
|
||||
|
||||
// Add media_type column (image or video)
|
||||
await addColumnIfNotExists(knex, 'photos', 'media_type', (table) => {
|
||||
table.string('media_type').defaultTo('image');
|
||||
});
|
||||
|
||||
// Add mime_type column if not exists
|
||||
await addColumnIfNotExists(knex, 'photos', 'mime_type', (table) => {
|
||||
table.string('mime_type');
|
||||
});
|
||||
|
||||
// Add duration column (for videos, in seconds)
|
||||
await addColumnIfNotExists(knex, 'photos', 'duration', (table) => {
|
||||
table.integer('duration');
|
||||
});
|
||||
|
||||
// Add video codec information
|
||||
await addColumnIfNotExists(knex, 'photos', 'video_codec', (table) => {
|
||||
table.string('video_codec');
|
||||
});
|
||||
|
||||
// Add audio codec information
|
||||
await addColumnIfNotExists(knex, 'photos', 'audio_codec', (table) => {
|
||||
table.string('audio_codec');
|
||||
});
|
||||
|
||||
// Add width dimension
|
||||
await addColumnIfNotExists(knex, 'photos', 'width', (table) => {
|
||||
table.integer('width');
|
||||
});
|
||||
|
||||
// Add height dimension
|
||||
await addColumnIfNotExists(knex, 'photos', 'height', (table) => {
|
||||
table.integer('height');
|
||||
});
|
||||
|
||||
// Update existing photos to have media_type 'image' if not set
|
||||
const hasMediaType = await knex.schema.hasColumn('photos', 'media_type');
|
||||
if (hasMediaType) {
|
||||
await knex('photos')
|
||||
.whereNull('media_type')
|
||||
.orWhere('media_type', '')
|
||||
.update({ media_type: 'image' });
|
||||
console.log('Updated existing photos to have media_type "image"');
|
||||
}
|
||||
|
||||
console.log('Migration 042_add_video_support completed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Rolling back migration: 042_add_video_support');
|
||||
|
||||
// Remove video support columns
|
||||
const hasMediaType = await knex.schema.hasColumn('photos', 'media_type');
|
||||
if (hasMediaType) {
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.dropColumn('media_type');
|
||||
});
|
||||
}
|
||||
|
||||
const hasDuration = await knex.schema.hasColumn('photos', 'duration');
|
||||
if (hasDuration) {
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.dropColumn('duration');
|
||||
});
|
||||
}
|
||||
|
||||
const hasVideoCodec = await knex.schema.hasColumn('photos', 'video_codec');
|
||||
if (hasVideoCodec) {
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.dropColumn('video_codec');
|
||||
});
|
||||
}
|
||||
|
||||
const hasAudioCodec = await knex.schema.hasColumn('photos', 'audio_codec');
|
||||
if (hasAudioCodec) {
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.dropColumn('audio_codec');
|
||||
});
|
||||
}
|
||||
|
||||
const hasWidth = await knex.schema.hasColumn('photos', 'width');
|
||||
if (hasWidth) {
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.dropColumn('width');
|
||||
});
|
||||
}
|
||||
|
||||
const hasHeight = await knex.schema.hasColumn('photos', 'height');
|
||||
if (hasHeight) {
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.dropColumn('height');
|
||||
});
|
||||
}
|
||||
|
||||
// Note: We don't drop mime_type as it may be used by images as well
|
||||
|
||||
console.log('Rollback of 042_add_video_support completed');
|
||||
};
|
||||
Generated
+1813
-1213
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
||||
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^5.3.1",
|
||||
"axios": "^1.12.2",
|
||||
@@ -26,6 +27,7 @@
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^6.7.0",
|
||||
"express-validator": "^7.0.1",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"form-data": "^4.0.4",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^7.0.0",
|
||||
|
||||
+3
-3
@@ -324,9 +324,9 @@ async function initializeRateLimiters() {
|
||||
|
||||
// Note: Rate limiters will be initialized after database connection
|
||||
|
||||
// Body parsing middleware with increased limits for large batch uploads
|
||||
app.use(express.json({ limit: '500mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '500mb' }));
|
||||
<<<<<<< HEAD
|
||||
app.use(express.json({ limit: '10gb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10gb' }));
|
||||
|
||||
// Request logging for API routes (with timestamps)
|
||||
const apiRequestLogger = (req, res, next) => {
|
||||
|
||||
@@ -9,29 +9,13 @@ const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||
const { getMaxFilesPerUpload } = require('../services/uploadSettings');
|
||||
const { processUploadedPhotos } = require('../services/photoProcessor');
|
||||
const chunkedUpload = require('../services/chunkedUploadService');
|
||||
const router = express.Router();
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
const parseCategoryId = (value) => {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||
return value === 0 ? null : value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed === 'null') return null;
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
const parsed = parseInt(trimmed, 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed === 0 ? null : parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Configure multer for file uploads
|
||||
// IMPORTANT: Using synchronous functions to prevent file corruption
|
||||
const storage = multer.diskStorage({
|
||||
@@ -66,7 +50,7 @@ const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024, // 50MB limit per file
|
||||
fileSize: 10 * 1024 * 1024 * 1024, // 10GB limit per file to support large videos
|
||||
files: 2000, // Hard safety ceiling; actual limit enforced dynamically
|
||||
// Set a reasonable field size limit to prevent memory issues
|
||||
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
|
||||
@@ -75,13 +59,16 @@ const upload = multer({
|
||||
headerPairs: 2000 // Maximum number of header key-value pairs
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// Accept images only with proper validation
|
||||
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
// Accept images and videos with proper validation
|
||||
const allowedMimeTypes = [
|
||||
'image/jpeg', 'image/png', 'image/webp',
|
||||
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'
|
||||
];
|
||||
|
||||
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only JPEG, PNG and WebP images are allowed'));
|
||||
cb(new Error('Only JPEG, PNG, WebP images and MP4, WebM, MOV, AVI videos are allowed'));
|
||||
}
|
||||
},
|
||||
// Add abort on limit to stop processing when limits are exceeded
|
||||
@@ -92,8 +79,11 @@ const { createFileUploadValidator } = require('../utils/fileSecurityUtils');
|
||||
|
||||
// Create content validator middleware
|
||||
const validateUploadContent = createFileUploadValidator({
|
||||
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
|
||||
maxFileSize: 50 * 1024 * 1024,
|
||||
allowedTypes: [
|
||||
'image/jpeg', 'image/png', 'image/webp',
|
||||
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'
|
||||
],
|
||||
maxFileSize: 10 * 1024 * 1024 * 1024, // 10GB to support large videos
|
||||
validateContent: true
|
||||
});
|
||||
|
||||
@@ -133,7 +123,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
||||
console.error('Multer error:', err);
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({ error: 'File too large. Maximum size is 50MB per file.' });
|
||||
return res.status(400).json({ error: 'File too large. Maximum size is 10GB per file.' });
|
||||
}
|
||||
if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') {
|
||||
return res.status(400).json({ error: `Too many files. Maximum ${maxFilesPerUpload} files per upload.` });
|
||||
@@ -185,16 +175,16 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
||||
}
|
||||
|
||||
// Parse category_id to number if provided
|
||||
const numericCategoryId = parseCategoryId(category_id);
|
||||
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
|
||||
|
||||
// Determine photo type from category_id parameter (for backwards compatibility)
|
||||
let photoType = 'individual'; // default
|
||||
let categoryName = 'individual';
|
||||
|
||||
if (numericCategoryId === 1 || category_id === 'collage') {
|
||||
if (parsedCategoryId === 1 || category_id === 'collage') {
|
||||
photoType = 'collage';
|
||||
categoryName = 'collages';
|
||||
} else if (numericCategoryId === 2 || category_id === 'individual') {
|
||||
} else if (parsedCategoryId === 2 || category_id === 'individual') {
|
||||
photoType = 'individual';
|
||||
categoryName = 'individual';
|
||||
}
|
||||
@@ -266,9 +256,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
||||
path: relativePath,
|
||||
thumbnail_path: null, // Will generate after successful commit
|
||||
type: photoType,
|
||||
size_bytes: tempStats.size, // Use actual file size from stat
|
||||
category_id: numericCategoryId,
|
||||
source_origin: 'managed'
|
||||
size_bytes: tempStats.size // Use actual file size from stat
|
||||
};
|
||||
|
||||
batchPhotos.push(photoData);
|
||||
@@ -504,9 +492,7 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData = {
|
||||
updated_at: new Date()
|
||||
};
|
||||
const updateData = {};
|
||||
|
||||
// Handle type-based categories ('individual' or 'collage')
|
||||
// These are string values that map to the photo.type field
|
||||
@@ -527,21 +513,11 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
// Update photo
|
||||
const normalizedCategoryId = parseCategoryId(category_id);
|
||||
|
||||
await db('photos')
|
||||
.where({ id: photoId, event_id: eventId })
|
||||
.update(updateData);
|
||||
|
||||
// Fetch and return updated photo for confirmation
|
||||
const updatedPhoto = await db('photos')
|
||||
.where({ id: photoId })
|
||||
.first();
|
||||
|
||||
res.json({
|
||||
message: 'Photo updated successfully',
|
||||
photo: updatedPhoto
|
||||
});
|
||||
res.json({ message: 'Photo updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error updating photo:', error);
|
||||
res.status(500).json({ error: 'Failed to update photo' });
|
||||
@@ -621,22 +597,22 @@ router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { photoIds, updates } = req.body;
|
||||
|
||||
|
||||
if (!Array.isArray(photoIds) || photoIds.length === 0) {
|
||||
return res.status(400).json({ error: 'Invalid photo IDs' });
|
||||
}
|
||||
|
||||
|
||||
// Verify all photos belong to the event
|
||||
const photoCount = await db('photos')
|
||||
.whereIn('id', photoIds)
|
||||
.where('event_id', eventId)
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
|
||||
if (parseInt(photoCount.count) !== photoIds.length) {
|
||||
return res.status(400).json({ error: 'Some photos do not belong to this event' });
|
||||
}
|
||||
|
||||
|
||||
// Prepare update data
|
||||
const updateData = {
|
||||
updated_at: new Date()
|
||||
@@ -713,22 +689,14 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
|
||||
|
||||
let query = db('photos')
|
||||
.leftJoin('photo_categories as pc', 'pc.id', 'photos.category_id')
|
||||
.where({ 'photos.event_id': eventId })
|
||||
.select(
|
||||
'photos.*',
|
||||
'pc.name as category_display_name',
|
||||
'pc.slug as category_display_slug'
|
||||
);
|
||||
.select('photos.*');
|
||||
|
||||
// Filter by type (individual/collage) - category_id maps to type
|
||||
if (category_id !== undefined) {
|
||||
if (category_id === '') {
|
||||
// No filter when empty string is provided
|
||||
} else if (category_id === '0') {
|
||||
query = query.whereNull('photos.category_id');
|
||||
} else if (/^\d+$/.test(category_id)) {
|
||||
query = query.where('photos.category_id', parseInt(category_id, 10));
|
||||
if (category_id === '' || category_id === '0') {
|
||||
// For backwards compatibility, empty category means no filter
|
||||
// Don't filter anything
|
||||
} else if (category_id === 'individual' || category_id === 'collage') {
|
||||
query = query.where({ 'photos.type': category_id });
|
||||
}
|
||||
@@ -754,11 +722,7 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
const photos = await query.orderBy(orderByColumn, order);
|
||||
|
||||
if (photos.length === 0) {
|
||||
return res.json({ photos: [] });
|
||||
}
|
||||
|
||||
|
||||
// Get comment counts separately
|
||||
const commentCounts = await db('photo_feedback')
|
||||
.whereIn('photo_id', photos.map(p => p.id))
|
||||
@@ -783,11 +747,9 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
// Always expose a thumbnail URL; backend will generate on demand if missing
|
||||
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
|
||||
type: photo.type,
|
||||
category_id: photo.category_id !== null && photo.category_id !== undefined
|
||||
? Number(photo.category_id)
|
||||
: null,
|
||||
category_name: photo.category_display_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
|
||||
category_slug: photo.category_display_slug || photo.type,
|
||||
category_id: photo.type,
|
||||
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
|
||||
category_slug: photo.type,
|
||||
size: photo.size_bytes,
|
||||
uploaded_at: photo.uploaded_at,
|
||||
// Feedback data
|
||||
@@ -902,4 +864,142 @@ router.get('/:eventId/debug', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// CHUNKED UPLOAD ENDPOINTS
|
||||
// For large file uploads (videos up to 10GB)
|
||||
// ============================================
|
||||
|
||||
// Initialize a chunked upload
|
||||
router.post('/:eventId/chunked-upload/init', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { filename, fileSize, mimeType, totalChunks } = req.body;
|
||||
|
||||
// Validate event exists
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!filename || !fileSize || !mimeType) {
|
||||
return res.status(400).json({ error: 'Missing required fields: filename, fileSize, mimeType' });
|
||||
}
|
||||
|
||||
// Validate file size (max 10GB)
|
||||
const maxSize = 10 * 1024 * 1024 * 1024;
|
||||
if (fileSize > maxSize) {
|
||||
return res.status(400).json({ error: `File too large. Maximum size is 10GB.` });
|
||||
}
|
||||
|
||||
const result = await chunkedUpload.initializeUpload({
|
||||
filename,
|
||||
fileSize,
|
||||
mimeType,
|
||||
eventId: parseInt(eventId),
|
||||
totalChunks
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error initializing chunked upload:', error);
|
||||
res.status(500).json({ error: 'Failed to initialize upload' });
|
||||
}
|
||||
});
|
||||
|
||||
// Upload a chunk
|
||||
router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { uploadId, chunkIndex } = req.params;
|
||||
|
||||
// Get chunk data from request body
|
||||
const chunks = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const chunkData = Buffer.concat(chunks);
|
||||
|
||||
const result = await chunkedUpload.uploadChunk(uploadId, parseInt(chunkIndex), chunkData);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error uploading chunk:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to upload chunk' });
|
||||
}
|
||||
});
|
||||
|
||||
// Complete chunked upload and process the file
|
||||
router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { eventId, uploadId } = req.params;
|
||||
const { category_id } = req.body;
|
||||
|
||||
// Complete the chunked upload (merge chunks)
|
||||
const mergedFile = await chunkedUpload.completeUpload(uploadId);
|
||||
|
||||
// Process the merged file as a regular upload
|
||||
const fileObj = {
|
||||
originalname: mergedFile.filename,
|
||||
mimetype: mergedFile.mimeType,
|
||||
size: mergedFile.size,
|
||||
path: mergedFile.path
|
||||
};
|
||||
|
||||
const uploadedPhotos = await processUploadedPhotos(
|
||||
[fileObj],
|
||||
parseInt(eventId),
|
||||
'admin',
|
||||
category_id || null
|
||||
);
|
||||
|
||||
// Clean up temp directory
|
||||
try {
|
||||
await fs.rm(mergedFile.tempDir, { recursive: true, force: true });
|
||||
} catch (cleanupErr) {
|
||||
console.warn('Failed to clean up temp directory:', cleanupErr.message);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
uploaded: uploadedPhotos.length,
|
||||
photos: uploadedPhotos
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error completing chunked upload:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to complete upload' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get upload status
|
||||
router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { uploadId } = req.params;
|
||||
|
||||
const status = chunkedUpload.getUploadStatus(uploadId);
|
||||
|
||||
if (!status) {
|
||||
return res.status(404).json({ error: 'Upload not found or expired' });
|
||||
}
|
||||
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
console.error('Error getting upload status:', error);
|
||||
res.status(500).json({ error: 'Failed to get upload status' });
|
||||
}
|
||||
});
|
||||
|
||||
// Abort chunked upload
|
||||
router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { uploadId } = req.params;
|
||||
|
||||
await chunkedUpload.abortUpload(uploadId);
|
||||
|
||||
res.json({ success: true, message: 'Upload aborted' });
|
||||
} catch (error) {
|
||||
console.error('Error aborting upload:', error);
|
||||
res.status(500).json({ error: 'Failed to abort upload' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -610,38 +610,41 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
|
||||
|
||||
|
||||
// View single photo (with watermark if enabled)
|
||||
router.get('/:slug/photo/:photoId',
|
||||
verifyGalleryAccess,
|
||||
router.get('/:slug/photo/:photoId',
|
||||
verifyGalleryAccess,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
|
||||
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
// Check if this is a video
|
||||
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||
|
||||
// Check protection level - basic and standard protection allow direct JWT access
|
||||
const protectionLevel = req.event.protection_level || 'standard';
|
||||
|
||||
|
||||
if (protectionLevel === 'enhanced' || protectionLevel === 'maximum') {
|
||||
// For enhanced/maximum protection, redirect to secure endpoint
|
||||
return res.status(302).json({
|
||||
return res.status(302).json({
|
||||
error: 'Secure access required',
|
||||
secureEndpoint: `/api/secure-images/${req.params.slug}/generate-token`,
|
||||
photoId: photoId
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Resolve the absolute file path for this photo, supporting both managed and external reference modes
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const filePath = resolvePhotoFilePath(req.event, photo);
|
||||
|
||||
|
||||
|
||||
|
||||
// Log access - temporarily disabled for debugging
|
||||
// await secureImageService.logImageAccess(
|
||||
// photoId,
|
||||
@@ -649,20 +652,61 @@ router.get('/:slug/photo/:photoId',
|
||||
// req.clientInfo,
|
||||
// 'view_basic'
|
||||
// );
|
||||
|
||||
|
||||
// Handle video streaming with range requests
|
||||
if (isVideo) {
|
||||
const fs = require('fs');
|
||||
const stat = fs.statSync(filePath);
|
||||
const fileSize = stat.size;
|
||||
const range = req.headers.range;
|
||||
|
||||
if (range) {
|
||||
// Parse range header
|
||||
const parts = range.replace(/bytes=/, "").split("-");
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
const chunksize = (end - start) + 1;
|
||||
const file = fs.createReadStream(filePath, { start, end });
|
||||
|
||||
res.writeHead(206, {
|
||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': chunksize,
|
||||
'Content-Type': photo.mime_type || 'video/mp4',
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
|
||||
file.pipe(res);
|
||||
} else {
|
||||
// No range request, send entire file
|
||||
res.writeHead(200, {
|
||||
'Content-Length': fileSize,
|
||||
'Content-Type': photo.mime_type || 'video/mp4',
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle images (existing logic)
|
||||
// Get watermark settings
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Apply watermark and send
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
|
||||
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
|
||||
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
// Send original file with basic protection headers
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const crypto = require('crypto');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const getChunksPath = () => path.join(getStoragePath(), 'chunks');
|
||||
|
||||
// In-memory store for active uploads (in production, consider Redis)
|
||||
const activeUploads = new Map();
|
||||
|
||||
// Chunk size: 10MB
|
||||
const CHUNK_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
// Upload expiration: 24 hours
|
||||
const UPLOAD_EXPIRATION_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Initialize a new chunked upload
|
||||
* @param {Object} options - Upload options
|
||||
* @returns {Promise<Object>} - Upload metadata
|
||||
*/
|
||||
async function initializeUpload(options) {
|
||||
const {
|
||||
filename,
|
||||
fileSize,
|
||||
mimeType,
|
||||
eventId,
|
||||
totalChunks
|
||||
} = options;
|
||||
|
||||
// Generate unique upload ID
|
||||
const uploadId = crypto.randomUUID();
|
||||
|
||||
// Create chunks directory for this upload
|
||||
const uploadDir = path.join(getChunksPath(), uploadId);
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
|
||||
// Calculate expected chunks
|
||||
const expectedChunks = totalChunks || Math.ceil(fileSize / CHUNK_SIZE);
|
||||
|
||||
// Store upload metadata
|
||||
const uploadMeta = {
|
||||
uploadId,
|
||||
filename,
|
||||
fileSize,
|
||||
mimeType,
|
||||
eventId,
|
||||
expectedChunks,
|
||||
receivedChunks: new Set(),
|
||||
uploadDir,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + UPLOAD_EXPIRATION_MS,
|
||||
status: 'in_progress'
|
||||
};
|
||||
|
||||
activeUploads.set(uploadId, uploadMeta);
|
||||
|
||||
logger.info('Initialized chunked upload', {
|
||||
uploadId,
|
||||
filename,
|
||||
fileSize,
|
||||
expectedChunks,
|
||||
eventId
|
||||
});
|
||||
|
||||
return {
|
||||
uploadId,
|
||||
chunkSize: CHUNK_SIZE,
|
||||
expectedChunks,
|
||||
expiresAt: uploadMeta.expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a single chunk
|
||||
* @param {string} uploadId - Upload ID
|
||||
* @param {number} chunkIndex - Chunk index (0-based)
|
||||
* @param {Buffer} chunkData - Chunk data
|
||||
* @returns {Promise<Object>} - Chunk upload result
|
||||
*/
|
||||
async function uploadChunk(uploadId, chunkIndex, chunkData) {
|
||||
const uploadMeta = activeUploads.get(uploadId);
|
||||
|
||||
if (!uploadMeta) {
|
||||
throw new Error('Upload not found or expired');
|
||||
}
|
||||
|
||||
if (uploadMeta.status !== 'in_progress') {
|
||||
throw new Error(`Upload is ${uploadMeta.status}`);
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (Date.now() > uploadMeta.expiresAt) {
|
||||
await abortUpload(uploadId);
|
||||
throw new Error('Upload expired');
|
||||
}
|
||||
|
||||
// Write chunk to disk
|
||||
const chunkPath = path.join(uploadMeta.uploadDir, `chunk_${String(chunkIndex).padStart(6, '0')}`);
|
||||
await fs.writeFile(chunkPath, chunkData);
|
||||
|
||||
// Mark chunk as received
|
||||
uploadMeta.receivedChunks.add(chunkIndex);
|
||||
|
||||
const progress = (uploadMeta.receivedChunks.size / uploadMeta.expectedChunks) * 100;
|
||||
|
||||
logger.debug('Chunk uploaded', {
|
||||
uploadId,
|
||||
chunkIndex,
|
||||
receivedChunks: uploadMeta.receivedChunks.size,
|
||||
expectedChunks: uploadMeta.expectedChunks,
|
||||
progress: progress.toFixed(1)
|
||||
});
|
||||
|
||||
return {
|
||||
chunkIndex,
|
||||
received: uploadMeta.receivedChunks.size,
|
||||
expected: uploadMeta.expectedChunks,
|
||||
progress,
|
||||
complete: uploadMeta.receivedChunks.size === uploadMeta.expectedChunks
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the upload by merging all chunks
|
||||
* @param {string} uploadId - Upload ID
|
||||
* @returns {Promise<Object>} - Merged file info
|
||||
*/
|
||||
async function completeUpload(uploadId) {
|
||||
const uploadMeta = activeUploads.get(uploadId);
|
||||
|
||||
if (!uploadMeta) {
|
||||
throw new Error('Upload not found or expired');
|
||||
}
|
||||
|
||||
// Verify all chunks received
|
||||
if (uploadMeta.receivedChunks.size !== uploadMeta.expectedChunks) {
|
||||
throw new Error(`Missing chunks: received ${uploadMeta.receivedChunks.size} of ${uploadMeta.expectedChunks}`);
|
||||
}
|
||||
|
||||
uploadMeta.status = 'merging';
|
||||
|
||||
// Create temp file for merged result
|
||||
const tempDir = path.join(getStoragePath(), 'temp', `merge_${Date.now()}_${Math.random().toString(36).substring(7)}`);
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
|
||||
const mergedFilePath = path.join(tempDir, uploadMeta.filename);
|
||||
const writeStream = require('fs').createWriteStream(mergedFilePath);
|
||||
|
||||
try {
|
||||
// Merge chunks in order
|
||||
for (let i = 0; i < uploadMeta.expectedChunks; i++) {
|
||||
const chunkPath = path.join(uploadMeta.uploadDir, `chunk_${String(i).padStart(6, '0')}`);
|
||||
const chunkData = await fs.readFile(chunkPath);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
writeStream.write(chunkData, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await new Promise((resolve) => writeStream.end(resolve));
|
||||
|
||||
// Verify file size
|
||||
const stats = await fs.stat(mergedFilePath);
|
||||
if (stats.size !== uploadMeta.fileSize) {
|
||||
logger.warn('Merged file size mismatch', {
|
||||
expected: uploadMeta.fileSize,
|
||||
actual: stats.size
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up chunks
|
||||
await fs.rm(uploadMeta.uploadDir, { recursive: true, force: true });
|
||||
|
||||
uploadMeta.status = 'completed';
|
||||
activeUploads.delete(uploadId);
|
||||
|
||||
logger.info('Chunked upload completed', {
|
||||
uploadId,
|
||||
filename: uploadMeta.filename,
|
||||
fileSize: stats.size,
|
||||
eventId: uploadMeta.eventId
|
||||
});
|
||||
|
||||
return {
|
||||
path: mergedFilePath,
|
||||
filename: uploadMeta.filename,
|
||||
size: stats.size,
|
||||
mimeType: uploadMeta.mimeType,
|
||||
eventId: uploadMeta.eventId,
|
||||
tempDir
|
||||
};
|
||||
} catch (error) {
|
||||
writeStream.destroy();
|
||||
uploadMeta.status = 'failed';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort and clean up an upload
|
||||
* @param {string} uploadId - Upload ID
|
||||
*/
|
||||
async function abortUpload(uploadId) {
|
||||
const uploadMeta = activeUploads.get(uploadId);
|
||||
|
||||
if (uploadMeta) {
|
||||
try {
|
||||
await fs.rm(uploadMeta.uploadDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to clean up upload directory', { uploadId, error: err.message });
|
||||
}
|
||||
|
||||
activeUploads.delete(uploadId);
|
||||
|
||||
logger.info('Chunked upload aborted', { uploadId });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get upload status
|
||||
* @param {string} uploadId - Upload ID
|
||||
* @returns {Object|null} - Upload status or null if not found
|
||||
*/
|
||||
function getUploadStatus(uploadId) {
|
||||
const uploadMeta = activeUploads.get(uploadId);
|
||||
|
||||
if (!uploadMeta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
uploadId,
|
||||
filename: uploadMeta.filename,
|
||||
fileSize: uploadMeta.fileSize,
|
||||
receivedChunks: uploadMeta.receivedChunks.size,
|
||||
expectedChunks: uploadMeta.expectedChunks,
|
||||
progress: (uploadMeta.receivedChunks.size / uploadMeta.expectedChunks) * 100,
|
||||
status: uploadMeta.status,
|
||||
createdAt: uploadMeta.createdAt,
|
||||
expiresAt: uploadMeta.expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired uploads
|
||||
*/
|
||||
async function cleanupExpiredUploads() {
|
||||
const now = Date.now();
|
||||
const expiredIds = [];
|
||||
|
||||
for (const [uploadId, meta] of activeUploads.entries()) {
|
||||
if (now > meta.expiresAt) {
|
||||
expiredIds.push(uploadId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const uploadId of expiredIds) {
|
||||
await abortUpload(uploadId);
|
||||
}
|
||||
|
||||
if (expiredIds.length > 0) {
|
||||
logger.info(`Cleaned up ${expiredIds.length} expired uploads`);
|
||||
}
|
||||
|
||||
return expiredIds.length;
|
||||
}
|
||||
|
||||
// Run cleanup every hour
|
||||
setInterval(cleanupExpiredUploads, 60 * 60 * 1000);
|
||||
|
||||
module.exports = {
|
||||
initializeUpload,
|
||||
uploadChunk,
|
||||
completeUpload,
|
||||
abortUpload,
|
||||
getUploadStatus,
|
||||
cleanupExpiredUploads,
|
||||
CHUNK_SIZE
|
||||
};
|
||||
@@ -3,8 +3,10 @@ const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { generateThumbnail } = require('./imageProcessor');
|
||||
const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
|
||||
const logger = require('../utils/logger');
|
||||
const { isVideoMimeType } = require('../utils/fileSecurityUtils');
|
||||
const mime = require('mime-types');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const WATCH_PATH = () => path.join(getStoragePath(), 'events/active');
|
||||
@@ -47,9 +49,11 @@ async function processNewPhoto(filePath) {
|
||||
const eventSlug = pathParts[0];
|
||||
const photoType = pathParts[1] === 'collages' ? 'collage' : 'individual';
|
||||
|
||||
// Check if this is an image file
|
||||
// Check if this is an image or video file
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
|
||||
const detectedMime = mime.lookup(filePath) || '';
|
||||
const isVideo = isVideoMimeType(detectedMime, filePath) || ['.mp4', '.mov', '.webm'].includes(ext);
|
||||
if (!isVideo && !['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
|
||||
|
||||
// Skip temporary upload files
|
||||
const filename = path.basename(filePath);
|
||||
@@ -65,11 +69,17 @@ async function processNewPhoto(filePath) {
|
||||
// Get file stats
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
// Generate thumbnail
|
||||
const thumbnailPath = await generateThumbnail(filePath);
|
||||
// Generate thumbnail or placeholder
|
||||
let thumbnailPath = null;
|
||||
if (isVideo) {
|
||||
thumbnailPath = await generateVideoPlaceholder(filename);
|
||||
} else {
|
||||
thumbnailPath = await generateThumbnail(filePath);
|
||||
}
|
||||
|
||||
// Calculate relative thumbnail path
|
||||
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
|
||||
const mimeType = detectedMime || (isVideo ? 'video/mp4' : 'image/jpeg');
|
||||
|
||||
// Check if photo already exists
|
||||
const existingPhoto = await db('photos')
|
||||
@@ -83,8 +93,9 @@ async function processNewPhoto(filePath) {
|
||||
filename: path.basename(filePath),
|
||||
path: relativePath,
|
||||
thumbnail_path: relativeThumbPath,
|
||||
type: photoType,
|
||||
size_bytes: stats.size
|
||||
type: isVideo ? 'video' : photoType,
|
||||
size_bytes: stats.size,
|
||||
mime_type: mimeType
|
||||
});
|
||||
|
||||
logger.info(`Added new photo: ${relativePath}`);
|
||||
|
||||
@@ -237,4 +237,54 @@ async function ensureThumbnail(photo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail };
|
||||
async function generateVideoPlaceholder(originalFilename, options = {}) {
|
||||
const parsed = path.parse(originalFilename || '');
|
||||
const baseName = parsed.name || 'video';
|
||||
const thumbnailDir = getThumbnailPath();
|
||||
const thumbnailFilename = `thumb_${baseName}.jpg`;
|
||||
const thumbnailPath = path.join(thumbnailDir, thumbnailFilename);
|
||||
|
||||
const settings = await getThumbnailSettings();
|
||||
const width = settings.width || DEFAULT_THUMBNAIL_WIDTH;
|
||||
const height = settings.height || DEFAULT_THUMBNAIL_HEIGHT;
|
||||
|
||||
if (options.regenerate) {
|
||||
try {
|
||||
await fs.unlink(thumbnailPath);
|
||||
} catch (_) {
|
||||
// ignore if missing
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.mkdir(thumbnailDir, { recursive: true });
|
||||
const svg = `
|
||||
<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 };
|
||||
|
||||
@@ -3,6 +3,7 @@ const fs = require('fs').promises;
|
||||
const { db } = require('../database/db');
|
||||
const { generateThumbnail } = require('./imageProcessor');
|
||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||
const { processUploadedVideo, isVideoMimeType } = require('./videoProcessor');
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
@@ -150,43 +151,66 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
}
|
||||
}
|
||||
|
||||
// Generate thumbnail
|
||||
const thumbnailPath = await generateThumbnail(newPath);
|
||||
|
||||
// Determine if this is a video or image
|
||||
const isVideo = isVideoMimeType(file.mimetype);
|
||||
const mediaType = isVideo ? 'video' : 'image';
|
||||
|
||||
// Generate thumbnail and extract metadata
|
||||
let thumbnailPath;
|
||||
let videoMetadata = null;
|
||||
|
||||
if (isVideo) {
|
||||
// Process video: extract metadata and generate thumbnail
|
||||
const thumbnailDir = path.join(getStoragePath(), 'thumbnails');
|
||||
await fs.mkdir(thumbnailDir, { recursive: true });
|
||||
const videoThumbnailPath = path.join(thumbnailDir, `thumb_${newFilename.replace(/\.[^.]+$/, '.jpg')}`);
|
||||
|
||||
const result = await processUploadedVideo(newPath, videoThumbnailPath);
|
||||
videoMetadata = result.metadata;
|
||||
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
|
||||
} else {
|
||||
// Process image: generate thumbnail
|
||||
thumbnailPath = await generateThumbnail(newPath);
|
||||
}
|
||||
|
||||
// Calculate relative paths
|
||||
const storagePath = getStoragePath();
|
||||
const relativePath = path.relative(path.join(storagePath, 'events/active'), newPath);
|
||||
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
|
||||
|
||||
// Add to database with uploaded_by field
|
||||
|
||||
// Add to database with uploaded_by field and media metadata
|
||||
let insertResult;
|
||||
const clientName = trx?.client?.config?.client;
|
||||
const supportsReturning = ['pg', 'postgres', 'postgresql'].includes(clientName);
|
||||
|
||||
const photoData = {
|
||||
event_id: eventId,
|
||||
filename: newFilename,
|
||||
path: relativePath,
|
||||
thumbnail_path: relativeThumbPath,
|
||||
type: photoType,
|
||||
size_bytes: file.size,
|
||||
uploaded_by: uploadedBy,
|
||||
source_origin: 'managed',
|
||||
media_type: mediaType,
|
||||
mime_type: file.mimetype
|
||||
};
|
||||
|
||||
// Add video-specific metadata if applicable
|
||||
if (isVideo && videoMetadata) {
|
||||
photoData.duration = videoMetadata.duration;
|
||||
photoData.video_codec = videoMetadata.videoCodec;
|
||||
photoData.audio_codec = videoMetadata.audioCodec;
|
||||
photoData.width = videoMetadata.width;
|
||||
photoData.height = videoMetadata.height;
|
||||
}
|
||||
|
||||
if (supportsReturning) {
|
||||
insertResult = await trx('photos')
|
||||
.insert({
|
||||
event_id: eventId,
|
||||
filename: newFilename,
|
||||
path: relativePath,
|
||||
thumbnail_path: relativeThumbPath,
|
||||
type: photoType,
|
||||
size_bytes: file.size,
|
||||
uploaded_by: uploadedBy,
|
||||
source_origin: 'managed'
|
||||
})
|
||||
.insert(photoData)
|
||||
.returning('id');
|
||||
} else {
|
||||
insertResult = await trx('photos').insert({
|
||||
event_id: eventId,
|
||||
filename: newFilename,
|
||||
path: relativePath,
|
||||
thumbnail_path: relativeThumbPath,
|
||||
type: photoType,
|
||||
size_bytes: file.size,
|
||||
uploaded_by: uploadedBy,
|
||||
source_origin: 'managed'
|
||||
});
|
||||
insertResult = await trx('photos').insert(photoData);
|
||||
}
|
||||
|
||||
const insertedId = Array.isArray(insertResult)
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
const ffmpeg = require('fluent-ffmpeg');
|
||||
const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path;
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Set FFmpeg path
|
||||
ffmpeg.setFfmpegPath(ffmpegPath);
|
||||
|
||||
/**
|
||||
* Extract video metadata using FFmpeg
|
||||
* @param {string} videoPath - Path to the video file
|
||||
* @returns {Promise<Object>} - Video metadata
|
||||
*/
|
||||
async function extractVideoMetadata(videoPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg.ffprobe(videoPath, (err, metadata) => {
|
||||
if (err) {
|
||||
logger.error('Error extracting video metadata', { error: err.message, videoPath });
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
try {
|
||||
const videoStream = metadata.streams.find(s => s.codec_type === 'video');
|
||||
const audioStream = metadata.streams.find(s => s.codec_type === 'audio');
|
||||
|
||||
const result = {
|
||||
duration: Math.floor(metadata.format.duration || 0),
|
||||
width: videoStream?.width || null,
|
||||
height: videoStream?.height || null,
|
||||
videoCodec: videoStream?.codec_name || null,
|
||||
audioCodec: audioStream?.codec_name || null,
|
||||
size: metadata.format.size || 0,
|
||||
bitrate: metadata.format.bit_rate || null,
|
||||
format: metadata.format.format_name || null
|
||||
};
|
||||
|
||||
resolve(result);
|
||||
} catch (parseErr) {
|
||||
logger.error('Error parsing video metadata', { error: parseErr.message });
|
||||
reject(parseErr);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate thumbnail from video
|
||||
* @param {string} videoPath - Path to the video file
|
||||
* @param {string} outputPath - Path for the output thumbnail
|
||||
* @param {Object} options - Thumbnail options
|
||||
* @returns {Promise<string>} - Path to generated thumbnail
|
||||
*/
|
||||
async function generateVideoThumbnail(videoPath, outputPath, options = {}) {
|
||||
const {
|
||||
timeOffset = '00:00:01', // Take screenshot at 1 second
|
||||
size = '300x300',
|
||||
quality = 2 // 1-31, lower is better quality
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg(videoPath)
|
||||
.screenshots({
|
||||
timestamps: [timeOffset],
|
||||
filename: path.basename(outputPath),
|
||||
folder: path.dirname(outputPath),
|
||||
size: size
|
||||
})
|
||||
.on('end', () => {
|
||||
logger.info('Video thumbnail generated', { videoPath, outputPath });
|
||||
resolve(outputPath);
|
||||
})
|
||||
.on('error', (err) => {
|
||||
logger.error('Error generating video thumbnail', { error: err.message, videoPath });
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a file is a valid video
|
||||
* @param {string} videoPath - Path to the video file
|
||||
* @returns {Promise<boolean>} - True if valid video
|
||||
*/
|
||||
async function isValidVideo(videoPath) {
|
||||
try {
|
||||
const metadata = await extractVideoMetadata(videoPath);
|
||||
return metadata.duration > 0 && metadata.width > 0 && metadata.height > 0;
|
||||
} catch (error) {
|
||||
logger.error('Video validation failed', { error: error.message, videoPath });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video duration in seconds
|
||||
* @param {string} videoPath - Path to the video file
|
||||
* @returns {Promise<number>} - Duration in seconds
|
||||
*/
|
||||
async function getVideoDuration(videoPath) {
|
||||
try {
|
||||
const metadata = await extractVideoMetadata(videoPath);
|
||||
return metadata.duration;
|
||||
} catch (error) {
|
||||
logger.error('Error getting video duration', { error: error.message });
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process uploaded video - extract metadata and generate thumbnail
|
||||
* @param {string} videoPath - Path to the video file
|
||||
* @param {string} thumbnailPath - Path for the thumbnail
|
||||
* @param {Object} options - Processing options
|
||||
* @returns {Promise<Object>} - Video metadata and processing result
|
||||
*/
|
||||
async function processUploadedVideo(videoPath, thumbnailPath, options = {}) {
|
||||
try {
|
||||
// Validate video
|
||||
const isValid = await isValidVideo(videoPath);
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid video file');
|
||||
}
|
||||
|
||||
// Extract metadata
|
||||
const metadata = await extractVideoMetadata(videoPath);
|
||||
|
||||
// Generate thumbnail
|
||||
await generateVideoThumbnail(videoPath, thumbnailPath, options);
|
||||
|
||||
// Verify thumbnail was created
|
||||
try {
|
||||
await fs.access(thumbnailPath);
|
||||
} catch (err) {
|
||||
throw new Error('Thumbnail generation failed');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
metadata,
|
||||
thumbnailPath
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error processing video', { error: error.message, videoPath });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video thumbnail at specific time
|
||||
* @param {string} videoPath - Path to video file
|
||||
* @param {string} outputPath - Output path for thumbnail
|
||||
* @param {number} timeInSeconds - Time in seconds to capture thumbnail
|
||||
* @returns {Promise<string>} - Path to thumbnail
|
||||
*/
|
||||
async function getThumbnailAtTime(videoPath, outputPath, timeInSeconds = 1) {
|
||||
const hours = Math.floor(timeInSeconds / 3600);
|
||||
const minutes = Math.floor((timeInSeconds % 3600) / 60);
|
||||
const seconds = Math.floor(timeInSeconds % 60);
|
||||
const timeOffset = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
|
||||
return generateVideoThumbnail(videoPath, outputPath, { timeOffset });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file is a video based on MIME type
|
||||
* @param {string} mimeType - MIME type of the file
|
||||
* @returns {boolean} - True if video MIME type
|
||||
*/
|
||||
function isVideoMimeType(mimeType) {
|
||||
return mimeType && mimeType.startsWith('video/');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extractVideoMetadata,
|
||||
generateVideoThumbnail,
|
||||
isValidVideo,
|
||||
getVideoDuration,
|
||||
processUploadedVideo,
|
||||
getThumbnailAtTime,
|
||||
isVideoMimeType
|
||||
};
|
||||
@@ -45,7 +45,7 @@ function isPathSafe(filePath) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced MIME type validation
|
||||
* Enhanced MIME type validation for images and videos
|
||||
*/
|
||||
const ALLOWED_IMAGE_TYPES = {
|
||||
'image/jpeg': {
|
||||
@@ -81,6 +81,40 @@ const ALLOWED_IMAGE_TYPES = {
|
||||
}
|
||||
};
|
||||
|
||||
const ALLOWED_VIDEO_TYPES = {
|
||||
'video/mp4': {
|
||||
extensions: ['.mp4', '.m4v'],
|
||||
magicNumbers: [
|
||||
{ offset: 4, bytes: [0x66, 0x74, 0x79, 0x70] } // 'ftyp' signature for MP4
|
||||
]
|
||||
},
|
||||
'video/webm': {
|
||||
extensions: ['.webm'],
|
||||
magicNumbers: [
|
||||
{ offset: 0, bytes: [0x1A, 0x45, 0xDF, 0xA3] } // EBML header for WebM/MKV
|
||||
]
|
||||
},
|
||||
'video/quicktime': {
|
||||
extensions: ['.mov'],
|
||||
magicNumbers: [
|
||||
{ offset: 4, bytes: [0x66, 0x74, 0x79, 0x70, 0x71, 0x74] } // 'ftypqt' signature for QuickTime
|
||||
]
|
||||
},
|
||||
'video/x-msvideo': {
|
||||
extensions: ['.avi'],
|
||||
magicNumbers: [
|
||||
{ offset: 0, bytes: [0x52, 0x49, 0x46, 0x46] }, // RIFF
|
||||
{ offset: 8, bytes: [0x41, 0x56, 0x49, 0x20] } // 'AVI '
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// Combined media types
|
||||
const ALLOWED_MEDIA_TYPES = {
|
||||
...ALLOWED_IMAGE_TYPES,
|
||||
...ALLOWED_VIDEO_TYPES
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate file type by MIME type and extension
|
||||
* @param {string} filename - The filename
|
||||
@@ -93,16 +127,16 @@ function validateFileType(filename, mimetype, allowedTypes) {
|
||||
if (!allowedTypes.includes(mimetype)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Get file extension
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
|
||||
|
||||
// Check if extension matches the MIME type
|
||||
const typeConfig = ALLOWED_IMAGE_TYPES[mimetype];
|
||||
const typeConfig = ALLOWED_MEDIA_TYPES[mimetype];
|
||||
if (!typeConfig || !typeConfig.extensions.includes(ext)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -114,22 +148,22 @@ function validateFileType(filename, mimetype, allowedTypes) {
|
||||
*/
|
||||
async function validateFileContent(filePath, expectedMimeType) {
|
||||
try {
|
||||
const typeConfig = ALLOWED_IMAGE_TYPES[expectedMimeType];
|
||||
const typeConfig = ALLOWED_MEDIA_TYPES[expectedMimeType];
|
||||
if (!typeConfig) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Skip validation for file types without magic numbers (like SVG)
|
||||
if (!typeConfig.magicNumbers) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Read the first 20 bytes of the file (enough for most magic numbers)
|
||||
const buffer = Buffer.alloc(20);
|
||||
const fileHandle = await fs.open(filePath, 'r');
|
||||
await fileHandle.read(buffer, 0, 20, 0);
|
||||
await fileHandle.close();
|
||||
|
||||
|
||||
// Check magic numbers
|
||||
return typeConfig.magicNumbers.every(magic => {
|
||||
for (let i = 0; i < magic.bytes.length; i++) {
|
||||
@@ -154,13 +188,13 @@ function getSafeFilename(originalFilename) {
|
||||
const timestamp = Date.now();
|
||||
const randomString = Math.random().toString(36).substring(2, 15);
|
||||
const ext = path.extname(originalFilename).toLowerCase();
|
||||
|
||||
// Validate extension
|
||||
const validExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg', '.ico'];
|
||||
|
||||
// Validate extension - including both image and video extensions
|
||||
const validExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg', '.ico', '.mp4', '.m4v', '.webm', '.mov', '.avi'];
|
||||
if (!validExtensions.includes(ext)) {
|
||||
throw new Error('Invalid file extension');
|
||||
}
|
||||
|
||||
|
||||
return `upload_${timestamp}_${randomString}${ext}`;
|
||||
}
|
||||
|
||||
@@ -229,5 +263,7 @@ module.exports = {
|
||||
validateFileContent,
|
||||
getSafeFilename,
|
||||
createFileUploadValidator,
|
||||
ALLOWED_IMAGE_TYPES
|
||||
ALLOWED_IMAGE_TYPES,
|
||||
ALLOWED_VIDEO_TYPES,
|
||||
ALLOWED_MEDIA_TYPES
|
||||
};
|
||||
@@ -43,6 +43,13 @@ done
|
||||
|
||||
>&2 echo "Target database \"$target_db\" is ready."
|
||||
|
||||
# Ensure storage directories exist with proper permissions (Issue #67 fix)
|
||||
# When host directories are bind-mounted, the container's built-in directories are overridden
|
||||
# This ensures the required directory structure exists before the application starts
|
||||
echo "Ensuring storage directories exist..."
|
||||
STORAGE_BASE="${STORAGE_PATH:-/app/storage}"
|
||||
mkdir -p "$STORAGE_BASE/events/active" "$STORAGE_BASE/events/archived" "$STORAGE_BASE/thumbnails" 2>/dev/null || true
|
||||
|
||||
# Run migrations (use safe runner in production)
|
||||
echo "Running database migrations..."
|
||||
if [ "$NODE_ENV" = "production" ]; then
|
||||
|
||||
+8
-3
@@ -12,6 +12,9 @@ LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
|
||||
LABEL org.opencontainers.image.description="PicPeak Frontend Application"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
||||
RUN npm install -g npm@latest
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
@@ -27,11 +30,13 @@ COPY . .
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
# Production stage (use Alpine with patched libpng)
|
||||
FROM nginx:1.27-alpine3.21
|
||||
|
||||
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
# Ensure libpng includes CVE fixes (pull patched version from edge)
|
||||
RUN apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/edge/main 'libpng>=1.6.51-r0'
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache curl
|
||||
@@ -63,4 +68,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
USER nginx
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
Generated
+1611
-1164
File diff suppressed because it is too large
Load Diff
@@ -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 { Check, Download, Trash2, Eye, Package, MessageSquare, Star } from 'lucide-react';
|
||||
import { Check, Download, Trash2, Eye, Package, MessageSquare, Star, Video } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { AdminPhoto } from '../../services/photos.service';
|
||||
import { photosService } from '../../services/photos.service';
|
||||
@@ -20,6 +21,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
onPhotoClick,
|
||||
onPhotosDeleted
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
@@ -126,7 +128,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
onClick={toggleSelectionMode}
|
||||
leftIcon={<Package className="w-4 h-4" />}
|
||||
>
|
||||
{isSelectionMode ? 'Cancel Selection' : 'Select Photos'}
|
||||
{isSelectionMode ? t('gallery.cancelSelection', 'Cancel Selection') : t('gallery.selectPhotos', 'Select Photos')}
|
||||
</Button>
|
||||
|
||||
{(isSelectionMode || selectedPhotos.size > 0) && (
|
||||
@@ -136,13 +138,13 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
size="sm"
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
{selectedPhotos.size === photos.length ? 'Deselect All' : 'Select All'}
|
||||
{selectedPhotos.size === photos.length ? t('gallery.deselectAll', 'Deselect All') : t('gallery.selectAll', 'Select All')}
|
||||
</Button>
|
||||
|
||||
{selectedPhotos.size > 0 && (
|
||||
<>
|
||||
<span className="text-sm text-neutral-600">
|
||||
{selectedPhotos.size} selected
|
||||
{t('gallery.photosSelected', { count: selectedPhotos.size })}
|
||||
</span>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete Selected
|
||||
{t('gallery.deleteSelected', 'Delete Selected')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -159,7 +161,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-neutral-600">
|
||||
{photos.length} photo{photos.length !== 1 ? 's' : ''}
|
||||
{t('gallery.photosCount', { count: photos.length })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -170,6 +172,9 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
const commentCount = photo.comment_count ?? 0;
|
||||
const averageRating = photo.average_rating ?? 0;
|
||||
const likeCount = photo.like_count ?? 0;
|
||||
const isVideo = (photo.media_type === 'video') ||
|
||||
(photo.mime_type && photo.mime_type.startsWith('video/')) ||
|
||||
photo.type === 'video';
|
||||
return (
|
||||
<div
|
||||
key={photo.id}
|
||||
@@ -259,6 +264,15 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
</span>
|
||||
</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) */}
|
||||
{(commentCount > 0 || averageRating > 0 || likeCount > 0) && (
|
||||
@@ -284,7 +298,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
|
||||
{photos.length === 0 && (
|
||||
<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>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { photosService } from '../../services/photos.service';
|
||||
import { feedbackService, type PhotoFeedback, type FeedbackSummary } from '../../services/feedback.service';
|
||||
import { Button } from '../common';
|
||||
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
import { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
|
||||
|
||||
type AdminFeedbackResponse = {
|
||||
feedback: PhotoFeedback[];
|
||||
@@ -39,6 +40,11 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const currentPhoto = photos[currentIndex];
|
||||
const isVideo = currentPhoto
|
||||
? (currentPhoto.media_type === 'video' ||
|
||||
(currentPhoto.mime_type && String(currentPhoto.mime_type).startsWith('video/')) ||
|
||||
currentPhoto.type === 'video')
|
||||
: false;
|
||||
const averageRating = currentPhoto?.average_rating ?? 0;
|
||||
const likeCount = currentPhoto?.like_count ?? 0;
|
||||
const favoriteCount = currentPhoto?.favorite_count ?? 0;
|
||||
@@ -191,19 +197,35 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
<div className="flex flex-col lg:flex-row gap-6 max-w-7xl mx-auto p-4 w-full h-full">
|
||||
{/* Image */}
|
||||
<div className="flex-1 flex items-center justify-center min-h-0">
|
||||
<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>
|
||||
{isVideo ? (
|
||||
<AdminAuthenticatedVideo
|
||||
src={currentPhoto.url}
|
||||
className="max-w-full max-h-full bg-black"
|
||||
poster={currentPhoto.thumbnail_url || undefined}
|
||||
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 media</p>
|
||||
</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>
|
||||
|
||||
{/* Sidebar */}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Search, Filter, SortAsc, SortDesc } from 'lucide-react';
|
||||
import { Input } from '../common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface PhotoFiltersProps {
|
||||
categories: Array<{ id: number; name: string; slug: string }>;
|
||||
selectedCategory: number | null | undefined;
|
||||
categories: Array<{ id: number | string; name: string; slug: string }>;
|
||||
selectedCategory: number | string | null | undefined;
|
||||
searchTerm: string;
|
||||
sortBy: 'date' | 'name' | 'size' | 'rating';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
onCategoryChange: (categoryId: number | null | undefined) => void;
|
||||
onCategoryChange: (categoryId: number | string | null | undefined) => void;
|
||||
onSearchChange: (search: string) => void;
|
||||
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating', order: 'asc' | 'desc') => void;
|
||||
mediaType?: 'all' | 'photo' | 'video';
|
||||
onMediaTypeChange?: (mediaType: 'all' | 'photo' | 'video') => void;
|
||||
showMediaFilter?: boolean;
|
||||
}
|
||||
|
||||
export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
@@ -21,8 +25,12 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
sortOrder,
|
||||
onCategoryChange,
|
||||
onSearchChange,
|
||||
onSortChange
|
||||
onSortChange,
|
||||
mediaType = 'all',
|
||||
onMediaTypeChange,
|
||||
showMediaFilter = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const handleSortToggle = () => {
|
||||
onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
};
|
||||
@@ -34,7 +42,7 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by filename..."
|
||||
placeholder={t('gallery.searchByFilename', 'Search by filename...')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
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" />
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
<option value="0">Uncategorized</option>
|
||||
<option value="">{t('gallery.allCategories', 'All Categories')}</option>
|
||||
<option value="0">{t('gallery.uncategorized', 'Uncategorized')}</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
@@ -59,6 +72,21 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
</select>
|
||||
</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 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
@@ -66,16 +94,16 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
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"
|
||||
>
|
||||
<option value="date">Sort by Date</option>
|
||||
<option value="name">Sort by Name</option>
|
||||
<option value="size">Sort by Size</option>
|
||||
<option value="rating">Sort by Rating</option>
|
||||
<option value="date">{t('gallery.sortByDate', 'Sort by Date')}</option>
|
||||
<option value="name">{t('gallery.sortByName', 'Sort by Name')}</option>
|
||||
<option value="size">{t('gallery.sortBySize', 'Sort by Size')}</option>
|
||||
<option value="rating">{t('gallery.sortByRating', 'Sort by Rating')}</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={handleSortToggle}
|
||||
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' ? (
|
||||
<SortAsc className="w-5 h-5 text-neutral-600" />
|
||||
@@ -87,4 +115,4 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -236,7 +236,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
accept="image/jpeg,image/png,image/webp,video/mp4,video/webm,video/quicktime,video/x-msvideo"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
@@ -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]">
|
||||
{/* Fixed Header */}
|
||||
<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
|
||||
variant="ghost"
|
||||
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 { PasswordResetModal } from './PasswordResetModal';
|
||||
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
export { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
|
||||
export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
|
||||
export { ThemeDisplay } from './ThemeDisplay';
|
||||
export { ThemeEditorModal } from './ThemeEditorModal';
|
||||
@@ -29,4 +30,4 @@ export { BackupHistory } from './BackupHistory';
|
||||
export { RestoreWizard } from './RestoreWizard';
|
||||
export { FeedbackSettings } from './FeedbackSettings';
|
||||
export { FeedbackModerationPanel } from './FeedbackModerationPanel';
|
||||
export { WordFilterManager } from './WordFilterManager';
|
||||
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 { LanguageSelector } from './LanguageSelector';
|
||||
export { AuthenticatedImage } from './AuthenticatedImage';
|
||||
export { AuthenticatedVideo } from './AuthenticatedVideo';
|
||||
export { ProtectedImage } from './ProtectedImage';
|
||||
export { ProtectionWarning } from './ProtectionWarning';
|
||||
export { ReCaptcha } from './ReCaptcha';
|
||||
export { PasswordGenerator } from './PasswordGenerator';
|
||||
export { PasswordGenerator } from './PasswordGenerator';
|
||||
|
||||
@@ -9,8 +9,8 @@ interface GallerySidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
categories: PhotoCategory[];
|
||||
selectedCategoryId: number | null;
|
||||
onCategoryChange: (categoryId: number | null) => void;
|
||||
selectedCategoryId: number | string | null;
|
||||
onCategoryChange: (categoryId: number | string | null) => void;
|
||||
searchTerm: string;
|
||||
onSearchChange: (term: string) => void;
|
||||
sortBy: 'date' | 'name' | 'size' | 'rating';
|
||||
@@ -22,7 +22,7 @@ interface GallerySidebarProps {
|
||||
onDownloadSelected: () => void;
|
||||
isDownloading: boolean;
|
||||
allowDownloads?: boolean;
|
||||
photoCounts?: Record<number, number>;
|
||||
photoCounts?: Record<number | string, number>;
|
||||
totalPhotos: number;
|
||||
isMobile: boolean;
|
||||
galleryLayout?: string;
|
||||
@@ -34,6 +34,9 @@ interface GallerySidebarProps {
|
||||
likeCount?: number;
|
||||
favoriteCount?: number;
|
||||
ratedCount?: number;
|
||||
mediaFilter?: 'all' | 'photo' | 'video';
|
||||
onMediaFilterChange?: (filter: 'all' | 'photo' | 'video') => void;
|
||||
showMediaFilter?: boolean;
|
||||
}
|
||||
|
||||
export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
@@ -64,7 +67,10 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
onFilterChange,
|
||||
likeCount = 0,
|
||||
favoriteCount = 0,
|
||||
ratedCount = 0
|
||||
ratedCount = 0,
|
||||
mediaFilter = 'all',
|
||||
onMediaFilterChange,
|
||||
showMediaFilter = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
@@ -288,6 +294,47 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
</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 */}
|
||||
{galleryLayout !== 'carousel' && galleryLayout !== 'timeline' && (
|
||||
<div className="p-4">
|
||||
|
||||
@@ -44,7 +44,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
const { t } = useTranslation();
|
||||
const { logout } = useGalleryAuth();
|
||||
const { setTheme, theme } = useTheme();
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating'>('date');
|
||||
const [brandingSettings, setBrandingSettings] = useState<any>(null);
|
||||
@@ -57,8 +57,22 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
const { watermarkEnabled } = useWatermarkSettings();
|
||||
const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard');
|
||||
const [filterType, setFilterType] = useState<FilterType>('all');
|
||||
const [mediaFilter, setMediaFilter] = useState<'all' | 'photo' | 'video'>('all');
|
||||
const [guestId, setGuestId] = useState<string>('');
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -176,6 +190,25 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
}, [settingsData]);
|
||||
|
||||
const availableMediaTypes = useMemo(() => {
|
||||
const types = new Set<'photo' | 'video'>();
|
||||
(data?.photos || []).forEach((photo) => {
|
||||
const mediaType = resolveMediaType(photo);
|
||||
if (mediaType === 'photo' || mediaType === 'video') {
|
||||
types.add(mediaType);
|
||||
}
|
||||
});
|
||||
return types;
|
||||
}, [data?.photos]);
|
||||
|
||||
const showMediaFilter = availableMediaTypes.has('photo') && availableMediaTypes.has('video');
|
||||
|
||||
useEffect(() => {
|
||||
if (!showMediaFilter && mediaFilter !== 'all') {
|
||||
setMediaFilter('all');
|
||||
}
|
||||
}, [showMediaFilter, mediaFilter]);
|
||||
|
||||
// Determine a stable hero photo from the initial (unfiltered) load
|
||||
useEffect(() => {
|
||||
if (!staticHeroPhoto && data?.photos && filterType === 'all') {
|
||||
@@ -185,7 +218,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
hero = data.photos.find(p => p.id === heroId) || null;
|
||||
}
|
||||
if (!hero && data.photos.length > 0) {
|
||||
hero = data.photos[0];
|
||||
const firstPhoto = data.photos.find(p => resolveMediaType(p) === 'photo');
|
||||
hero = firstPhoto || data.photos[0];
|
||||
}
|
||||
if (hero) {
|
||||
setStaticHeroPhoto(hero);
|
||||
@@ -259,6 +293,12 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
if (!data?.photos) return [];
|
||||
|
||||
let photos = [...data.photos];
|
||||
|
||||
if (mediaFilter === 'photo') {
|
||||
photos = photos.filter(photo => resolveMediaType(photo) !== 'video');
|
||||
} else if (mediaFilter === 'video') {
|
||||
photos = photos.filter(photo => resolveMediaType(photo) === 'video');
|
||||
}
|
||||
|
||||
// Apply category filter
|
||||
if (selectedCategoryId) {
|
||||
@@ -323,7 +363,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
|
||||
return photos;
|
||||
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug, filterType]);
|
||||
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug, filterType, mediaFilter]);
|
||||
|
||||
const likeCount = useMemo(
|
||||
() => data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0,
|
||||
@@ -388,14 +428,20 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
// Calculate photo counts per category
|
||||
const photoCounts = useMemo(() => {
|
||||
if (!data?.photos) return {};
|
||||
const counts: Record<number, number> = {};
|
||||
data.photos.forEach(photo => {
|
||||
const counts: Record<number | string, number> = {};
|
||||
data.photos
|
||||
.filter(photo => {
|
||||
if (mediaFilter === 'photo') return resolveMediaType(photo) !== 'video';
|
||||
if (mediaFilter === 'video') return resolveMediaType(photo) === 'video';
|
||||
return true;
|
||||
})
|
||||
.forEach(photo => {
|
||||
if (photo.category_id) {
|
||||
counts[photo.category_id] = (counts[photo.category_id] || 0) + 1;
|
||||
}
|
||||
});
|
||||
return counts;
|
||||
}, [data?.photos]);
|
||||
}, [data?.photos, mediaFilter]);
|
||||
|
||||
// Track search usage with debouncing
|
||||
useEffect(() => {
|
||||
@@ -497,6 +543,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
filterType={filterType}
|
||||
onFilterChange={setFilterType}
|
||||
mediaFilter={mediaFilter}
|
||||
onMediaFilterChange={setMediaFilter}
|
||||
showMediaFilter={showMediaFilter}
|
||||
likeCount={likeCount}
|
||||
favoriteCount={favoriteCount}
|
||||
ratedCount={ratedCount}
|
||||
@@ -582,16 +631,19 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
onCategoryChange={setSelectedCategoryId}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
photoCount={filteredPhotos.length}
|
||||
// Feedback filter props
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
currentFilter={filterType}
|
||||
onFilterChange={setFilterType}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
photoCount={filteredPhotos.length}
|
||||
// Feedback filter props
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
currentFilter={filterType}
|
||||
onFilterChange={setFilterType}
|
||||
mediaFilter={mediaFilter}
|
||||
onMediaFilterChange={setMediaFilter}
|
||||
showMediaFilter={showMediaFilter}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Photo Grid */}
|
||||
<div className={showSidebar ? "mt-6" : "mt-6"}>
|
||||
|
||||
@@ -32,6 +32,9 @@ interface PhotoFilterBarProps {
|
||||
feedbackEnabled?: boolean;
|
||||
currentFilter?: FilterType;
|
||||
onFilterChange?: (filter: FilterType) => void;
|
||||
mediaFilter?: 'all' | 'photo' | 'video';
|
||||
onMediaFilterChange?: (filter: 'all' | 'photo' | 'video') => void;
|
||||
showMediaFilter?: boolean;
|
||||
}
|
||||
|
||||
export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||
@@ -47,6 +50,9 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||
feedbackEnabled = false,
|
||||
currentFilter = 'all',
|
||||
onFilterChange,
|
||||
mediaFilter = 'all',
|
||||
onMediaFilterChange,
|
||||
showMediaFilter = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [showSortMenu, setShowSortMenu] = useState(false);
|
||||
@@ -148,7 +154,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||
leftIcon={<Grid className="w-3 h-3 md:w-4 md:h-4" />}
|
||||
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>
|
||||
{categories.map((category) => {
|
||||
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">
|
||||
{photoCount} {photoCount === 1 ? t('common.photo') : t('common.photos')}
|
||||
{photoCount} {t('common.media', 'media')}
|
||||
</p>
|
||||
</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 */}
|
||||
{feedbackEnabled && onFilterChange && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Download, Maximize2, Check, Package, MessageSquare, Star } from 'lucide-react';
|
||||
import { Download, Maximize2, Check, Package, MessageSquare, Star, Play } from 'lucide-react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { toast as toastify } from 'react-toastify';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -336,14 +336,25 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Photo type badge */}
|
||||
{photo.type === 'collage' && (
|
||||
<div className="absolute bottom-2 left-2">
|
||||
{/* Media type badges */}
|
||||
<div className="absolute bottom-2 left-2 flex gap-2">
|
||||
{photo.type === 'collage' && (
|
||||
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
|
||||
Collage
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{photo.media_type === 'video' && (
|
||||
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded flex items-center gap-1">
|
||||
<Play className="w-3 h-3" fill="white" />
|
||||
Video
|
||||
{photo.duration && (
|
||||
<span className="ml-1">
|
||||
{Math.floor(photo.duration / 60)}:{String(photo.duration % 60).padStart(2, '0')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="skeleton aspect-square w-full" />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AuthenticatedImage } from '../common';
|
||||
import { PhotoFeedback } from './PhotoFeedback';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||
import { VideoPlayer } from './VideoPlayer';
|
||||
|
||||
interface PhotoLightboxProps {
|
||||
photos: Photo[];
|
||||
@@ -448,49 +449,58 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image container */}
|
||||
{/* Image/Video container */}
|
||||
<div
|
||||
className="absolute top-0 left-0 bottom-0 flex items-center justify-center z-0"
|
||||
onClick={handleImageClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onClick={currentPhoto.media_type === 'video' ? undefined : handleImageClick}
|
||||
onMouseDown={currentPhoto.media_type === 'video' ? undefined : handleMouseDown}
|
||||
onMouseMove={currentPhoto.media_type === 'video' ? undefined : handleMouseMove}
|
||||
onMouseUp={currentPhoto.media_type === 'video' ? undefined : handleMouseUp}
|
||||
onMouseLeave={currentPhoto.media_type === 'video' ? undefined : handleMouseUp}
|
||||
onTouchStart={currentPhoto.media_type === 'video' ? undefined : handleTouchStart}
|
||||
onTouchMove={currentPhoto.media_type === 'video' ? undefined : handleTouchMove}
|
||||
onTouchEnd={currentPhoto.media_type === 'video' ? undefined : handleTouchEnd}
|
||||
style={{
|
||||
cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default',
|
||||
cursor: currentPhoto.media_type === 'video' ? 'default' : (zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'),
|
||||
right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0,
|
||||
}}
|
||||
>
|
||||
<AuthenticatedImage
|
||||
src={currentPhoto.url}
|
||||
alt={currentPhoto.filename}
|
||||
fallbackSrc={currentPhoto.thumbnail_url || undefined}
|
||||
className="max-w-full max-h-full object-contain select-none"
|
||||
style={{
|
||||
transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`,
|
||||
transition: isDragging ? 'none' : 'transform 0.2s',
|
||||
}}
|
||||
draggable={false}
|
||||
useWatermark={useEnhancedProtection}
|
||||
watermarkText={useEnhancedProtection ? `${currentPhoto.filename} - Protected` : undefined}
|
||||
isGallery={true}
|
||||
slug={slug}
|
||||
photoId={currentPhoto.id}
|
||||
requiresToken={currentPhoto.requires_token}
|
||||
secureUrlTemplate={currentPhoto.secure_url_template}
|
||||
protectFromDownload={!allowDownloads || useEnhancedProtection}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={protectionLevel === 'maximum'}
|
||||
fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
|
||||
blockKeyboardShortcuts={useEnhancedProtection}
|
||||
detectPrintScreen={useEnhancedProtection}
|
||||
detectDevTools={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
|
||||
onProtectionViolation={(violationType) => {
|
||||
console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`);
|
||||
{currentPhoto.media_type === 'video' ? (
|
||||
<VideoPlayer
|
||||
src={currentPhoto.url}
|
||||
poster={currentPhoto.thumbnail_url}
|
||||
className="max-w-full max-h-full"
|
||||
controls={true}
|
||||
autoPlay={false}
|
||||
/>
|
||||
) : (
|
||||
<AuthenticatedImage
|
||||
src={currentPhoto.url}
|
||||
alt={currentPhoto.filename}
|
||||
fallbackSrc={currentPhoto.thumbnail_url || undefined}
|
||||
className="max-w-full max-h-full object-contain select-none"
|
||||
style={{
|
||||
transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`,
|
||||
transition: isDragging ? 'none' : 'transform 0.2s',
|
||||
}}
|
||||
draggable={false}
|
||||
useWatermark={useEnhancedProtection}
|
||||
watermarkText={useEnhancedProtection ? `${currentPhoto.filename} - Protected` : undefined}
|
||||
isGallery={true}
|
||||
slug={slug}
|
||||
photoId={currentPhoto.id}
|
||||
requiresToken={currentPhoto.requires_token}
|
||||
secureUrlTemplate={currentPhoto.secure_url_template}
|
||||
protectFromDownload={!allowDownloads || useEnhancedProtection}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={protectionLevel === 'maximum'}
|
||||
fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
|
||||
blockKeyboardShortcuts={useEnhancedProtection}
|
||||
detectPrintScreen={useEnhancedProtection}
|
||||
detectDevTools={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
|
||||
onProtectionViolation={(violationType) => {
|
||||
console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`);
|
||||
|
||||
// Track analytics
|
||||
if (typeof window !== 'undefined' && (window as any).umami) {
|
||||
@@ -503,12 +513,13 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
}
|
||||
|
||||
// For maximum protection, close lightbox on violation
|
||||
if (protectionLevel === 'maximum' &&
|
||||
if (protectionLevel === 'maximum' &&
|
||||
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Touch/swipe indicators for mobile */}
|
||||
|
||||
@@ -143,7 +143,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
accept="image/jpeg,image/png,image/webp,video/mp4,video/webm,video/quicktime,video/x-msvideo"
|
||||
onChange={handleFileSelect}
|
||||
disabled={uploading}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Play, Pause, Volume2, VolumeX, Maximize, Minimize } from 'lucide-react';
|
||||
|
||||
interface VideoPlayerProps {
|
||||
src: string;
|
||||
poster?: string;
|
||||
className?: string;
|
||||
autoPlay?: boolean;
|
||||
muted?: boolean;
|
||||
loop?: boolean;
|
||||
controls?: boolean;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
}
|
||||
|
||||
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
src,
|
||||
poster,
|
||||
className = '',
|
||||
autoPlay = false,
|
||||
muted = false,
|
||||
loop = false,
|
||||
controls = true,
|
||||
width = '100%',
|
||||
height = 'auto'
|
||||
}) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(muted);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const controlsTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
setCurrentTime(video.currentTime);
|
||||
setProgress((video.currentTime / video.duration) * 100 || 0);
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
setDuration(video.duration);
|
||||
};
|
||||
|
||||
const handlePlay = () => setIsPlaying(true);
|
||||
const handlePause = () => setIsPlaying(false);
|
||||
const handleEnded = () => setIsPlaying(false);
|
||||
|
||||
video.addEventListener('timeupdate', handleTimeUpdate);
|
||||
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
video.addEventListener('play', handlePlay);
|
||||
video.addEventListener('pause', handlePause);
|
||||
video.addEventListener('ended', handleEnded);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
video.removeEventListener('ended', handleEnded);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const togglePlayPause = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (isPlaying) {
|
||||
video.pause();
|
||||
} else {
|
||||
video.play();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.muted = !video.muted;
|
||||
setIsMuted(!isMuted);
|
||||
};
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
try {
|
||||
if (!isFullscreen) {
|
||||
if (video.requestFullscreen) {
|
||||
await video.requestFullscreen();
|
||||
}
|
||||
setIsFullscreen(true);
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling fullscreen:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const pos = (e.clientX - rect.left) / rect.width;
|
||||
video.currentTime = pos * video.duration;
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleMouseMove = () => {
|
||||
setShowControls(true);
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
controlsTimeoutRef.current = setTimeout(() => {
|
||||
if (isPlaying) {
|
||||
setShowControls(false);
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative bg-black rounded-lg overflow-hidden ${className}`}
|
||||
style={{ width, height: height === 'auto' ? undefined : height }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => isPlaying && setShowControls(false)}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
poster={poster}
|
||||
autoPlay={autoPlay}
|
||||
muted={muted}
|
||||
loop={loop}
|
||||
className="w-full h-full object-contain"
|
||||
playsInline
|
||||
onClick={togglePlayPause}
|
||||
/>
|
||||
|
||||
{controls && (
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300 ${
|
||||
showControls ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
{/* Progress bar */}
|
||||
<div
|
||||
className="w-full h-1 bg-gray-600 rounded-full cursor-pointer mb-3"
|
||||
onClick={handleProgressClick}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-white rounded-full transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between text-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={togglePlayPause}
|
||||
className="hover:bg-white/20 p-2 rounded-full transition-colors"
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <Pause size={20} /> : <Play size={20} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="hover:bg-white/20 p-2 rounded-full transition-colors"
|
||||
aria-label={isMuted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
||||
</button>
|
||||
|
||||
<span className="text-sm">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="hover:bg-white/20 p-2 rounded-full transition-colors"
|
||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? <Minimize size={20} /> : <Maximize size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Play button overlay when paused */}
|
||||
{!isPlaying && showControls && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<button
|
||||
onClick={togglePlayPause}
|
||||
className="bg-black/50 hover:bg-black/70 text-white rounded-full p-6 transition-colors"
|
||||
aria-label="Play"
|
||||
>
|
||||
<Play size={48} fill="white" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPlayer;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Download, Maximize2, Check, MessageSquare, Star, Heart } from 'lucide-react';
|
||||
import { Download, Maximize2, Check, MessageSquare, Star, Heart, Video } from 'lucide-react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
@@ -155,6 +155,10 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
? 'opacity-100 md:opacity-100'
|
||||
: 'opacity-0 md:opacity-0';
|
||||
|
||||
const isVideo = (photo.media_type === 'video') ||
|
||||
(photo.mime_type && photo.mime_type.startsWith('video/')) ||
|
||||
photo.type === 'video';
|
||||
|
||||
const handlePhotoClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isTouchDevice && !overlayVisible && !isSelectionMode) {
|
||||
e.preventDefault();
|
||||
@@ -318,6 +322,15 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
</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' && (
|
||||
<div className="absolute bottom-2 right-2">
|
||||
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
"uploaded": "Hochgeladen",
|
||||
"photo": "Foto",
|
||||
"photos": "Fotos",
|
||||
"video": "Video",
|
||||
"videos": "Videos",
|
||||
"media": "Medien",
|
||||
"restore": "Wiederherstellen",
|
||||
"actions": "Aktionen",
|
||||
"refresh": "Aktualisieren",
|
||||
@@ -49,12 +52,15 @@
|
||||
"eventSpecific": "(Veranstaltungsspezifisch)",
|
||||
"clickToUpload": "Klicken zum Hochladen oder per Drag & Drop",
|
||||
"fileRequirements": "JPEG, PNG oder WebP (max. 50MB pro Datei, {{limit}} Dateien pro Upload)",
|
||||
"fileRequirementsMedia": "JPEG-, PNG- oder WebP-Bilder sowie MP4/MOV/WEBM-Videos (max. 50MB pro Datei, {{limit}} Dateien pro Upload)",
|
||||
"unsupportedFiles": "Einige Dateien wurden übersprungen, da das Format nicht unterstützt wird (JPEG/PNG/WebP/MP4/MOV/WEBM verwenden).",
|
||||
"selectedFiles": "Ausgewählte Dateien",
|
||||
"uploading": "Wird hochgeladen...",
|
||||
"uploadComplete": "Upload abgeschlossen!",
|
||||
"uploadFailed": "Upload fehlgeschlagen",
|
||||
"someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden",
|
||||
"uploadPhotos": "Fotos hochladen",
|
||||
"uploadMedia": "Fotos & Videos hochladen",
|
||||
"importExternal": "Aus externem Ordner importieren",
|
||||
"externalImportInfo": "Alle Bilder aus dem ausgewählten Ordner werden importiert.",
|
||||
"selectExternalFolder": "Externen Ordner unter /external-media auswählen",
|
||||
@@ -64,7 +70,9 @@
|
||||
"tooManyFiles": "Maximal {{limit}} Dateien können gleichzeitig hochgeladen werden",
|
||||
"limitInfo": "{{selected}} von {{limit}} Dateien ausgewählt ({{remaining}} verbleibend)",
|
||||
"limitReached": "Upload-Limit erreicht ({{limit}} Dateien pro Vorgang)",
|
||||
"uploadingChunks": "Lade {{count}} Dateien in {{total}} Teilen hoch..."
|
||||
"uploadingChunks": "Lade {{count}} Dateien in {{total}} Teilen hoch...",
|
||||
"mediaCategory": "Medienkategorie",
|
||||
"uploadAction": "{{count}} Dateien hochladen"
|
||||
},
|
||||
"navigation": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -513,6 +521,13 @@
|
||||
"selectAll": "Alle auswählen",
|
||||
"deselectAll": "Auswahl aufheben",
|
||||
"downloadSelected": "{{count}} ausgewählte herunterladen",
|
||||
"deleteSelected": "Ausgewählte löschen",
|
||||
"photosCount": "{{count}} Foto",
|
||||
"photosCount_plural": "{{count}} Fotos",
|
||||
"searchByFilename": "Nach Dateinamen suchen...",
|
||||
"uncategorized": "Ohne Kategorie",
|
||||
"sortAscending": "Aufsteigend sortieren",
|
||||
"sortDescending": "Absteigend sortieren",
|
||||
"remaining": "verbleibend",
|
||||
"selectPhotosHint": "Tipp: Verwenden Sie Strg+Klick (Cmd+Klick auf Mac), um schnell mehrere Fotos auszuwählen",
|
||||
"filters": "Filter",
|
||||
@@ -521,7 +536,12 @@
|
||||
"toggleMenu": "Menü umschalten",
|
||||
"allCategories": "Alle Kategorien",
|
||||
"categories": "Kategorien",
|
||||
"mediaType": "Medien",
|
||||
"allMedia": "Alle Medien",
|
||||
"photosOnly": "Fotos",
|
||||
"videosOnly": "Videos",
|
||||
"download": "Herunterladen",
|
||||
"noMedia": "Noch keine Medien hochgeladen",
|
||||
"searchPlaceholder": "Fotos suchen...",
|
||||
"sortBy": "Sortieren nach",
|
||||
"sortByDate": "Nach Datum sortieren",
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
"uploaded": "Uploaded",
|
||||
"photo": "photo",
|
||||
"photos": "photos",
|
||||
"video": "video",
|
||||
"videos": "videos",
|
||||
"media": "media",
|
||||
"restore": "Restore",
|
||||
"actions": "Actions",
|
||||
"refresh": "Refresh",
|
||||
@@ -49,12 +52,15 @@
|
||||
"eventSpecific": "(Event specific)",
|
||||
"clickToUpload": "Click to upload or drag and drop",
|
||||
"fileRequirements": "JPEG, PNG or WebP (max 50MB per file, {{limit}} files per upload)",
|
||||
"fileRequirementsMedia": "JPEG, PNG or WebP images, plus MP4/MOV/WEBM videos (max 50MB per file, {{limit}} files per upload)",
|
||||
"unsupportedFiles": "Some files were skipped because the format is not supported (use JPEG/PNG/WebP/MP4/MOV/WEBM).",
|
||||
"selectedFiles": "Selected files",
|
||||
"uploading": "Uploading...",
|
||||
"uploadComplete": "Upload complete!",
|
||||
"uploadFailed": "Upload failed",
|
||||
"someFilesFailed": "Some files failed to upload",
|
||||
"uploadPhotos": "Upload Photos",
|
||||
"uploadMedia": "Upload Photos & Videos",
|
||||
"importExternal": "Import from External Folder",
|
||||
"externalImportInfo": "All pictures from the selected folder will be imported.",
|
||||
"selectExternalFolder": "Select external folder under /external-media",
|
||||
@@ -64,7 +70,9 @@
|
||||
"tooManyFiles": "Maximum {{limit}} files can be uploaded at once",
|
||||
"limitInfo": "{{selected}} of {{limit}} files selected ({{remaining}} remaining)",
|
||||
"limitReached": "Upload limit reached ({{limit}} files per batch)",
|
||||
"uploadingChunks": "Uploading {{count}} files in {{total}} batches..."
|
||||
"uploadingChunks": "Uploading {{count}} files in {{total}} batches...",
|
||||
"mediaCategory": "Media category",
|
||||
"uploadAction": "Upload {{count}} files"
|
||||
},
|
||||
"navigation": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -178,6 +186,13 @@
|
||||
"selectAll": "Select All",
|
||||
"deselectAll": "Deselect All",
|
||||
"downloadSelected": "Download {{count}} Selected",
|
||||
"deleteSelected": "Delete Selected",
|
||||
"photosCount": "{{count}} photo",
|
||||
"photosCount_plural": "{{count}} photos",
|
||||
"searchByFilename": "Search by filename...",
|
||||
"uncategorized": "Uncategorized",
|
||||
"sortAscending": "Sort ascending",
|
||||
"sortDescending": "Sort descending",
|
||||
"remaining": "remaining",
|
||||
"selectPhotosHint": "Tip: Use Ctrl+Click (Cmd+Click on Mac) to quickly select multiple photos",
|
||||
"filters": "Filters",
|
||||
@@ -186,7 +201,12 @@
|
||||
"toggleMenu": "Toggle menu",
|
||||
"allCategories": "All Categories",
|
||||
"categories": "Categories",
|
||||
"mediaType": "Media",
|
||||
"allMedia": "All media",
|
||||
"photosOnly": "Photos",
|
||||
"videosOnly": "Videos",
|
||||
"download": "Download",
|
||||
"noMedia": "No media uploaded yet",
|
||||
"searchPlaceholder": "Search photos...",
|
||||
"sortBy": "Sort By",
|
||||
"sortByDate": "Sort by Date",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -208,6 +208,26 @@ export const EventDetailsPage: React.FC = () => {
|
||||
enabled: !!id && (activeTab === 'photos' || isEditing),
|
||||
});
|
||||
|
||||
const mediaTypes = useMemo(() => {
|
||||
const types = new Set<'photo' | 'video'>();
|
||||
photos.forEach((p: any) => {
|
||||
const mediaType = (p.media_type as 'photo' | 'video' | undefined)
|
||||
|| ((p.mime_type && String(p.mime_type).startsWith('video/')) || p.type === 'video' ? 'video' : 'photo');
|
||||
if (mediaType === 'video' || mediaType === 'photo') {
|
||||
types.add(mediaType);
|
||||
}
|
||||
});
|
||||
return types;
|
||||
}, [photos]);
|
||||
|
||||
const showMediaFilter = mediaTypes.has('photo') && mediaTypes.has('video');
|
||||
|
||||
useEffect(() => {
|
||||
if (!showMediaFilter && photoFilters.media_type) {
|
||||
setPhotoFilters(prev => ({ ...prev, media_type: undefined }));
|
||||
}
|
||||
}, [showMediaFilter, photoFilters.media_type]);
|
||||
|
||||
// Fetch categories for the event
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ['admin-event-categories', id],
|
||||
@@ -1237,6 +1257,12 @@ export const EventDetailsPage: React.FC = () => {
|
||||
onCategoryChange={(categoryId) => setPhotoFilters(prev => ({ ...prev, category_id: categoryId }))}
|
||||
onSearchChange={(search) => setPhotoFilters(prev => ({ ...prev, search }))}
|
||||
onSortChange={(sort, order) => setPhotoFilters(prev => ({ ...prev, sort, order }))}
|
||||
mediaType={photoFilters.media_type || 'all'}
|
||||
onMediaTypeChange={(mediaType) => setPhotoFilters(prev => ({
|
||||
...prev,
|
||||
media_type: mediaType === 'all' ? undefined : mediaType
|
||||
}))}
|
||||
showMediaFilter={showMediaFilter}
|
||||
/>
|
||||
|
||||
{/* Actions Bar */}
|
||||
|
||||
@@ -7,11 +7,13 @@ export interface AdminPhoto {
|
||||
url: string;
|
||||
thumbnail_url: string | null;
|
||||
type: string;
|
||||
category_id: number | null;
|
||||
category_id: number | string | null;
|
||||
category_name: string | null;
|
||||
category_slug: string | null;
|
||||
size: number;
|
||||
uploaded_at: string;
|
||||
media_type?: 'photo' | 'video';
|
||||
mime_type?: string | null;
|
||||
view_count?: number;
|
||||
download_count?: number;
|
||||
// Feedback fields
|
||||
@@ -23,8 +25,9 @@ export interface AdminPhoto {
|
||||
}
|
||||
|
||||
export interface PhotoFilters {
|
||||
category_id?: number | null;
|
||||
category_id?: number | string | null;
|
||||
type?: string;
|
||||
media_type?: 'photo' | 'video';
|
||||
search?: string;
|
||||
sort?: 'date' | 'name' | 'size' | 'rating';
|
||||
order?: 'asc' | 'desc';
|
||||
@@ -39,6 +42,7 @@ class PhotosService {
|
||||
params.append('category_id', filters.category_id?.toString() || '');
|
||||
}
|
||||
if (filters.type) params.append('type', filters.type);
|
||||
if (filters.media_type) params.append('media_type', filters.media_type);
|
||||
if (filters.search) params.append('search', filters.search);
|
||||
if (filters.sort) params.append('sort', filters.sort);
|
||||
if (filters.order) params.append('order', filters.order);
|
||||
@@ -95,6 +99,106 @@ class PhotosService {
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Chunked upload methods for large files (videos up to 10GB)
|
||||
private CHUNK_SIZE = 10 * 1024 * 1024; // 10MB chunks
|
||||
|
||||
async initChunkedUpload(
|
||||
eventId: number,
|
||||
filename: string,
|
||||
fileSize: number,
|
||||
mimeType: string
|
||||
): Promise<{ uploadId: string; chunkSize: number; expectedChunks: number }> {
|
||||
const totalChunks = Math.ceil(fileSize / this.CHUNK_SIZE);
|
||||
const response = await api.post(`/admin/photos/${eventId}/chunked-upload/init`, {
|
||||
filename,
|
||||
fileSize,
|
||||
mimeType,
|
||||
totalChunks
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async uploadChunk(
|
||||
eventId: number,
|
||||
uploadId: string,
|
||||
chunkIndex: number,
|
||||
chunkData: Blob
|
||||
): Promise<{ progress: number; complete: boolean }> {
|
||||
const response = await api.post(
|
||||
`/admin/photos/${eventId}/chunked-upload/${uploadId}/chunk/${chunkIndex}`,
|
||||
chunkData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream'
|
||||
}
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async completeChunkedUpload(
|
||||
eventId: number,
|
||||
uploadId: string,
|
||||
categoryId?: number | null
|
||||
): Promise<{ success: boolean; uploaded: number; photos: AdminPhoto[] }> {
|
||||
const response = await api.post(
|
||||
`/admin/photos/${eventId}/chunked-upload/${uploadId}/complete`,
|
||||
{ category_id: categoryId }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async abortChunkedUpload(eventId: number, uploadId: string): Promise<void> {
|
||||
await api.delete(`/admin/photos/${eventId}/chunked-upload/${uploadId}`);
|
||||
}
|
||||
|
||||
async uploadLargeFile(
|
||||
eventId: number,
|
||||
file: File,
|
||||
categoryId?: number | null,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<AdminPhoto[]> {
|
||||
// Initialize upload
|
||||
const { uploadId, expectedChunks } = await this.initChunkedUpload(
|
||||
eventId,
|
||||
file.name,
|
||||
file.size,
|
||||
file.type
|
||||
);
|
||||
|
||||
try {
|
||||
// Upload chunks
|
||||
for (let i = 0; i < expectedChunks; i++) {
|
||||
const start = i * this.CHUNK_SIZE;
|
||||
const end = Math.min(start + this.CHUNK_SIZE, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
|
||||
const result = await this.uploadChunk(eventId, uploadId, i, chunk);
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(result.progress);
|
||||
}
|
||||
}
|
||||
|
||||
// Complete upload
|
||||
const result = await this.completeChunkedUpload(eventId, uploadId, categoryId);
|
||||
return result.photos;
|
||||
} catch (error) {
|
||||
// Abort on error
|
||||
try {
|
||||
await this.abortChunkedUpload(eventId, uploadId);
|
||||
} catch (abortError) {
|
||||
console.error('Failed to abort upload:', abortError);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if file should use chunked upload (> 100MB)
|
||||
shouldUseChunkedUpload(fileSize: number): boolean {
|
||||
return fileSize > 100 * 1024 * 1024; // 100MB threshold
|
||||
}
|
||||
}
|
||||
|
||||
export const photosService = new PhotosService();
|
||||
|
||||
@@ -55,12 +55,22 @@ export interface Photo {
|
||||
secure_url_template?: string;
|
||||
download_url_template?: string;
|
||||
requires_token?: boolean;
|
||||
type: 'collage' | 'individual';
|
||||
category_id?: number;
|
||||
type: 'collage' | 'individual' | 'video';
|
||||
media_type?: 'photo' | 'video';
|
||||
mime_type?: string;
|
||||
category_id?: number | string | null;
|
||||
category_name?: string;
|
||||
category_slug?: string;
|
||||
size: number;
|
||||
uploaded_at: string;
|
||||
// Media type fields
|
||||
media_type?: 'image' | 'video';
|
||||
mime_type?: string;
|
||||
duration?: number; // Duration in seconds for videos
|
||||
video_codec?: string;
|
||||
audio_codec?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
// Feedback fields
|
||||
has_feedback?: boolean;
|
||||
average_rating?: number;
|
||||
@@ -71,7 +81,7 @@ export interface Photo {
|
||||
}
|
||||
|
||||
export interface PhotoCategory {
|
||||
id: number;
|
||||
id: number | string;
|
||||
name: string;
|
||||
slug: string;
|
||||
is_global: boolean;
|
||||
|
||||
@@ -823,8 +823,8 @@ EOF
|
||||
|
||||
create_systemd_services() {
|
||||
log_step "Creating systemd services..."
|
||||
|
||||
# Backend service
|
||||
|
||||
# Backend service (includes workers - fileWatcher, expirationChecker are started by server.js)
|
||||
cat > /etc/systemd/system/picpeak-backend.service <<EOF
|
||||
[Unit]
|
||||
Description=PicPeak Backend Service
|
||||
@@ -844,27 +844,10 @@ StandardError=append:$NATIVE_APP_DIR/logs/backend-error.log
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# Workers service
|
||||
cat > /etc/systemd/system/picpeak-workers.service <<EOF
|
||||
[Unit]
|
||||
Description=PicPeak Background Workers
|
||||
After=network.target picpeak-backend.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$NATIVE_APP_USER
|
||||
WorkingDirectory=$NATIVE_APP_DIR/app/backend
|
||||
Environment="NODE_ENV=production"
|
||||
ExecStart=/usr/bin/node src/services/workerManager.js
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=append:$NATIVE_APP_DIR/logs/workers.log
|
||||
StandardError=append:$NATIVE_APP_DIR/logs/workers-error.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
# Note: Workers (fileWatcher, expirationChecker, emailProcessor) are now started
|
||||
# automatically by server.js, so a separate workers service is no longer needed.
|
||||
# Legacy picpeak-workers.service will be cleaned up during installation.
|
||||
}
|
||||
|
||||
setup_caddy() {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
404: Not Found
|
||||
Binary file not shown.
Reference in New Issue
Block a user