Compare commits

...

14 Commits

Author SHA1 Message Date
paul 7c7498385f Regenerate frontend package-lock to match package.json
Build and Push Docker Images / build-backend (push) Failing after 2m44s
Build and Push Docker Images / build-frontend (push) Failing after 11s
Build and Push Docker Images / summary (push) Successful in 3s
2025-11-28 18:44:38 +01:00
paul 1ae63890ff Fetch patched libpng from edge for frontend runtime
Build and Push Docker Images / build-backend (push) Failing after 15m39s
Build and Push Docker Images / build-frontend (push) Failing after 4m3s
Build and Push Docker Images / summary (push) Successful in 3s
2025-11-28 17:54:56 +01:00
Claude 5f1affafd8 Update frontend package-lock.json for npm compatibility
Regenerate lock file to include missing esbuild platform dependencies
required by newer npm versions.
2025-11-28 17:54:56 +01:00
Claude 8315c11d34 Update backend package-lock.json for npm compatibility
Regenerate lock file to include missing transitive dependencies
(encoding, iconv-lite) required by newer npm versions.
2025-11-28 17:54:36 +01:00
Claude 0043f2aaf4 Fix npm ci command for newer npm versions
Replace deprecated --only=production with --omit=dev flag
which is required for npm 10+ after the npm upgrade.
2025-11-28 17:54:36 +01:00
Claude d494eda301 Fix glob CVE-2025-64756 security vulnerability in Docker images
Upgrade npm to latest version in both backend and frontend Dockerfiles
to fix the command injection vulnerability in glob's CLI (CVE-2025-64756).
The vulnerability exists in npm's bundled glob package (< 10.5.0 or < 11.1.0).
2025-11-28 17:54:36 +01:00
Claude a59a4232ff Fix worker service and Docker storage permission issues (Issues #66, #67)
Issue #66: Remove redundant picpeak-workers.service creation from setup script.
Workers (fileWatcher, expirationChecker, emailProcessor) are now started
automatically by server.js, so a separate systemd service is not needed.
The legacy service cleanup code is retained for migration purposes.

Issue #67: Ensure storage directories exist at container startup in
wait-for-db.sh. When host directories are bind-mounted in Docker, the
container's built-in directories are overridden. This fix creates the
required directory structure (events/active, events/archived, thumbnails)
before the application starts, preventing EACCES permission errors.
2025-11-28 17:54:36 +01:00
Claude 77326a91ca Apply critical bug fixes from main to prevent merge regressions
This commit applies essential bug fixes from main branch to ensure no
regressions occur when merging the video-support branch:

1. Increase body parser limits from 100mb to 10gb for large video uploads
   - Updated express.json and express.urlencoded limits in server.js

2. Rename video migration from 047 to 048 to avoid conflict
   - Main branch already has 047_add_tls_reject_unauthorized.js
   - Prevents migration system from skipping one of the migrations

3. Fix category update logic with proper validation
   - Add updated_at timestamp to all category updates
   - Add explicit null handling for category_id
   - Add parseInt with radix parameter for numeric IDs
   - Add isNaN validation to prevent invalid values
   - Fix event_id constraint in single photo update query
   - Add parseInt to photoCount comparison for type safety

These fixes ensure all bug fixes from main branch (especially from
commit d91ab43) are preserved when the PR is merged.
2025-11-28 17:54:36 +01:00
Claude 0d95eab86a Add chunked upload support for large video files up to 10GB
- Increased max file size from 500MB to 10GB
- Created chunkedUploadService.js for managing chunked uploads
- Added chunked upload API endpoints (init, chunk, complete, status, abort)
- Added frontend chunked upload methods to photos.service.ts
- Files >100MB automatically use chunked uploads
- 10MB chunk size for reliable transfers
- Auto-cleanup of expired uploads after 24 hours
- Updated README with 10GB limit and nginx configuration example
2025-11-28 17:53:56 +01:00
Claude f3482a9a78 Update README with video support requirements and status
- Added Video Support Requirements section with resource recommendations
- Noted FFmpeg is bundled via npm (no system installation required)
- Listed supported formats and max file size
- Updated roadmap to mark Video Support as implemented
2025-11-28 17:53:56 +01:00
Claude 68a9dc5749 Add comprehensive video support to galleries
This commit implements full video upload, storage, streaming, and playback functionality
for the PicPeak photo sharing platform, allowing users to upload and view videos alongside
photos in galleries.

Backend Changes:
- Added video processing dependencies (fluent-ffmpeg, @ffmpeg-installer/ffmpeg)
- Created videoProcessor.js service for video metadata extraction and thumbnail generation
- Updated photoProcessor.js to handle both images and videos
- Modified adminPhotos.js to accept video files with 500MB size limit
- Enhanced gallery.js with HTTP range request support for video streaming
- Expanded fileSecurityUtils.js with video MIME types and magic number validation
- Added database migration for video support columns (media_type, duration, codecs, dimensions)

Frontend Changes:
- Updated TypeScript types to include video metadata fields
- Created VideoPlayer.tsx component with custom controls
- Modified PhotoUpload.tsx to accept video files (.mp4, .webm, .mov, .avi)
- Updated UserPhotoUpload.tsx for guest video uploads
- Enhanced PhotoGrid.tsx with video badges and duration display
- Modified PhotoLightbox.tsx to conditionally render VideoPlayer for videos

Database Schema:
- Added media_type column ('image' | 'video')
- Added mime_type, duration, video_codec, audio_codec columns
- Added width and height columns for media dimensions
- Migrated existing photos to media_type 'image'

Features:
- Video thumbnail generation from video frames
- Streaming support with range requests for efficient playback
- Video duration display on thumbnails
- Play button indicators on video items
- Full-featured video player with playback controls
- Support for MP4, WebM, MOV, and AVI formats
2025-11-28 17:53:56 +01:00
paul 8c87f1537b Resolve merge conflicts for video uploads and processing 2025-11-28 17:52:42 +01:00
paul 97e54355fb Update frontend runtime image to patched libpng
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
2025-11-28 17:47:24 +01:00
paul 9a75f1c929 Add video support, media filters, and translations
Build and Push Docker Images / build-backend (push) Failing after 14m2s
Build and Push Docker Images / build-frontend (push) Failing after 44m43s
Build and Push Docker Images / summary (push) Successful in 3s
2025-11-28 13:29:44 +01:00
42 changed files with 5426 additions and 2655 deletions
+26 -1
View File
@@ -135,6 +135,31 @@ Perfect for:
- **Docker**: v20.10.0+ - **Docker**: v20.10.0+
- **Docker Compose**: v2.0.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 ## 🤝 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. 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 | | **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 | | **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 | | **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 | | **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 | | **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
View File
@@ -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.description="PicPeak Backend Service"
LABEL org.opencontainers.image.licenses="MIT" LABEL org.opencontainers.image.licenses="MIT"
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
RUN npm install -g npm@latest
WORKDIR /app WORKDIR /app
# Copy package files # Copy package files
COPY package*.json ./ COPY package*.json ./
# Install dependencies # Install dependencies (--omit=dev replaces deprecated --only=production)
RUN npm ci --only=production RUN npm ci --omit=dev
# Copy application files # Copy application files
COPY . . COPY . .
@@ -30,6 +33,9 @@ WORKDIR /app
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs) # Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
RUN apk upgrade --no-cache 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 # Install dumb-init for proper signal handling and postgresql-client for database checks
RUN apk add --no-cache dumb-init postgresql-client 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');
};
+1813 -1213
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -15,6 +15,7 @@
"@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0",
"@aws-sdk/s3-request-presigner": "^3.850.0", "@aws-sdk/s3-request-presigner": "^3.850.0",
"@ffmpeg-installer/ffmpeg": "^1.1.0",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"archiver": "^5.3.1", "archiver": "^5.3.1",
"axios": "^1.12.2", "axios": "^1.12.2",
@@ -26,6 +27,7 @@
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^6.7.0", "express-rate-limit": "^6.7.0",
"express-validator": "^7.0.1", "express-validator": "^7.0.1",
"fluent-ffmpeg": "^2.1.3",
"form-data": "^4.0.4", "form-data": "^4.0.4",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"helmet": "^7.0.0", "helmet": "^7.0.0",
+3 -3
View File
@@ -324,9 +324,9 @@ async function initializeRateLimiters() {
// Note: Rate limiters will be initialized after database connection // Note: Rate limiters will be initialized after database connection
// Body parsing middleware with increased limits for large batch uploads <<<<<<< HEAD
app.use(express.json({ limit: '500mb' })); app.use(express.json({ limit: '10gb' }));
app.use(express.urlencoded({ extended: true, limit: '500mb' })); app.use(express.urlencoded({ extended: true, limit: '10gb' }));
// Request logging for API routes (with timestamps) // Request logging for API routes (with timestamps)
const apiRequestLogger = (req, res, next) => { const apiRequestLogger = (req, res, next) => {
+172 -72
View File
@@ -9,29 +9,13 @@ const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { escapeLikePattern } = require('../utils/sqlSecurity'); const { escapeLikePattern } = require('../utils/sqlSecurity');
const { validateUploadedFiles } = require('../middleware/uploadValidation'); const { validateUploadedFiles } = require('../middleware/uploadValidation');
const { getMaxFilesPerUpload } = require('../services/uploadSettings'); const { getMaxFilesPerUpload } = require('../services/uploadSettings');
const { processUploadedPhotos } = require('../services/photoProcessor');
const chunkedUpload = require('../services/chunkedUploadService');
const router = express.Router(); const router = express.Router();
// Get storage path from environment or default // Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
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 // Configure multer for file uploads
// IMPORTANT: Using synchronous functions to prevent file corruption // IMPORTANT: Using synchronous functions to prevent file corruption
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@@ -66,7 +50,7 @@ const { validateFileType } = require('../utils/fileSecurityUtils');
const upload = multer({ const upload = multer({
storage: storage, storage: storage,
limits: { 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 files: 2000, // Hard safety ceiling; actual limit enforced dynamically
// Set a reasonable field size limit to prevent memory issues // Set a reasonable field size limit to prevent memory issues
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields 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 headerPairs: 2000 // Maximum number of header key-value pairs
}, },
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
// Accept images only with proper validation // Accept images and videos with proper validation
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp']; const allowedMimeTypes = [
'image/jpeg', 'image/png', 'image/webp',
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'
];
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) { if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
return cb(null, true); return cb(null, true);
} else { } else {
cb(new Error('Only JPEG, PNG and WebP images are allowed')); cb(new Error('Only JPEG, PNG, WebP images and MP4, WebM, MOV, AVI videos are allowed'));
} }
}, },
// Add abort on limit to stop processing when limits are exceeded // Add abort on limit to stop processing when limits are exceeded
@@ -92,8 +79,11 @@ const { createFileUploadValidator } = require('../utils/fileSecurityUtils');
// Create content validator middleware // Create content validator middleware
const validateUploadContent = createFileUploadValidator({ const validateUploadContent = createFileUploadValidator({
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'], allowedTypes: [
maxFileSize: 50 * 1024 * 1024, '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 validateContent: true
}); });
@@ -133,7 +123,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
console.error('Multer error:', err); console.error('Multer error:', err);
if (err instanceof multer.MulterError) { if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') { 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') { 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.` }); 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 // 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) // Determine photo type from category_id parameter (for backwards compatibility)
let photoType = 'individual'; // default let photoType = 'individual'; // default
let categoryName = 'individual'; let categoryName = 'individual';
if (numericCategoryId === 1 || category_id === 'collage') { if (parsedCategoryId === 1 || category_id === 'collage') {
photoType = 'collage'; photoType = 'collage';
categoryName = 'collages'; categoryName = 'collages';
} else if (numericCategoryId === 2 || category_id === 'individual') { } else if (parsedCategoryId === 2 || category_id === 'individual') {
photoType = 'individual'; photoType = 'individual';
categoryName = 'individual'; categoryName = 'individual';
} }
@@ -266,9 +256,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
path: relativePath, path: relativePath,
thumbnail_path: null, // Will generate after successful commit thumbnail_path: null, // Will generate after successful commit
type: photoType, type: photoType,
size_bytes: tempStats.size, // Use actual file size from stat size_bytes: tempStats.size // Use actual file size from stat
category_id: numericCategoryId,
source_origin: 'managed'
}; };
batchPhotos.push(photoData); batchPhotos.push(photoData);
@@ -504,9 +492,7 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
} }
// Prepare update data // Prepare update data
const updateData = { const updateData = {};
updated_at: new Date()
};
// Handle type-based categories ('individual' or 'collage') // Handle type-based categories ('individual' or 'collage')
// These are string values that map to the photo.type field // 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 // Update photo
const normalizedCategoryId = parseCategoryId(category_id);
await db('photos') await db('photos')
.where({ id: photoId, event_id: eventId }) .where({ id: photoId, event_id: eventId })
.update(updateData); .update(updateData);
// Fetch and return updated photo for confirmation res.json({ message: 'Photo updated successfully' });
const updatedPhoto = await db('photos')
.where({ id: photoId })
.first();
res.json({
message: 'Photo updated successfully',
photo: updatedPhoto
});
} catch (error) { } catch (error) {
console.error('Error updating photo:', error); console.error('Error updating photo:', error);
res.status(500).json({ error: 'Failed to update photo' }); res.status(500).json({ error: 'Failed to update photo' });
@@ -621,22 +597,22 @@ router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
try { try {
const { eventId } = req.params; const { eventId } = req.params;
const { photoIds, updates } = req.body; const { photoIds, updates } = req.body;
if (!Array.isArray(photoIds) || photoIds.length === 0) { if (!Array.isArray(photoIds) || photoIds.length === 0) {
return res.status(400).json({ error: 'Invalid photo IDs' }); return res.status(400).json({ error: 'Invalid photo IDs' });
} }
// Verify all photos belong to the event // Verify all photos belong to the event
const photoCount = await db('photos') const photoCount = await db('photos')
.whereIn('id', photoIds) .whereIn('id', photoIds)
.where('event_id', eventId) .where('event_id', eventId)
.count('id as count') .count('id as count')
.first(); .first();
if (parseInt(photoCount.count) !== photoIds.length) { if (parseInt(photoCount.count) !== photoIds.length) {
return res.status(400).json({ error: 'Some photos do not belong to this event' }); return res.status(400).json({ error: 'Some photos do not belong to this event' });
} }
// Prepare update data // Prepare update data
const updateData = { const updateData = {
updated_at: new Date() 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; const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
let query = db('photos') let query = db('photos')
.leftJoin('photo_categories as pc', 'pc.id', 'photos.category_id')
.where({ 'photos.event_id': eventId }) .where({ 'photos.event_id': eventId })
.select( .select('photos.*');
'photos.*',
'pc.name as category_display_name',
'pc.slug as category_display_slug'
);
// Filter by type (individual/collage) - category_id maps to type // Filter by type (individual/collage) - category_id maps to type
if (category_id !== undefined) { if (category_id !== undefined) {
if (category_id === '') { if (category_id === '' || category_id === '0') {
// No filter when empty string is provided // For backwards compatibility, empty category means no filter
} else if (category_id === '0') { // Don't filter anything
query = query.whereNull('photos.category_id');
} else if (/^\d+$/.test(category_id)) {
query = query.where('photos.category_id', parseInt(category_id, 10));
} else if (category_id === 'individual' || category_id === 'collage') { } else if (category_id === 'individual' || category_id === 'collage') {
query = query.where({ 'photos.type': category_id }); 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); const photos = await query.orderBy(orderByColumn, order);
if (photos.length === 0) {
return res.json({ photos: [] });
}
// Get comment counts separately // Get comment counts separately
const commentCounts = await db('photo_feedback') const commentCounts = await db('photo_feedback')
.whereIn('photo_id', photos.map(p => p.id)) .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 // Always expose a thumbnail URL; backend will generate on demand if missing
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`, thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
type: photo.type, type: photo.type,
category_id: photo.category_id !== null && photo.category_id !== undefined category_id: photo.type,
? Number(photo.category_id) category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
: null, category_slug: photo.type,
category_name: photo.category_display_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
category_slug: photo.category_display_slug || photo.type,
size: photo.size_bytes, size: photo.size_bytes,
uploaded_at: photo.uploaded_at, uploaded_at: photo.uploaded_at,
// Feedback data // 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; module.exports = router;
+58 -14
View File
@@ -610,38 +610,41 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
// View single photo (with watermark if enabled) // View single photo (with watermark if enabled)
router.get('/:slug/photo/:photoId', router.get('/:slug/photo/:photoId',
verifyGalleryAccess, verifyGalleryAccess,
async (req, res) => { async (req, res) => {
try { try {
const { photoId } = req.params; const { photoId } = req.params;
const photo = await db('photos') const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id }) .where({ id: photoId, event_id: req.event.id })
.first(); .first();
if (!photo) { if (!photo) {
return res.status(404).json({ error: 'Photo not found' }); 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 // Check protection level - basic and standard protection allow direct JWT access
const protectionLevel = req.event.protection_level || 'standard'; const protectionLevel = req.event.protection_level || 'standard';
if (protectionLevel === 'enhanced' || protectionLevel === 'maximum') { if (protectionLevel === 'enhanced' || protectionLevel === 'maximum') {
// For enhanced/maximum protection, redirect to secure endpoint // For enhanced/maximum protection, redirect to secure endpoint
return res.status(302).json({ return res.status(302).json({
error: 'Secure access required', error: 'Secure access required',
secureEndpoint: `/api/secure-images/${req.params.slug}/generate-token`, secureEndpoint: `/api/secure-images/${req.params.slug}/generate-token`,
photoId: photoId photoId: photoId
}); });
} }
// Resolve the absolute file path for this photo, supporting both managed and external reference modes // Resolve the absolute file path for this photo, supporting both managed and external reference modes
const { resolvePhotoFilePath } = require('../services/photoResolver'); const { resolvePhotoFilePath } = require('../services/photoResolver');
const filePath = resolvePhotoFilePath(req.event, photo); const filePath = resolvePhotoFilePath(req.event, photo);
// Log access - temporarily disabled for debugging // Log access - temporarily disabled for debugging
// await secureImageService.logImageAccess( // await secureImageService.logImageAccess(
// photoId, // photoId,
@@ -649,20 +652,61 @@ router.get('/:slug/photo/:photoId',
// req.clientInfo, // req.clientInfo,
// 'view_basic' // '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 // Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings(); const watermarkSettings = await watermarkService.getWatermarkSettings();
if (watermarkSettings && watermarkSettings.enabled) { if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark and send // Apply watermark and send
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
res.set({ res.set({
'Content-Type': photo.mime_type || 'image/jpeg', 'Content-Type': photo.mime_type || 'image/jpeg',
'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes 'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes
'X-Protection-Level': 'basic' 'X-Protection-Level': 'basic'
}); });
res.send(watermarkedBuffer); res.send(watermarkedBuffer);
} else { } else {
// Send original file with basic protection headers // 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
};
+18 -7
View File
@@ -3,8 +3,10 @@ const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { generateThumbnail } = require('./imageProcessor'); const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { isVideoMimeType } = require('../utils/fileSecurityUtils');
const mime = require('mime-types');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const WATCH_PATH = () => path.join(getStoragePath(), 'events/active'); const WATCH_PATH = () => path.join(getStoragePath(), 'events/active');
@@ -47,9 +49,11 @@ async function processNewPhoto(filePath) {
const eventSlug = pathParts[0]; const eventSlug = pathParts[0];
const photoType = pathParts[1] === 'collages' ? 'collage' : 'individual'; const photoType = pathParts[1] === 'collages' ? 'collage' : 'individual';
// Check if this is an image file // Check if this is an image or video file
const ext = path.extname(filePath).toLowerCase(); const ext = path.extname(filePath).toLowerCase();
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return; const detectedMime = mime.lookup(filePath) || '';
const isVideo = isVideoMimeType(detectedMime, filePath) || ['.mp4', '.mov', '.webm'].includes(ext);
if (!isVideo && !['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
// Skip temporary upload files // Skip temporary upload files
const filename = path.basename(filePath); const filename = path.basename(filePath);
@@ -65,11 +69,17 @@ async function processNewPhoto(filePath) {
// Get file stats // Get file stats
const stats = await fs.stat(filePath); const stats = await fs.stat(filePath);
// Generate thumbnail // Generate thumbnail or placeholder
const thumbnailPath = await generateThumbnail(filePath); let thumbnailPath = null;
if (isVideo) {
thumbnailPath = await generateVideoPlaceholder(filename);
} else {
thumbnailPath = await generateThumbnail(filePath);
}
// Calculate relative thumbnail path // Calculate relative thumbnail path
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
const mimeType = detectedMime || (isVideo ? 'video/mp4' : 'image/jpeg');
// Check if photo already exists // Check if photo already exists
const existingPhoto = await db('photos') const existingPhoto = await db('photos')
@@ -83,8 +93,9 @@ async function processNewPhoto(filePath) {
filename: path.basename(filePath), filename: path.basename(filePath),
path: relativePath, path: relativePath,
thumbnail_path: relativeThumbPath, thumbnail_path: relativeThumbPath,
type: photoType, type: isVideo ? 'video' : photoType,
size_bytes: stats.size size_bytes: stats.size,
mime_type: mimeType
}); });
logger.info(`Added new photo: ${relativePath}`); logger.info(`Added new photo: ${relativePath}`);
+51 -1
View File
@@ -237,4 +237,54 @@ async function ensureThumbnail(photo) {
return null; return null;
} }
module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail }; async function generateVideoPlaceholder(originalFilename, options = {}) {
const parsed = path.parse(originalFilename || '');
const baseName = parsed.name || 'video';
const thumbnailDir = getThumbnailPath();
const thumbnailFilename = `thumb_${baseName}.jpg`;
const thumbnailPath = path.join(thumbnailDir, thumbnailFilename);
const settings = await getThumbnailSettings();
const width = settings.width || DEFAULT_THUMBNAIL_WIDTH;
const height = settings.height || DEFAULT_THUMBNAIL_HEIGHT;
if (options.regenerate) {
try {
await fs.unlink(thumbnailPath);
} catch (_) {
// ignore if missing
}
}
try {
await fs.mkdir(thumbnailDir, { recursive: true });
const svg = `
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0f172a" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#1e293b" stop-opacity="0.9"/>
</linearGradient>
</defs>
<rect width="${width}" height="${height}" rx="18" fill="url(#grad)"/>
<circle cx="${width / 2}" cy="${height / 2}" r="${Math.min(width, height) / 6}" fill="rgba(255,255,255,0.85)"/>
<polygon points="${width / 2 - 10},${height / 2 - 14} ${width / 2 - 10},${height / 2 + 14} ${width / 2 + 16},${height / 2}" fill="#0f172a"/>
<text x="50%" y="${height - 18}" font-family="Arial, sans-serif" font-size="16" fill="rgba(255,255,255,0.9)" text-anchor="middle">
VIDEO
</text>
</svg>
`;
await sharp(Buffer.from(svg))
.resize(width, height, { fit: 'cover' })
.jpeg({ quality: settings.quality || DEFAULT_THUMBNAIL_QUALITY })
.toFile(thumbnailPath);
return path.relative(getStoragePath(), thumbnailPath);
} catch (error) {
logger.error('Failed to generate video placeholder thumbnail:', error.message);
return null;
}
}
module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail, generateVideoPlaceholder };
+49 -25
View File
@@ -3,6 +3,7 @@ const fs = require('fs').promises;
const { db } = require('../database/db'); const { db } = require('../database/db');
const { generateThumbnail } = require('./imageProcessor'); const { generateThumbnail } = require('./imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer'); const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { processUploadedVideo, isVideoMimeType } = require('./videoProcessor');
// Get storage path from environment or default // Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -150,43 +151,66 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
} }
} }
// Generate thumbnail // Determine if this is a video or image
const thumbnailPath = await generateThumbnail(newPath); 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 // Calculate relative paths
const storagePath = getStoragePath(); const storagePath = getStoragePath();
const relativePath = path.relative(path.join(storagePath, 'events/active'), newPath); const relativePath = path.relative(path.join(storagePath, 'events/active'), newPath);
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root 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; let insertResult;
const clientName = trx?.client?.config?.client; const clientName = trx?.client?.config?.client;
const supportsReturning = ['pg', 'postgres', 'postgresql'].includes(clientName); 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) { if (supportsReturning) {
insertResult = await trx('photos') insertResult = await trx('photos')
.insert({ .insert(photoData)
event_id: eventId,
filename: newFilename,
path: relativePath,
thumbnail_path: relativeThumbPath,
type: photoType,
size_bytes: file.size,
uploaded_by: uploadedBy,
source_origin: 'managed'
})
.returning('id'); .returning('id');
} else { } else {
insertResult = await trx('photos').insert({ insertResult = await trx('photos').insert(photoData);
event_id: eventId,
filename: newFilename,
path: relativePath,
thumbnail_path: relativeThumbPath,
type: photoType,
size_bytes: file.size,
uploaded_by: uploadedBy,
source_origin: 'managed'
});
} }
const insertedId = Array.isArray(insertResult) const insertedId = Array.isArray(insertResult)
+182
View File
@@ -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
};
+50 -14
View File
@@ -45,7 +45,7 @@ function isPathSafe(filePath) {
} }
/** /**
* Enhanced MIME type validation * Enhanced MIME type validation for images and videos
*/ */
const ALLOWED_IMAGE_TYPES = { const ALLOWED_IMAGE_TYPES = {
'image/jpeg': { '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 * Validate file type by MIME type and extension
* @param {string} filename - The filename * @param {string} filename - The filename
@@ -93,16 +127,16 @@ function validateFileType(filename, mimetype, allowedTypes) {
if (!allowedTypes.includes(mimetype)) { if (!allowedTypes.includes(mimetype)) {
return false; return false;
} }
// Get file extension // Get file extension
const ext = path.extname(filename).toLowerCase(); const ext = path.extname(filename).toLowerCase();
// Check if extension matches the MIME type // 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)) { if (!typeConfig || !typeConfig.extensions.includes(ext)) {
return false; return false;
} }
return true; return true;
} }
@@ -114,22 +148,22 @@ function validateFileType(filename, mimetype, allowedTypes) {
*/ */
async function validateFileContent(filePath, expectedMimeType) { async function validateFileContent(filePath, expectedMimeType) {
try { try {
const typeConfig = ALLOWED_IMAGE_TYPES[expectedMimeType]; const typeConfig = ALLOWED_MEDIA_TYPES[expectedMimeType];
if (!typeConfig) { if (!typeConfig) {
return false; return false;
} }
// Skip validation for file types without magic numbers (like SVG) // Skip validation for file types without magic numbers (like SVG)
if (!typeConfig.magicNumbers) { if (!typeConfig.magicNumbers) {
return true; return true;
} }
// Read the first 20 bytes of the file (enough for most magic numbers) // Read the first 20 bytes of the file (enough for most magic numbers)
const buffer = Buffer.alloc(20); const buffer = Buffer.alloc(20);
const fileHandle = await fs.open(filePath, 'r'); const fileHandle = await fs.open(filePath, 'r');
await fileHandle.read(buffer, 0, 20, 0); await fileHandle.read(buffer, 0, 20, 0);
await fileHandle.close(); await fileHandle.close();
// Check magic numbers // Check magic numbers
return typeConfig.magicNumbers.every(magic => { return typeConfig.magicNumbers.every(magic => {
for (let i = 0; i < magic.bytes.length; i++) { for (let i = 0; i < magic.bytes.length; i++) {
@@ -154,13 +188,13 @@ function getSafeFilename(originalFilename) {
const timestamp = Date.now(); const timestamp = Date.now();
const randomString = Math.random().toString(36).substring(2, 15); const randomString = Math.random().toString(36).substring(2, 15);
const ext = path.extname(originalFilename).toLowerCase(); const ext = path.extname(originalFilename).toLowerCase();
// Validate extension // Validate extension - including both image and video extensions
const validExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg', '.ico']; const validExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg', '.ico', '.mp4', '.m4v', '.webm', '.mov', '.avi'];
if (!validExtensions.includes(ext)) { if (!validExtensions.includes(ext)) {
throw new Error('Invalid file extension'); throw new Error('Invalid file extension');
} }
return `upload_${timestamp}_${randomString}${ext}`; return `upload_${timestamp}_${randomString}${ext}`;
} }
@@ -229,5 +263,7 @@ module.exports = {
validateFileContent, validateFileContent,
getSafeFilename, getSafeFilename,
createFileUploadValidator, createFileUploadValidator,
ALLOWED_IMAGE_TYPES ALLOWED_IMAGE_TYPES,
ALLOWED_VIDEO_TYPES,
ALLOWED_MEDIA_TYPES
}; };
+7
View File
@@ -43,6 +43,13 @@ done
>&2 echo "Target database \"$target_db\" is ready." >&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) # Run migrations (use safe runner in production)
echo "Running database migrations..." echo "Running database migrations..."
if [ "$NODE_ENV" = "production" ]; then if [ "$NODE_ENV" = "production" ]; then
+8 -3
View File
@@ -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.description="PicPeak Frontend Application"
LABEL org.opencontainers.image.licenses="MIT" LABEL org.opencontainers.image.licenses="MIT"
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
RUN npm install -g npm@latest
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
@@ -27,11 +30,13 @@ COPY . .
# Build the application # Build the application
RUN npm run build RUN npm run build
# Production stage # Production stage (use Alpine with patched libpng)
FROM nginx:alpine FROM nginx:1.27-alpine3.21
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs) # Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
RUN apk upgrade --no-cache 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 # Install runtime dependencies
RUN apk add --no-cache curl RUN apk add --no-cache curl
@@ -63,4 +68,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
USER nginx USER nginx
# Start nginx # Start nginx
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+1611 -1164
View File
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 React, { useState } from 'react';
import { Check, Download, Trash2, Eye, Package, MessageSquare, Star } from 'lucide-react'; import { Check, Download, Trash2, Eye, Package, MessageSquare, Star, Video } from 'lucide-react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import { AdminPhoto } from '../../services/photos.service'; import { AdminPhoto } from '../../services/photos.service';
import { photosService } from '../../services/photos.service'; import { photosService } from '../../services/photos.service';
@@ -20,6 +21,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
onPhotoClick, onPhotoClick,
onPhotosDeleted onPhotosDeleted
}) => { }) => {
const { t } = useTranslation();
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set()); const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
const [isSelectionMode, setIsSelectionMode] = useState(false); const [isSelectionMode, setIsSelectionMode] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
@@ -126,7 +128,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
onClick={toggleSelectionMode} onClick={toggleSelectionMode}
leftIcon={<Package className="w-4 h-4" />} leftIcon={<Package className="w-4 h-4" />}
> >
{isSelectionMode ? 'Cancel Selection' : 'Select Photos'} {isSelectionMode ? t('gallery.cancelSelection', 'Cancel Selection') : t('gallery.selectPhotos', 'Select Photos')}
</Button> </Button>
{(isSelectionMode || selectedPhotos.size > 0) && ( {(isSelectionMode || selectedPhotos.size > 0) && (
@@ -136,13 +138,13 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
size="sm" size="sm"
onClick={handleSelectAll} onClick={handleSelectAll}
> >
{selectedPhotos.size === photos.length ? 'Deselect All' : 'Select All'} {selectedPhotos.size === photos.length ? t('gallery.deselectAll', 'Deselect All') : t('gallery.selectAll', 'Select All')}
</Button> </Button>
{selectedPhotos.size > 0 && ( {selectedPhotos.size > 0 && (
<> <>
<span className="text-sm text-neutral-600"> <span className="text-sm text-neutral-600">
{selectedPhotos.size} selected {t('gallery.photosSelected', { count: selectedPhotos.size })}
</span> </span>
<button <button
onClick={handleDeleteSelected} onClick={handleDeleteSelected}
@@ -150,7 +152,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
className="px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:bg-red-400 rounded-lg flex items-center gap-2" className="px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:bg-red-400 rounded-lg flex items-center gap-2"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
Delete Selected {t('gallery.deleteSelected', 'Delete Selected')}
</button> </button>
</> </>
)} )}
@@ -159,7 +161,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
</div> </div>
<div className="text-sm text-neutral-600"> <div className="text-sm text-neutral-600">
{photos.length} photo{photos.length !== 1 ? 's' : ''} {t('gallery.photosCount', { count: photos.length })}
</div> </div>
</div> </div>
@@ -170,6 +172,9 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
const commentCount = photo.comment_count ?? 0; const commentCount = photo.comment_count ?? 0;
const averageRating = photo.average_rating ?? 0; const averageRating = photo.average_rating ?? 0;
const likeCount = photo.like_count ?? 0; const likeCount = photo.like_count ?? 0;
const isVideo = (photo.media_type === 'video') ||
(photo.mime_type && photo.mime_type.startsWith('video/')) ||
photo.type === 'video';
return ( return (
<div <div
key={photo.id} key={photo.id}
@@ -259,6 +264,15 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
</span> </span>
</div> </div>
)} )}
{isVideo && (
<div className="absolute bottom-2 left-2 pointer-events-none">
<span className="px-2 py-1 text-[11px] font-semibold bg-black/70 text-white rounded flex items-center gap-1">
<Video className="w-3 h-3" />
{t('common.video', 'Video')}
</span>
</div>
)}
{/* Feedback Indicators (moved to bottom-right to avoid covering category) */} {/* Feedback Indicators (moved to bottom-right to avoid covering category) */}
{(commentCount > 0 || averageRating > 0 || likeCount > 0) && ( {(commentCount > 0 || averageRating > 0 || likeCount > 0) && (
@@ -284,7 +298,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
{photos.length === 0 && ( {photos.length === 0 && (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-neutral-500">No photos uploaded yet</p> <p className="text-neutral-500">{t('gallery.noMedia', 'No media uploaded yet')}</p>
</div> </div>
)} )}
</div> </div>
@@ -9,6 +9,7 @@ import { photosService } from '../../services/photos.service';
import { feedbackService, type PhotoFeedback, type FeedbackSummary } from '../../services/feedback.service'; import { feedbackService, type PhotoFeedback, type FeedbackSummary } from '../../services/feedback.service';
import { Button } from '../common'; import { Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
import { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
type AdminFeedbackResponse = { type AdminFeedbackResponse = {
feedback: PhotoFeedback[]; feedback: PhotoFeedback[];
@@ -39,6 +40,11 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const currentPhoto = photos[currentIndex]; const currentPhoto = photos[currentIndex];
const isVideo = currentPhoto
? (currentPhoto.media_type === 'video' ||
(currentPhoto.mime_type && String(currentPhoto.mime_type).startsWith('video/')) ||
currentPhoto.type === 'video')
: false;
const averageRating = currentPhoto?.average_rating ?? 0; const averageRating = currentPhoto?.average_rating ?? 0;
const likeCount = currentPhoto?.like_count ?? 0; const likeCount = currentPhoto?.like_count ?? 0;
const favoriteCount = currentPhoto?.favorite_count ?? 0; const favoriteCount = currentPhoto?.favorite_count ?? 0;
@@ -191,19 +197,35 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
<div className="flex flex-col lg:flex-row gap-6 max-w-7xl mx-auto p-4 w-full h-full"> <div className="flex flex-col lg:flex-row gap-6 max-w-7xl mx-auto p-4 w-full h-full">
{/* Image */} {/* Image */}
<div className="flex-1 flex items-center justify-center min-h-0"> <div className="flex-1 flex items-center justify-center min-h-0">
<AdminAuthenticatedImage {isVideo ? (
src={currentPhoto.url} <AdminAuthenticatedVideo
alt={currentPhoto.filename} src={currentPhoto.url}
className="max-w-full max-h-full object-contain" className="max-w-full max-h-full bg-black"
fallback={ poster={currentPhoto.thumbnail_url || undefined}
<div className="flex items-center justify-center text-neutral-400"> fallback={
<div className="text-center"> <div className="flex items-center justify-center text-neutral-400">
<Eye className="w-12 h-12 mx-auto mb-2" /> <div className="text-center">
<p className="text-sm">Failed to load image</p> <Eye className="w-12 h-12 mx-auto mb-2" />
<p className="text-sm">Failed to load media</p>
</div>
</div> </div>
</div> }
} />
/> ) : (
<AdminAuthenticatedImage
src={currentPhoto.url}
alt={currentPhoto.filename}
className="max-w-full max-h-full object-contain"
fallback={
<div className="flex items-center justify-center text-neutral-400">
<div className="text-center">
<Eye className="w-12 h-12 mx-auto mb-2" />
<p className="text-sm">Failed to load image</p>
</div>
</div>
}
/>
)}
</div> </div>
{/* Sidebar */} {/* Sidebar */}
+42 -14
View File
@@ -1,16 +1,20 @@
import React from 'react'; import React from 'react';
import { Search, Filter, SortAsc, SortDesc } from 'lucide-react'; import { Search, Filter, SortAsc, SortDesc } from 'lucide-react';
import { Input } from '../common'; import { Input } from '../common';
import { useTranslation } from 'react-i18next';
interface PhotoFiltersProps { interface PhotoFiltersProps {
categories: Array<{ id: number; name: string; slug: string }>; categories: Array<{ id: number | string; name: string; slug: string }>;
selectedCategory: number | null | undefined; selectedCategory: number | string | null | undefined;
searchTerm: string; searchTerm: string;
sortBy: 'date' | 'name' | 'size' | 'rating'; sortBy: 'date' | 'name' | 'size' | 'rating';
sortOrder: 'asc' | 'desc'; sortOrder: 'asc' | 'desc';
onCategoryChange: (categoryId: number | null | undefined) => void; onCategoryChange: (categoryId: number | string | null | undefined) => void;
onSearchChange: (search: string) => void; onSearchChange: (search: string) => void;
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating', order: 'asc' | 'desc') => void; onSortChange: (sort: 'date' | 'name' | 'size' | 'rating', order: 'asc' | 'desc') => void;
mediaType?: 'all' | 'photo' | 'video';
onMediaTypeChange?: (mediaType: 'all' | 'photo' | 'video') => void;
showMediaFilter?: boolean;
} }
export const PhotoFilters: React.FC<PhotoFiltersProps> = ({ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
@@ -21,8 +25,12 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
sortOrder, sortOrder,
onCategoryChange, onCategoryChange,
onSearchChange, onSearchChange,
onSortChange onSortChange,
mediaType = 'all',
onMediaTypeChange,
showMediaFilter = false
}) => { }) => {
const { t } = useTranslation();
const handleSortToggle = () => { const handleSortToggle = () => {
onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc'); onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc');
}; };
@@ -34,7 +42,7 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
<div className="flex-1"> <div className="flex-1">
<Input <Input
type="text" type="text"
placeholder="Search by filename..." placeholder={t('gallery.searchByFilename', 'Search by filename...')}
value={searchTerm} value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)} onChange={(e) => onSearchChange(e.target.value)}
leftIcon={<Search className="w-5 h-5 text-neutral-400" />} leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
@@ -46,11 +54,16 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
<Filter className="w-5 h-5 text-neutral-400" /> <Filter className="w-5 h-5 text-neutral-400" />
<select <select
value={selectedCategory === null ? '' : selectedCategory || ''} value={selectedCategory === null ? '' : selectedCategory || ''}
onChange={(e) => onCategoryChange(e.target.value === '' ? null : Number(e.target.value) || undefined)} onChange={(e) => {
const raw = e.target.value;
if (raw === '') return onCategoryChange(null);
const numeric = Number(raw);
onCategoryChange(Number.isNaN(numeric) ? raw : numeric);
}}
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
> >
<option value="">All Categories</option> <option value="">{t('gallery.allCategories', 'All Categories')}</option>
<option value="0">Uncategorized</option> <option value="0">{t('gallery.uncategorized', 'Uncategorized')}</option>
{categories.map(cat => ( {categories.map(cat => (
<option key={cat.id} value={cat.id}> <option key={cat.id} value={cat.id}>
{cat.name} {cat.name}
@@ -59,6 +72,21 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
</select> </select>
</div> </div>
{showMediaFilter && onMediaTypeChange && (
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-neutral-400" />
<select
value={mediaType}
onChange={(e) => onMediaTypeChange(e.target.value as 'all' | 'photo' | 'video')}
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="all">{t('gallery.allMedia', 'All media')}</option>
<option value="photo">{t('gallery.photosOnly', 'Photos only')}</option>
<option value="video">{t('gallery.videosOnly', 'Videos only')}</option>
</select>
</div>
)}
{/* Sort Options */} {/* Sort Options */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<select <select
@@ -66,16 +94,16 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size' | 'rating', sortOrder)} onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size' | 'rating', sortOrder)}
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
> >
<option value="date">Sort by Date</option> <option value="date">{t('gallery.sortByDate', 'Sort by Date')}</option>
<option value="name">Sort by Name</option> <option value="name">{t('gallery.sortByName', 'Sort by Name')}</option>
<option value="size">Sort by Size</option> <option value="size">{t('gallery.sortBySize', 'Sort by Size')}</option>
<option value="rating">Sort by Rating</option> <option value="rating">{t('gallery.sortByRating', 'Sort by Rating')}</option>
</select> </select>
<button <button
onClick={handleSortToggle} onClick={handleSortToggle}
className="p-2 border border-neutral-300 rounded-lg hover:bg-neutral-50 transition-colors" className="p-2 border border-neutral-300 rounded-lg hover:bg-neutral-50 transition-colors"
aria-label={sortOrder === 'asc' ? 'Sort descending' : 'Sort ascending'} aria-label={sortOrder === 'asc' ? t('gallery.sortDescending', 'Sort descending') : t('gallery.sortAscending', 'Sort ascending')}
> >
{sortOrder === 'asc' ? ( {sortOrder === 'asc' ? (
<SortAsc className="w-5 h-5 text-neutral-600" /> <SortAsc className="w-5 h-5 text-neutral-600" />
@@ -87,4 +115,4 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
</div> </div>
</div> </div>
); );
}; };
@@ -236,7 +236,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
multiple multiple
accept="image/jpeg,image/png,image/webp" accept="image/jpeg,image/png,image/webp,video/mp4,video/webm,video/quicktime,video/x-msvideo"
onChange={handleFileSelect} onChange={handleFileSelect}
className="hidden" 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]"> <div className="bg-white rounded-lg shadow-xl w-full max-w-2xl flex flex-col max-h-[90vh]">
{/* Fixed Header */} {/* Fixed Header */}
<div className="flex items-center justify-between p-6 border-b border-neutral-200"> <div className="flex items-center justify-between p-6 border-b border-neutral-200">
<h2 className="text-xl font-semibold text-neutral-900">{t('events.uploadPhotos')}</h2> <h2 className="text-xl font-semibold text-neutral-900">{t('upload.uploadMedia', t('events.uploadPhotos'))}</h2>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -56,4 +56,4 @@ export const PhotoUploadModal: React.FC<PhotoUploadModalProps> = ({
); );
}; };
PhotoUploadModal.displayName = 'PhotoUploadModal'; PhotoUploadModal.displayName = 'PhotoUploadModal';
+2 -1
View File
@@ -17,6 +17,7 @@ export { AdminPhotoViewer } from './AdminPhotoViewer';
export { PhotoFilters } from './PhotoFilters'; export { PhotoFilters } from './PhotoFilters';
export { PasswordResetModal } from './PasswordResetModal'; export { PasswordResetModal } from './PasswordResetModal';
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
export { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced'; export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
export { ThemeDisplay } from './ThemeDisplay'; export { ThemeDisplay } from './ThemeDisplay';
export { ThemeEditorModal } from './ThemeEditorModal'; export { ThemeEditorModal } from './ThemeEditorModal';
@@ -29,4 +30,4 @@ export { BackupHistory } from './BackupHistory';
export { RestoreWizard } from './RestoreWizard'; export { RestoreWizard } from './RestoreWizard';
export { FeedbackSettings } from './FeedbackSettings'; export { FeedbackSettings } from './FeedbackSettings';
export { FeedbackModerationPanel } from './FeedbackModerationPanel'; export { FeedbackModerationPanel } from './FeedbackModerationPanel';
export { WordFilterManager } from './WordFilterManager'; export { WordFilterManager } from './WordFilterManager';
@@ -0,0 +1,125 @@
import React, { useEffect, useState } from 'react';
import { buildResourceUrl } from '../../utils/url';
import {
getActiveGallerySlug,
getGalleryToken,
inferGallerySlugFromLocation,
resolveSlugFromRequestUrl,
} from '../../utils/galleryAuthStorage';
interface AuthenticatedVideoProps extends React.VideoHTMLAttributes<HTMLVideoElement> {
src: string;
fallbackSrc?: string;
slug?: string;
}
export const AuthenticatedVideo: React.FC<AuthenticatedVideoProps> = ({
src,
fallbackSrc,
slug,
...props
}) => {
const [videoSrc, setVideoSrc] = useState<string>('');
const [error, setError] = useState(false);
useEffect(() => {
let aborted = false;
const objectUrls: string[] = [];
if (!src) {
setVideoSrc('');
setError(true);
return;
}
const resolveSlug = (candidateSrc?: string): string | null => {
if (slug) {
return slug;
}
const fromUrl = candidateSrc ? resolveSlugFromRequestUrl(candidateSrc) : null;
if (fromUrl) {
return fromUrl;
}
return getActiveGallerySlug() || inferGallerySlugFromLocation();
};
const fetchWithAuth = async (rawUrl: string | undefined | null): Promise<string> => {
if (!rawUrl) {
throw new Error('No URL provided');
}
const fullUrl = rawUrl.startsWith('/')
? buildResourceUrl(rawUrl)
: rawUrl;
const headers: Record<string, string> = {};
const slugForRequest = resolveSlug(rawUrl);
const token = getGalleryToken(slugForRequest);
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(fullUrl, {
credentials: 'include',
headers: Object.keys(headers).length ? headers : undefined,
});
if (!response.ok) {
throw new Error(`Failed to fetch media: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
objectUrls.push(objectUrl);
return objectUrl;
};
const load = async () => {
try {
const primaryUrl = await fetchWithAuth(src);
if (!aborted) {
setVideoSrc(primaryUrl);
setError(false);
}
} catch (err) {
if (fallbackSrc && fallbackSrc !== src) {
try {
const fallbackUrl = await fetchWithAuth(fallbackSrc);
if (!aborted) {
setVideoSrc(fallbackUrl);
setError(false);
}
return;
} catch (_) {
// ignore and set error below
}
}
if (!aborted) {
setError(true);
setVideoSrc('');
}
}
};
load();
return () => {
aborted = true;
objectUrls.forEach((url) => URL.revokeObjectURL(url));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [src, fallbackSrc, slug]);
if (error || !videoSrc) {
return null;
}
return (
<video
src={videoSrc}
controls
preload="metadata"
{...props}
/>
);
};
+2 -1
View File
@@ -16,7 +16,8 @@ export { SkipLink } from './SkipLink';
export { DynamicFavicon } from './DynamicFavicon'; export { DynamicFavicon } from './DynamicFavicon';
export { LanguageSelector } from './LanguageSelector'; export { LanguageSelector } from './LanguageSelector';
export { AuthenticatedImage } from './AuthenticatedImage'; export { AuthenticatedImage } from './AuthenticatedImage';
export { AuthenticatedVideo } from './AuthenticatedVideo';
export { ProtectedImage } from './ProtectedImage'; export { ProtectedImage } from './ProtectedImage';
export { ProtectionWarning } from './ProtectionWarning'; export { ProtectionWarning } from './ProtectionWarning';
export { ReCaptcha } from './ReCaptcha'; export { ReCaptcha } from './ReCaptcha';
export { PasswordGenerator } from './PasswordGenerator'; export { PasswordGenerator } from './PasswordGenerator';
@@ -9,8 +9,8 @@ interface GallerySidebarProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
categories: PhotoCategory[]; categories: PhotoCategory[];
selectedCategoryId: number | null; selectedCategoryId: number | string | null;
onCategoryChange: (categoryId: number | null) => void; onCategoryChange: (categoryId: number | string | null) => void;
searchTerm: string; searchTerm: string;
onSearchChange: (term: string) => void; onSearchChange: (term: string) => void;
sortBy: 'date' | 'name' | 'size' | 'rating'; sortBy: 'date' | 'name' | 'size' | 'rating';
@@ -22,7 +22,7 @@ interface GallerySidebarProps {
onDownloadSelected: () => void; onDownloadSelected: () => void;
isDownloading: boolean; isDownloading: boolean;
allowDownloads?: boolean; allowDownloads?: boolean;
photoCounts?: Record<number, number>; photoCounts?: Record<number | string, number>;
totalPhotos: number; totalPhotos: number;
isMobile: boolean; isMobile: boolean;
galleryLayout?: string; galleryLayout?: string;
@@ -34,6 +34,9 @@ interface GallerySidebarProps {
likeCount?: number; likeCount?: number;
favoriteCount?: number; favoriteCount?: number;
ratedCount?: number; ratedCount?: number;
mediaFilter?: 'all' | 'photo' | 'video';
onMediaFilterChange?: (filter: 'all' | 'photo' | 'video') => void;
showMediaFilter?: boolean;
} }
export const GallerySidebar: React.FC<GallerySidebarProps> = ({ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
@@ -64,7 +67,10 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
onFilterChange, onFilterChange,
likeCount = 0, likeCount = 0,
favoriteCount = 0, favoriteCount = 0,
ratedCount = 0 ratedCount = 0,
mediaFilter = 'all',
onMediaFilterChange,
showMediaFilter = false
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const sidebarRef = useRef<HTMLDivElement>(null); const sidebarRef = useRef<HTMLDivElement>(null);
@@ -288,6 +294,47 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
</div> </div>
)} )}
{showMediaFilter && onMediaFilterChange && (
<div className="p-4 border-b border-neutral-200">
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
<Filter className="w-4 h-4" />
{t('gallery.mediaType', 'Media')}
</h3>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant={mediaFilter === 'all' ? 'primary' : 'outline'}
size="sm"
onClick={() => {
onMediaFilterChange('all');
if (isMobile) onClose();
}}
>
{t('gallery.allMedia', 'All')}
</Button>
<Button
variant={mediaFilter === 'photo' ? 'primary' : 'outline'}
size="sm"
onClick={() => {
onMediaFilterChange('photo');
if (isMobile) onClose();
}}
>
{t('gallery.photosOnly', 'Photos')}
</Button>
<Button
variant={mediaFilter === 'video' ? 'primary' : 'outline'}
size="sm"
onClick={() => {
onMediaFilterChange('video');
if (isMobile) onClose();
}}
>
{t('gallery.videosOnly', 'Videos')}
</Button>
</div>
</div>
)}
{/* Sort Section - Hidden for carousel and timeline layouts */} {/* Sort Section - Hidden for carousel and timeline layouts */}
{galleryLayout !== 'carousel' && galleryLayout !== 'timeline' && ( {galleryLayout !== 'carousel' && galleryLayout !== 'timeline' && (
<div className="p-4"> <div className="p-4">
+68 -16
View File
@@ -44,7 +44,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { logout } = useGalleryAuth(); const { logout } = useGalleryAuth();
const { setTheme, theme } = useTheme(); const { setTheme, theme } = useTheme();
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null); const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating'>('date'); const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating'>('date');
const [brandingSettings, setBrandingSettings] = useState<any>(null); const [brandingSettings, setBrandingSettings] = useState<any>(null);
@@ -57,8 +57,22 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const { watermarkEnabled } = useWatermarkSettings(); const { watermarkEnabled } = useWatermarkSettings();
const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard'); const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard');
const [filterType, setFilterType] = useState<FilterType>('all'); const [filterType, setFilterType] = useState<FilterType>('all');
const [mediaFilter, setMediaFilter] = useState<'all' | 'photo' | 'video'>('all');
const [guestId, setGuestId] = useState<string>(''); const [guestId, setGuestId] = useState<string>('');
const [staticHeroPhoto, setStaticHeroPhoto] = useState<Photo | null>(null); const [staticHeroPhoto, setStaticHeroPhoto] = useState<Photo | null>(null);
const resolveMediaType = (photo: Photo) => {
if (photo.media_type === 'video' || photo.media_type === 'photo') {
return photo.media_type;
}
if (photo.mime_type && photo.mime_type.startsWith('video/')) {
return 'video';
}
if ((photo as any).type === 'video') {
return 'video';
}
return 'photo';
};
// Generate a unique guest ID for this session // Generate a unique guest ID for this session
useEffect(() => { useEffect(() => {
@@ -176,6 +190,25 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
} }
}, [settingsData]); }, [settingsData]);
const availableMediaTypes = useMemo(() => {
const types = new Set<'photo' | 'video'>();
(data?.photos || []).forEach((photo) => {
const mediaType = resolveMediaType(photo);
if (mediaType === 'photo' || mediaType === 'video') {
types.add(mediaType);
}
});
return types;
}, [data?.photos]);
const showMediaFilter = availableMediaTypes.has('photo') && availableMediaTypes.has('video');
useEffect(() => {
if (!showMediaFilter && mediaFilter !== 'all') {
setMediaFilter('all');
}
}, [showMediaFilter, mediaFilter]);
// Determine a stable hero photo from the initial (unfiltered) load // Determine a stable hero photo from the initial (unfiltered) load
useEffect(() => { useEffect(() => {
if (!staticHeroPhoto && data?.photos && filterType === 'all') { if (!staticHeroPhoto && data?.photos && filterType === 'all') {
@@ -185,7 +218,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
hero = data.photos.find(p => p.id === heroId) || null; hero = data.photos.find(p => p.id === heroId) || null;
} }
if (!hero && data.photos.length > 0) { if (!hero && data.photos.length > 0) {
hero = data.photos[0]; const firstPhoto = data.photos.find(p => resolveMediaType(p) === 'photo');
hero = firstPhoto || data.photos[0];
} }
if (hero) { if (hero) {
setStaticHeroPhoto(hero); setStaticHeroPhoto(hero);
@@ -259,6 +293,12 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
if (!data?.photos) return []; if (!data?.photos) return [];
let photos = [...data.photos]; let photos = [...data.photos];
if (mediaFilter === 'photo') {
photos = photos.filter(photo => resolveMediaType(photo) !== 'video');
} else if (mediaFilter === 'video') {
photos = photos.filter(photo => resolveMediaType(photo) === 'video');
}
// Apply category filter // Apply category filter
if (selectedCategoryId) { if (selectedCategoryId) {
@@ -323,7 +363,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
} }
return photos; return photos;
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug, filterType]); }, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug, filterType, mediaFilter]);
const likeCount = useMemo( const likeCount = useMemo(
() => data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0, () => data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0,
@@ -388,14 +428,20 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
// Calculate photo counts per category // Calculate photo counts per category
const photoCounts = useMemo(() => { const photoCounts = useMemo(() => {
if (!data?.photos) return {}; if (!data?.photos) return {};
const counts: Record<number, number> = {}; const counts: Record<number | string, number> = {};
data.photos.forEach(photo => { data.photos
.filter(photo => {
if (mediaFilter === 'photo') return resolveMediaType(photo) !== 'video';
if (mediaFilter === 'video') return resolveMediaType(photo) === 'video';
return true;
})
.forEach(photo => {
if (photo.category_id) { if (photo.category_id) {
counts[photo.category_id] = (counts[photo.category_id] || 0) + 1; counts[photo.category_id] = (counts[photo.category_id] || 0) + 1;
} }
}); });
return counts; return counts;
}, [data?.photos]); }, [data?.photos, mediaFilter]);
// Track search usage with debouncing // Track search usage with debouncing
useEffect(() => { useEffect(() => {
@@ -497,6 +543,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
feedbackEnabled={feedbackEnabled} feedbackEnabled={feedbackEnabled}
filterType={filterType} filterType={filterType}
onFilterChange={setFilterType} onFilterChange={setFilterType}
mediaFilter={mediaFilter}
onMediaFilterChange={setMediaFilter}
showMediaFilter={showMediaFilter}
likeCount={likeCount} likeCount={likeCount}
favoriteCount={favoriteCount} favoriteCount={favoriteCount}
ratedCount={ratedCount} ratedCount={ratedCount}
@@ -582,16 +631,19 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
onCategoryChange={setSelectedCategoryId} onCategoryChange={setSelectedCategoryId}
searchTerm={searchTerm} searchTerm={searchTerm}
onSearchChange={setSearchTerm} onSearchChange={setSearchTerm}
sortBy={sortBy} sortBy={sortBy}
onSortChange={setSortBy} onSortChange={setSortBy}
photoCount={filteredPhotos.length} photoCount={filteredPhotos.length}
// Feedback filter props // Feedback filter props
feedbackEnabled={feedbackEnabled} feedbackEnabled={feedbackEnabled}
currentFilter={filterType} currentFilter={filterType}
onFilterChange={setFilterType} onFilterChange={setFilterType}
/> mediaFilter={mediaFilter}
</div> onMediaFilterChange={setMediaFilter}
) : null} showMediaFilter={showMediaFilter}
/>
</div>
) : null}
{/* Photo Grid */} {/* Photo Grid */}
<div className={showSidebar ? "mt-6" : "mt-6"}> <div className={showSidebar ? "mt-6" : "mt-6"}>
@@ -32,6 +32,9 @@ interface PhotoFilterBarProps {
feedbackEnabled?: boolean; feedbackEnabled?: boolean;
currentFilter?: FilterType; currentFilter?: FilterType;
onFilterChange?: (filter: FilterType) => void; onFilterChange?: (filter: FilterType) => void;
mediaFilter?: 'all' | 'photo' | 'video';
onMediaFilterChange?: (filter: 'all' | 'photo' | 'video') => void;
showMediaFilter?: boolean;
} }
export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
@@ -47,6 +50,9 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
feedbackEnabled = false, feedbackEnabled = false,
currentFilter = 'all', currentFilter = 'all',
onFilterChange, onFilterChange,
mediaFilter = 'all',
onMediaFilterChange,
showMediaFilter = false
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [showSortMenu, setShowSortMenu] = useState(false); const [showSortMenu, setShowSortMenu] = useState(false);
@@ -148,7 +154,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
leftIcon={<Grid className="w-3 h-3 md:w-4 md:h-4" />} leftIcon={<Grid className="w-3 h-3 md:w-4 md:h-4" />}
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0" className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
> >
{t('gallery.allPhotos')} ({photos.length}) {showMediaFilter ? t('gallery.allMedia', 'All media') : t('gallery.allPhotos')} ({photos.length})
</Button> </Button>
{categories.map((category) => { {categories.map((category) => {
const categoryPhotoCount = photos.filter(p => p.category_id === category.id).length; const categoryPhotoCount = photos.filter(p => p.category_id === category.id).length;
@@ -226,10 +232,44 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
)} )}
<p className="text-xs md:text-sm text-neutral-600 flex-shrink-0 ml-auto"> <p className="text-xs md:text-sm text-neutral-600 flex-shrink-0 ml-auto">
{photoCount} {photoCount === 1 ? t('common.photo') : t('common.photos')} {photoCount} {t('common.media', 'media')}
</p> </p>
</div> </div>
)} )}
{showMediaFilter && onMediaFilterChange && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs md:text-sm text-neutral-600 whitespace-nowrap">
{t('gallery.mediaType', 'Media')}
</span>
<div className="flex items-center gap-2">
<Button
variant={mediaFilter === 'all' ? 'primary' : 'outline'}
size="sm"
onClick={() => onMediaFilterChange('all')}
className="text-xs md:text-sm"
>
{t('gallery.allMedia', 'All')}
</Button>
<Button
variant={mediaFilter === 'photo' ? 'primary' : 'outline'}
size="sm"
onClick={() => onMediaFilterChange('photo')}
className="text-xs md:text-sm"
>
{t('gallery.photosOnly', 'Photos')}
</Button>
<Button
variant={mediaFilter === 'video' ? 'primary' : 'outline'}
size="sm"
onClick={() => onMediaFilterChange('video')}
className="text-xs md:text-sm"
>
{t('gallery.videosOnly', 'Videos')}
</Button>
</div>
</div>
)}
{/* Mobile/Tablet: compact horizontal icons with headline below categories */} {/* Mobile/Tablet: compact horizontal icons with headline below categories */}
{feedbackEnabled && onFilterChange && ( {feedbackEnabled && onFilterChange && (
+17 -6
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; 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 { useInView } from 'react-intersection-observer';
import { toast as toastify } from 'react-toastify'; import { toast as toastify } from 'react-toastify';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -336,14 +336,25 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
</div> </div>
)} )}
{/* Photo type badge */} {/* Media type badges */}
{photo.type === 'collage' && ( <div className="absolute bottom-2 left-2 flex gap-2">
<div className="absolute bottom-2 left-2"> {photo.type === 'collage' && (
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded"> <span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
Collage Collage
</span> </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" /> <div className="skeleton aspect-square w-full" />
@@ -7,6 +7,7 @@ import { AuthenticatedImage } from '../common';
import { PhotoFeedback } from './PhotoFeedback'; import { PhotoFeedback } from './PhotoFeedback';
import { feedbackService } from '../../services/feedback.service'; import { feedbackService } from '../../services/feedback.service';
import { FeedbackIdentityModal } from './FeedbackIdentityModal'; import { FeedbackIdentityModal } from './FeedbackIdentityModal';
import { VideoPlayer } from './VideoPlayer';
interface PhotoLightboxProps { interface PhotoLightboxProps {
photos: Photo[]; photos: Photo[];
@@ -448,49 +449,58 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
</div> </div>
</div> </div>
{/* Image container */} {/* Image/Video container */}
<div <div
className="absolute top-0 left-0 bottom-0 flex items-center justify-center z-0" className="absolute top-0 left-0 bottom-0 flex items-center justify-center z-0"
onClick={handleImageClick} onClick={currentPhoto.media_type === 'video' ? undefined : handleImageClick}
onMouseDown={handleMouseDown} onMouseDown={currentPhoto.media_type === 'video' ? undefined : handleMouseDown}
onMouseMove={handleMouseMove} onMouseMove={currentPhoto.media_type === 'video' ? undefined : handleMouseMove}
onMouseUp={handleMouseUp} onMouseUp={currentPhoto.media_type === 'video' ? undefined : handleMouseUp}
onMouseLeave={handleMouseUp} onMouseLeave={currentPhoto.media_type === 'video' ? undefined : handleMouseUp}
onTouchStart={handleTouchStart} onTouchStart={currentPhoto.media_type === 'video' ? undefined : handleTouchStart}
onTouchMove={handleTouchMove} onTouchMove={currentPhoto.media_type === 'video' ? undefined : handleTouchMove}
onTouchEnd={handleTouchEnd} onTouchEnd={currentPhoto.media_type === 'video' ? undefined : handleTouchEnd}
style={{ 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, right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0,
}} }}
> >
<AuthenticatedImage {currentPhoto.media_type === 'video' ? (
src={currentPhoto.url} <VideoPlayer
alt={currentPhoto.filename} src={currentPhoto.url}
fallbackSrc={currentPhoto.thumbnail_url || undefined} poster={currentPhoto.thumbnail_url}
className="max-w-full max-h-full object-contain select-none" className="max-w-full max-h-full"
style={{ controls={true}
transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`, autoPlay={false}
transition: isDragging ? 'none' : 'transform 0.2s', />
}} ) : (
draggable={false} <AuthenticatedImage
useWatermark={useEnhancedProtection} src={currentPhoto.url}
watermarkText={useEnhancedProtection ? `${currentPhoto.filename} - Protected` : undefined} alt={currentPhoto.filename}
isGallery={true} fallbackSrc={currentPhoto.thumbnail_url || undefined}
slug={slug} className="max-w-full max-h-full object-contain select-none"
photoId={currentPhoto.id} style={{
requiresToken={currentPhoto.requires_token} transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`,
secureUrlTemplate={currentPhoto.secure_url_template} transition: isDragging ? 'none' : 'transform 0.2s',
protectFromDownload={!allowDownloads || useEnhancedProtection} }}
protectionLevel={protectionLevel} draggable={false}
useEnhancedProtection={useEnhancedProtection} useWatermark={useEnhancedProtection}
useCanvasRendering={protectionLevel === 'maximum'} watermarkText={useEnhancedProtection ? `${currentPhoto.filename} - Protected` : undefined}
fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'} isGallery={true}
blockKeyboardShortcuts={useEnhancedProtection} slug={slug}
detectPrintScreen={useEnhancedProtection} photoId={currentPhoto.id}
detectDevTools={protectionLevel === 'enhanced' || protectionLevel === 'maximum'} requiresToken={currentPhoto.requires_token}
onProtectionViolation={(violationType) => { secureUrlTemplate={currentPhoto.secure_url_template}
console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`); protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
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 // Track analytics
if (typeof window !== 'undefined' && (window as any).umami) { 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 // For maximum protection, close lightbox on violation
if (protectionLevel === 'maximum' && if (protectionLevel === 'maximum' &&
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) { ['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {
onClose(); onClose();
} }
}} }}
/> />
)}
</div> </div>
{/* Touch/swipe indicators for mobile */} {/* Touch/swipe indicators for mobile */}
@@ -143,7 +143,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
type="file" type="file"
className="hidden" className="hidden"
multiple 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} onChange={handleFileSelect}
disabled={uploading} 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 React from 'react';
import { Download, Maximize2, Check, MessageSquare, Star, Heart } from 'lucide-react'; import { Download, Maximize2, Check, MessageSquare, Star, Heart, Video } from 'lucide-react';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import { useTheme } from '../../../contexts/ThemeContext'; import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common'; import { AuthenticatedImage } from '../../common';
@@ -155,6 +155,10 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
? 'opacity-100 md:opacity-100' ? 'opacity-100 md:opacity-100'
: 'opacity-0 md:opacity-0'; : 'opacity-0 md:opacity-0';
const isVideo = (photo.media_type === 'video') ||
(photo.mime_type && photo.mime_type.startsWith('video/')) ||
photo.type === 'video';
const handlePhotoClick = (e: React.MouseEvent<HTMLDivElement>) => { const handlePhotoClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (isTouchDevice && !overlayVisible && !isSelectionMode) { if (isTouchDevice && !overlayVisible && !isSelectionMode) {
e.preventDefault(); e.preventDefault();
@@ -318,6 +322,15 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
</div> </div>
)} )}
{isVideo && (
<div className="absolute bottom-2 right-2">
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded flex items-center gap-1">
<Video className="w-3 h-3" />
{t('common.video', 'Video')}
</span>
</div>
)}
{photo.type === 'collage' && ( {photo.type === 'collage' && (
<div className="absolute bottom-2 right-2"> <div className="absolute bottom-2 right-2">
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded"> <span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
+21 -1
View File
@@ -26,6 +26,9 @@
"uploaded": "Hochgeladen", "uploaded": "Hochgeladen",
"photo": "Foto", "photo": "Foto",
"photos": "Fotos", "photos": "Fotos",
"video": "Video",
"videos": "Videos",
"media": "Medien",
"restore": "Wiederherstellen", "restore": "Wiederherstellen",
"actions": "Aktionen", "actions": "Aktionen",
"refresh": "Aktualisieren", "refresh": "Aktualisieren",
@@ -49,12 +52,15 @@
"eventSpecific": "(Veranstaltungsspezifisch)", "eventSpecific": "(Veranstaltungsspezifisch)",
"clickToUpload": "Klicken zum Hochladen oder per Drag & Drop", "clickToUpload": "Klicken zum Hochladen oder per Drag & Drop",
"fileRequirements": "JPEG, PNG oder WebP (max. 50MB pro Datei, {{limit}} Dateien pro Upload)", "fileRequirements": "JPEG, PNG oder WebP (max. 50MB pro Datei, {{limit}} Dateien pro Upload)",
"fileRequirementsMedia": "JPEG-, PNG- oder WebP-Bilder sowie MP4/MOV/WEBM-Videos (max. 50MB pro Datei, {{limit}} Dateien pro Upload)",
"unsupportedFiles": "Einige Dateien wurden übersprungen, da das Format nicht unterstützt wird (JPEG/PNG/WebP/MP4/MOV/WEBM verwenden).",
"selectedFiles": "Ausgewählte Dateien", "selectedFiles": "Ausgewählte Dateien",
"uploading": "Wird hochgeladen...", "uploading": "Wird hochgeladen...",
"uploadComplete": "Upload abgeschlossen!", "uploadComplete": "Upload abgeschlossen!",
"uploadFailed": "Upload fehlgeschlagen", "uploadFailed": "Upload fehlgeschlagen",
"someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden", "someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden",
"uploadPhotos": "Fotos hochladen", "uploadPhotos": "Fotos hochladen",
"uploadMedia": "Fotos & Videos hochladen",
"importExternal": "Aus externem Ordner importieren", "importExternal": "Aus externem Ordner importieren",
"externalImportInfo": "Alle Bilder aus dem ausgewählten Ordner werden importiert.", "externalImportInfo": "Alle Bilder aus dem ausgewählten Ordner werden importiert.",
"selectExternalFolder": "Externen Ordner unter /external-media auswählen", "selectExternalFolder": "Externen Ordner unter /external-media auswählen",
@@ -64,7 +70,9 @@
"tooManyFiles": "Maximal {{limit}} Dateien können gleichzeitig hochgeladen werden", "tooManyFiles": "Maximal {{limit}} Dateien können gleichzeitig hochgeladen werden",
"limitInfo": "{{selected}} von {{limit}} Dateien ausgewählt ({{remaining}} verbleibend)", "limitInfo": "{{selected}} von {{limit}} Dateien ausgewählt ({{remaining}} verbleibend)",
"limitReached": "Upload-Limit erreicht ({{limit}} Dateien pro Vorgang)", "limitReached": "Upload-Limit erreicht ({{limit}} Dateien pro Vorgang)",
"uploadingChunks": "Lade {{count}} Dateien in {{total}} Teilen hoch..." "uploadingChunks": "Lade {{count}} Dateien in {{total}} Teilen hoch...",
"mediaCategory": "Medienkategorie",
"uploadAction": "{{count}} Dateien hochladen"
}, },
"navigation": { "navigation": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -513,6 +521,13 @@
"selectAll": "Alle auswählen", "selectAll": "Alle auswählen",
"deselectAll": "Auswahl aufheben", "deselectAll": "Auswahl aufheben",
"downloadSelected": "{{count}} ausgewählte herunterladen", "downloadSelected": "{{count}} ausgewählte herunterladen",
"deleteSelected": "Ausgewählte löschen",
"photosCount": "{{count}} Foto",
"photosCount_plural": "{{count}} Fotos",
"searchByFilename": "Nach Dateinamen suchen...",
"uncategorized": "Ohne Kategorie",
"sortAscending": "Aufsteigend sortieren",
"sortDescending": "Absteigend sortieren",
"remaining": "verbleibend", "remaining": "verbleibend",
"selectPhotosHint": "Tipp: Verwenden Sie Strg+Klick (Cmd+Klick auf Mac), um schnell mehrere Fotos auszuwählen", "selectPhotosHint": "Tipp: Verwenden Sie Strg+Klick (Cmd+Klick auf Mac), um schnell mehrere Fotos auszuwählen",
"filters": "Filter", "filters": "Filter",
@@ -521,7 +536,12 @@
"toggleMenu": "Menü umschalten", "toggleMenu": "Menü umschalten",
"allCategories": "Alle Kategorien", "allCategories": "Alle Kategorien",
"categories": "Kategorien", "categories": "Kategorien",
"mediaType": "Medien",
"allMedia": "Alle Medien",
"photosOnly": "Fotos",
"videosOnly": "Videos",
"download": "Herunterladen", "download": "Herunterladen",
"noMedia": "Noch keine Medien hochgeladen",
"searchPlaceholder": "Fotos suchen...", "searchPlaceholder": "Fotos suchen...",
"sortBy": "Sortieren nach", "sortBy": "Sortieren nach",
"sortByDate": "Nach Datum sortieren", "sortByDate": "Nach Datum sortieren",
+21 -1
View File
@@ -26,6 +26,9 @@
"uploaded": "Uploaded", "uploaded": "Uploaded",
"photo": "photo", "photo": "photo",
"photos": "photos", "photos": "photos",
"video": "video",
"videos": "videos",
"media": "media",
"restore": "Restore", "restore": "Restore",
"actions": "Actions", "actions": "Actions",
"refresh": "Refresh", "refresh": "Refresh",
@@ -49,12 +52,15 @@
"eventSpecific": "(Event specific)", "eventSpecific": "(Event specific)",
"clickToUpload": "Click to upload or drag and drop", "clickToUpload": "Click to upload or drag and drop",
"fileRequirements": "JPEG, PNG or WebP (max 50MB per file, {{limit}} files per upload)", "fileRequirements": "JPEG, PNG or WebP (max 50MB per file, {{limit}} files per upload)",
"fileRequirementsMedia": "JPEG, PNG or WebP images, plus MP4/MOV/WEBM videos (max 50MB per file, {{limit}} files per upload)",
"unsupportedFiles": "Some files were skipped because the format is not supported (use JPEG/PNG/WebP/MP4/MOV/WEBM).",
"selectedFiles": "Selected files", "selectedFiles": "Selected files",
"uploading": "Uploading...", "uploading": "Uploading...",
"uploadComplete": "Upload complete!", "uploadComplete": "Upload complete!",
"uploadFailed": "Upload failed", "uploadFailed": "Upload failed",
"someFilesFailed": "Some files failed to upload", "someFilesFailed": "Some files failed to upload",
"uploadPhotos": "Upload Photos", "uploadPhotos": "Upload Photos",
"uploadMedia": "Upload Photos & Videos",
"importExternal": "Import from External Folder", "importExternal": "Import from External Folder",
"externalImportInfo": "All pictures from the selected folder will be imported.", "externalImportInfo": "All pictures from the selected folder will be imported.",
"selectExternalFolder": "Select external folder under /external-media", "selectExternalFolder": "Select external folder under /external-media",
@@ -64,7 +70,9 @@
"tooManyFiles": "Maximum {{limit}} files can be uploaded at once", "tooManyFiles": "Maximum {{limit}} files can be uploaded at once",
"limitInfo": "{{selected}} of {{limit}} files selected ({{remaining}} remaining)", "limitInfo": "{{selected}} of {{limit}} files selected ({{remaining}} remaining)",
"limitReached": "Upload limit reached ({{limit}} files per batch)", "limitReached": "Upload limit reached ({{limit}} files per batch)",
"uploadingChunks": "Uploading {{count}} files in {{total}} batches..." "uploadingChunks": "Uploading {{count}} files in {{total}} batches...",
"mediaCategory": "Media category",
"uploadAction": "Upload {{count}} files"
}, },
"navigation": { "navigation": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -178,6 +186,13 @@
"selectAll": "Select All", "selectAll": "Select All",
"deselectAll": "Deselect All", "deselectAll": "Deselect All",
"downloadSelected": "Download {{count}} Selected", "downloadSelected": "Download {{count}} Selected",
"deleteSelected": "Delete Selected",
"photosCount": "{{count}} photo",
"photosCount_plural": "{{count}} photos",
"searchByFilename": "Search by filename...",
"uncategorized": "Uncategorized",
"sortAscending": "Sort ascending",
"sortDescending": "Sort descending",
"remaining": "remaining", "remaining": "remaining",
"selectPhotosHint": "Tip: Use Ctrl+Click (Cmd+Click on Mac) to quickly select multiple photos", "selectPhotosHint": "Tip: Use Ctrl+Click (Cmd+Click on Mac) to quickly select multiple photos",
"filters": "Filters", "filters": "Filters",
@@ -186,7 +201,12 @@
"toggleMenu": "Toggle menu", "toggleMenu": "Toggle menu",
"allCategories": "All Categories", "allCategories": "All Categories",
"categories": "Categories", "categories": "Categories",
"mediaType": "Media",
"allMedia": "All media",
"photosOnly": "Photos",
"videosOnly": "Videos",
"download": "Download", "download": "Download",
"noMedia": "No media uploaded yet",
"searchPlaceholder": "Search photos...", "searchPlaceholder": "Search photos...",
"sortBy": "Sort By", "sortBy": "Sort By",
"sortByDate": "Sort by Date", "sortByDate": "Sort by Date",
+27 -1
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -208,6 +208,26 @@ export const EventDetailsPage: React.FC = () => {
enabled: !!id && (activeTab === 'photos' || isEditing), enabled: !!id && (activeTab === 'photos' || isEditing),
}); });
const mediaTypes = useMemo(() => {
const types = new Set<'photo' | 'video'>();
photos.forEach((p: any) => {
const mediaType = (p.media_type as 'photo' | 'video' | undefined)
|| ((p.mime_type && String(p.mime_type).startsWith('video/')) || p.type === 'video' ? 'video' : 'photo');
if (mediaType === 'video' || mediaType === 'photo') {
types.add(mediaType);
}
});
return types;
}, [photos]);
const showMediaFilter = mediaTypes.has('photo') && mediaTypes.has('video');
useEffect(() => {
if (!showMediaFilter && photoFilters.media_type) {
setPhotoFilters(prev => ({ ...prev, media_type: undefined }));
}
}, [showMediaFilter, photoFilters.media_type]);
// Fetch categories for the event // Fetch categories for the event
const { data: categories = [] } = useQuery({ const { data: categories = [] } = useQuery({
queryKey: ['admin-event-categories', id], queryKey: ['admin-event-categories', id],
@@ -1237,6 +1257,12 @@ export const EventDetailsPage: React.FC = () => {
onCategoryChange={(categoryId) => setPhotoFilters(prev => ({ ...prev, category_id: categoryId }))} onCategoryChange={(categoryId) => setPhotoFilters(prev => ({ ...prev, category_id: categoryId }))}
onSearchChange={(search) => setPhotoFilters(prev => ({ ...prev, search }))} onSearchChange={(search) => setPhotoFilters(prev => ({ ...prev, search }))}
onSortChange={(sort, order) => setPhotoFilters(prev => ({ ...prev, sort, order }))} onSortChange={(sort, order) => setPhotoFilters(prev => ({ ...prev, sort, order }))}
mediaType={photoFilters.media_type || 'all'}
onMediaTypeChange={(mediaType) => setPhotoFilters(prev => ({
...prev,
media_type: mediaType === 'all' ? undefined : mediaType
}))}
showMediaFilter={showMediaFilter}
/> />
{/* Actions Bar */} {/* Actions Bar */}
+106 -2
View File
@@ -7,11 +7,13 @@ export interface AdminPhoto {
url: string; url: string;
thumbnail_url: string | null; thumbnail_url: string | null;
type: string; type: string;
category_id: number | null; category_id: number | string | null;
category_name: string | null; category_name: string | null;
category_slug: string | null; category_slug: string | null;
size: number; size: number;
uploaded_at: string; uploaded_at: string;
media_type?: 'photo' | 'video';
mime_type?: string | null;
view_count?: number; view_count?: number;
download_count?: number; download_count?: number;
// Feedback fields // Feedback fields
@@ -23,8 +25,9 @@ export interface AdminPhoto {
} }
export interface PhotoFilters { export interface PhotoFilters {
category_id?: number | null; category_id?: number | string | null;
type?: string; type?: string;
media_type?: 'photo' | 'video';
search?: string; search?: string;
sort?: 'date' | 'name' | 'size' | 'rating'; sort?: 'date' | 'name' | 'size' | 'rating';
order?: 'asc' | 'desc'; order?: 'asc' | 'desc';
@@ -39,6 +42,7 @@ class PhotosService {
params.append('category_id', filters.category_id?.toString() || ''); params.append('category_id', filters.category_id?.toString() || '');
} }
if (filters.type) params.append('type', filters.type); if (filters.type) params.append('type', filters.type);
if (filters.media_type) params.append('media_type', filters.media_type);
if (filters.search) params.append('search', filters.search); if (filters.search) params.append('search', filters.search);
if (filters.sort) params.append('sort', filters.sort); if (filters.sort) params.append('sort', filters.sort);
if (filters.order) params.append('order', filters.order); if (filters.order) params.append('order', filters.order);
@@ -95,6 +99,106 @@ class PhotosService {
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; 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(); export const photosService = new PhotosService();
+13 -3
View File
@@ -55,12 +55,22 @@ export interface Photo {
secure_url_template?: string; secure_url_template?: string;
download_url_template?: string; download_url_template?: string;
requires_token?: boolean; requires_token?: boolean;
type: 'collage' | 'individual'; type: 'collage' | 'individual' | 'video';
category_id?: number; media_type?: 'photo' | 'video';
mime_type?: string;
category_id?: number | string | null;
category_name?: string; category_name?: string;
category_slug?: string; category_slug?: string;
size: number; size: number;
uploaded_at: string; 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 // Feedback fields
has_feedback?: boolean; has_feedback?: boolean;
average_rating?: number; average_rating?: number;
@@ -71,7 +81,7 @@ export interface Photo {
} }
export interface PhotoCategory { export interface PhotoCategory {
id: number; id: number | string;
name: string; name: string;
slug: string; slug: string;
is_global: boolean; is_global: boolean;
+5 -22
View File
@@ -823,8 +823,8 @@ EOF
create_systemd_services() { create_systemd_services() {
log_step "Creating 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 cat > /etc/systemd/system/picpeak-backend.service <<EOF
[Unit] [Unit]
Description=PicPeak Backend Service Description=PicPeak Backend Service
@@ -844,27 +844,10 @@ StandardError=append:$NATIVE_APP_DIR/logs/backend-error.log
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
# Workers service
cat > /etc/systemd/system/picpeak-workers.service <<EOF
[Unit]
Description=PicPeak Background Workers
After=network.target picpeak-backend.service
[Service] # Note: Workers (fileWatcher, expirationChecker, emailProcessor) are now started
Type=simple # automatically by server.js, so a separate workers service is no longer needed.
User=$NATIVE_APP_USER # Legacy picpeak-workers.service will be cleaned up during installation.
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
} }
setup_caddy() { setup_caddy() {
+1
View File
@@ -0,0 +1 @@
404: Not Found
Binary file not shown.