diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 78baf5bf..69e82f12 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.5.0" + ".": "2.6.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a1faadf..a95c889b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **security:** resolve all npm audit vulnerabilities ([4272618](https://github.com/the-luap/picpeak/commit/4272618b3f7fcb06aaca14fb724a6a7733251f24)) * **security:** resolve Docker image CVEs for code scanning alerts ([cbecb93](https://github.com/the-luap/picpeak/commit/cbecb9323cf4b80c800326de14f6df73f60147c1)) +## [2.6.0](https://github.com/the-luap/picpeak/compare/v2.5.1...v2.6.0) (2026-03-11) + + +### Features + +* add configurable upload batch size for reverse proxy compatibility ([#208](https://github.com/the-luap/picpeak/issues/208)) ([02a46e0](https://github.com/the-luap/picpeak/commit/02a46e083d68cfdb355b5a4fe4a8da7d667050b9)) +* configurable upload batch size for reverse proxy compatibility ([4243363](https://github.com/the-luap/picpeak/commit/424336340bef8e1629490ade154f0ceebb2a71e1)) + + +### Bug Fixes + +* video upload media type, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([fc75bcd](https://github.com/the-luap/picpeak/commit/fc75bcdfc38673d6e4dd1cd943cfb4638d3a306c)) +* video upload, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([a0bb080](https://github.com/the-luap/picpeak/commit/a0bb0805868e742f323b64312c3c5ef8ec408f68)) + +## [2.5.1](https://github.com/the-luap/picpeak/compare/v2.5.0...v2.5.1) (2026-02-22) + + +### Bug Fixes + +* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([33af088](https://github.com/the-luap/picpeak/commit/33af0885607799e0071e2e74a582c7eb396c9b83)) +* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([33483cf](https://github.com/the-luap/picpeak/commit/33483cf32dfae57f8da51c0765353792239135f9)) + ## [2.5.0](https://github.com/the-luap/picpeak/compare/v2.4.0...v2.5.0) (2026-02-21) diff --git a/backend/migrations/core/072_add_max_upload_batch_size.js b/backend/migrations/core/072_add_max_upload_batch_size.js deleted file mode 100644 index 4f1cb45c..00000000 --- a/backend/migrations/core/072_add_max_upload_batch_size.js +++ /dev/null @@ -1,20 +0,0 @@ -exports.up = async function(knex) { - const exists = await knex('app_settings') - .where({ setting_key: 'general_max_upload_batch_size_mb' }) - .first(); - - if (!exists) { - await knex('app_settings').insert({ - setting_key: 'general_max_upload_batch_size_mb', - setting_value: JSON.stringify(95), - setting_type: 'general', - updated_at: new Date() - }); - } -}; - -exports.down = async function(knex) { - await knex('app_settings') - .where({ setting_key: 'general_max_upload_batch_size_mb' }) - .del(); -}; diff --git a/backend/server.js b/backend/server.js index dbd9a8c7..839e4351 100644 --- a/backend/server.js +++ b/backend/server.js @@ -495,6 +495,7 @@ app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup' app.use('/api/admin/feedback', require('./src/routes/adminFeedback')); app.use('/api/admin/image-security', require('./src/routes/adminImageSecurity')); app.use('/api/admin/thumbnails', require('./src/routes/adminThumbnails')); +app.use('/api/admin/photos', require('./src/routes/adminPhotoDimensions')); app.use('/api/admin/photos', require('./src/routes/adminPhotos')); app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport')); app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates')); diff --git a/backend/src/routes/adminPhotoDimensions.js b/backend/src/routes/adminPhotoDimensions.js new file mode 100644 index 00000000..74c7be9f --- /dev/null +++ b/backend/src/routes/adminPhotoDimensions.js @@ -0,0 +1,151 @@ +const express = require('express'); +const router = express.Router(); +const { db } = require('../database/db'); +const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); +const path = require('path'); +const fs = require('fs').promises; +const logger = require('../utils/logger'); + +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + +// Module-level progress state +let repairProgress = { + isRunning: false, + lastResult: null +}; + +// Repair photo dimensions (background job) +router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), async (req, res) => { + try { + if (repairProgress.isRunning) { + return res.status(409).json({ error: 'Repair is already running' }); + } + + const photos = await db('photos') + .where(function () { + this.whereNull('width').orWhereNull('height'); + }) + .where(function () { + this.where('media_type', '!=', 'video').orWhereNull('media_type'); + }) + .select('id', 'path', 'filename'); + + if (photos.length === 0) { + return res.json({ message: 'No photos need dimension repair', count: 0 }); + } + + // Return immediately + res.json({ + message: `Started repairing dimensions for ${photos.length} photos`, + count: photos.length + }); + + // Process in background + repairProgress.isRunning = true; + repairProgress.lastResult = null; + + setImmediate(async () => { + let sharp; + try { + sharp = require('sharp'); + } catch (err) { + logger.error('Sharp not available for dimension repair:', err.message); + repairProgress.isRunning = false; + repairProgress.lastResult = { success: 0, failed: 0, error: 'Sharp not available' }; + return; + } + + let successCount = 0; + let errorCount = 0; + + for (const photo of photos) { + try { + if (!photo.path) { + logger.warn(`Photo ${photo.id} has no path, skipping dimension repair`); + errorCount++; + continue; + } + + const storagePath = getStoragePath(); + const fullPath = path.join(storagePath, 'events/active', photo.path); + + try { + await fs.access(fullPath); + } catch (err) { + logger.warn(`File not found for photo ${photo.id}: ${fullPath}`); + errorCount++; + continue; + } + + const metadata = await sharp(fullPath).metadata(); + + if (metadata.width && metadata.height) { + await db('photos') + .where({ id: photo.id }) + .update({ + width: metadata.width, + height: metadata.height, + updated_at: db.fn.now() + }); + successCount++; + + if (successCount % 50 === 0) { + logger.info(`Dimension repair progress: ${successCount} updated...`); + } + } else { + logger.warn(`Could not extract dimensions for photo ${photo.id}`); + errorCount++; + } + } catch (error) { + logger.error(`Error repairing dimensions for photo ${photo.id}:`, error); + errorCount++; + } + } + + repairProgress.isRunning = false; + repairProgress.lastResult = { success: successCount, failed: errorCount }; + logger.info(`Dimension repair complete: ${successCount} success, ${errorCount} errors`); + }); + } catch (error) { + logger.error('Error starting dimension repair:', error); + res.status(500).json({ error: 'Failed to start dimension repair' }); + } +}); + +// Get dimension repair status +router.get('/repair-dimensions/status', adminAuth, requirePermission('photos.view'), async (req, res) => { + try { + const totalPhotos = await db('photos') + .where(function () { + this.where('media_type', '!=', 'video').orWhereNull('media_type'); + }) + .count('id as count') + .first(); + + const withDimensions = await db('photos') + .where(function () { + this.where('media_type', '!=', 'video').orWhereNull('media_type'); + }) + .whereNotNull('width') + .whereNotNull('height') + .count('id as count') + .first(); + + const total = Number(totalPhotos.count); + const withDims = Number(withDimensions.count); + + res.json({ + total, + withDimensions: withDims, + withoutDimensions: total - withDims, + isRunning: repairProgress.isRunning, + lastResult: repairProgress.lastResult + }); + } catch (error) { + logger.error('Error fetching dimension repair status:', error); + res.status(500).json({ error: 'Failed to fetch dimension repair status' }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index 2a33f399..a2c9dfff 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -6,6 +6,7 @@ const { db, logActivity } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); const { generateThumbnail, ensureThumbnail, extractCaptureDate } = require('../services/imageProcessor'); +const { processUploadedVideo, isVideoMimeType } = require('../services/videoProcessor'); const { generatePhotoFilename } = require('../utils/filenameSanitizer'); const { escapeLikePattern } = require('../utils/sqlSecurity'); const { validateUploadedFiles } = require('../middleware/uploadValidation'); @@ -271,6 +272,10 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u console.log(`Could not extract EXIF date for ${file.originalname}`); } + // Determine media type + const isVideo = isVideoMimeType(file.mimetype); + const mediaType = isVideo ? 'video' : 'image'; + // Prepare photo data for batch insert const photoData = { event_id: parseInt(eventId), @@ -281,7 +286,9 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u type: photoType, category_id: parsedCategoryId, // Save the selected category size_bytes: tempStats.size, // Use actual file size from stat - captured_at: capturedAt // EXIF capture date (if available) + captured_at: capturedAt, // EXIF capture date (if available) + media_type: mediaType, + mime_type: file.mimetype }; batchPhotos.push(photoData); @@ -325,26 +332,65 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u throw new Error(`File size mismatch after move: expected ${operation.photoData.size_bytes}, got ${finalStats.size}`); } - // Generate thumbnail with final path + // Generate thumbnail and extract metadata + const photoId = insertedIds[idx]?.id || insertedIds[idx]; + const isVideoFile = isVideoMimeType(operation.photoData.mime_type); let thumbnailPath = null; - try { - thumbnailPath = await generateThumbnail(operation.finalPath); - // Update the database with thumbnail path - if (thumbnailPath && insertedIds[idx]) { - const photoId = insertedIds[idx]?.id || insertedIds[idx]; - await db('photos') - .where({ id: photoId }) - .update({ thumbnail_path: thumbnailPath }); + try { + if (isVideoFile) { + // 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_${operation.filename.replace(/\.[^.]+$/, '.jpg')}`); + + const result = await processUploadedVideo(operation.finalPath, videoThumbnailPath); + thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath); + + if (photoId && result.metadata) { + await db('photos') + .where({ id: photoId }) + .update({ + thumbnail_path: thumbnailPath, + duration: result.metadata.duration, + video_codec: result.metadata.videoCodec, + audio_codec: result.metadata.audioCodec, + width: result.metadata.width, + height: result.metadata.height + }); + } + } else { + thumbnailPath = await generateThumbnail(operation.finalPath); + + // Update the database with thumbnail path and image dimensions + if (photoId) { + const updateData = {}; + if (thumbnailPath) updateData.thumbnail_path = thumbnailPath; + + try { + const sharp = require('sharp'); + const metadata = await sharp(operation.finalPath).metadata(); + if (metadata.width && metadata.height) { + updateData.width = metadata.width; + updateData.height = metadata.height; + } + } catch (metadataError) { + console.warn(`Could not extract image dimensions for ${operation.filename}:`, metadataError.message); + } + + if (Object.keys(updateData).length > 0) { + await db('photos') + .where({ id: photoId }) + .update(updateData); + } + } } } catch (thumbError) { - console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message); + console.error(`Thumbnail/metadata processing failed for ${operation.filename}:`, thumbError.message); } - // Queue watermark generation in background (non-blocking) - // This pre-generates watermarked versions for fast serving in lightbox - if (insertedIds[idx]) { - const photoId = insertedIds[idx]?.id || insertedIds[idx]; + // Queue watermark generation in background (non-blocking, images only) + if (photoId && !isVideoFile) { watermarkGeneratorService.generateForPhoto(photoId) .catch(err => console.warn(`Watermark generation queued failed for photo ${photoId}:`, err.message)); } @@ -809,6 +855,11 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn category_id: photo.category_id || photo.type, category_name: photo.pc_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'), category_slug: photo.pc_slug || photo.type, + media_type: photo.media_type || 'image', + mime_type: photo.mime_type || null, + width: photo.width || null, + height: photo.height || null, + duration: photo.duration || null, size: photo.size_bytes, uploaded_at: photo.uploaded_at, // Feedback data diff --git a/frontend/src/components/admin/PhotoUpload.tsx b/frontend/src/components/admin/PhotoUpload.tsx index 4edbe190..fd0789b1 100644 --- a/frontend/src/components/admin/PhotoUpload.tsx +++ b/frontend/src/components/admin/PhotoUpload.tsx @@ -109,8 +109,7 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl // For large uploads, chunk the files by both count AND size to prevent memory/network issues const MAX_FILES_PER_CHUNK = Math.max(1, Math.min(50, maxFilesPerUpload)); // Max 50 files per chunk - const maxBatchSizeMb = Number(settings?.general_max_upload_batch_size_mb) || 95; - const MAX_BYTES_PER_CHUNK = maxBatchSizeMb * 1024 * 1024; + const MAX_BYTES_PER_CHUNK = 500 * 1024 * 1024; // Max 500MB per chunk (nginx limit is 1GB) const chunks: File[][] = []; let currentChunk: File[] = []; diff --git a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx index 88032a7e..db1f1be4 100644 --- a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx +++ b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx @@ -215,6 +215,8 @@ export const PhotoGridWithLayouts: React.FC = ({ useCanvasRendering, isSelectionMode, onPhotoSelect: handlePhotoSelect, + onSelectAll: selectAll, + onDeselectAll: deselectAll, eventName, eventLogo, eventDate, diff --git a/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx b/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx index 972dff7a..913cc5c9 100644 --- a/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx @@ -13,6 +13,8 @@ export interface BaseGalleryLayoutProps { selectedPhotos?: Set; isSelectionMode?: boolean; onPhotoSelect?: (photoId: number) => void; + onSelectAll?: () => void; + onDeselectAll?: () => void; eventName?: string; eventLogo?: string | null; eventDate?: string | null; diff --git a/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx b/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx index eb214d65..188a3077 100644 --- a/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx +++ b/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx @@ -165,6 +165,8 @@ export const GalleryPremiumLayout: React.FC = ({ selectedPhotos = new Set(), isSelectionMode = false, onPhotoSelect, + onSelectAll, + onDeselectAll, eventName, eventDate, allowDownloads = true, @@ -287,17 +289,11 @@ export const GalleryPremiumLayout: React.FC = ({ const handleSelectAll = useCallback(() => { if (selectedPhotos.size === filteredPhotos.length) { - // Deselect all - filteredPhotos.forEach(p => onPhotoSelect?.(p.id)); + onDeselectAll?.(); } else { - // Select all - filteredPhotos.forEach(p => { - if (!selectedPhotos.has(p.id)) { - onPhotoSelect?.(p.id); - } - }); + onSelectAll?.(); } - }, [selectedPhotos, filteredPhotos, onPhotoSelect]); + }, [selectedPhotos, filteredPhotos, onSelectAll, onDeselectAll]); const handleDownloadSelected = useCallback(async () => { if (selectedPhotos.size === 0) return; diff --git a/frontend/src/features/settings/hooks/useSettingsState.ts b/frontend/src/features/settings/hooks/useSettingsState.ts index 1ad7c671..fabdf384 100644 --- a/frontend/src/features/settings/hooks/useSettingsState.ts +++ b/frontend/src/features/settings/hooks/useSettingsState.ts @@ -16,7 +16,6 @@ export interface GeneralSettings { max_file_size_mb: number; max_files_per_upload: number; allowed_file_types: string; - max_upload_batch_size_mb: number; enable_analytics: boolean; enable_registration: boolean; maintenance_mode: boolean; @@ -88,7 +87,6 @@ export function useSettingsState() { max_file_size_mb: 50, max_files_per_upload: 500, allowed_file_types: 'jpg,jpeg,png,gif,webp', - max_upload_batch_size_mb: 95, enable_analytics: true, enable_registration: false, maintenance_mode: false, @@ -171,7 +169,6 @@ export function useSettingsState() { Math.max(1, toNumber(settings.general_max_files_per_upload, 500)) ), allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp', - max_upload_batch_size_mb: toNumber(settings.general_max_upload_batch_size_mb, 95), enable_analytics: toBoolean(settings.general_enable_analytics, true), enable_registration: toBoolean(settings.general_enable_registration, false), maintenance_mode: toBoolean(settings.general_maintenance_mode, false), diff --git a/frontend/src/features/settings/tabs/GeneralTab.tsx b/frontend/src/features/settings/tabs/GeneralTab.tsx index 6c4ae1fd..964bbdbe 100644 --- a/frontend/src/features/settings/tabs/GeneralTab.tsx +++ b/frontend/src/features/settings/tabs/GeneralTab.tsx @@ -161,28 +161,6 @@ export const GeneralTab: React.FC = ({ {t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })}

-
- - { - const parsed = parseInt(e.target.value, 10); - setGeneralSettings(prev => ({ - ...prev, - max_upload_batch_size_mb: Number.isFinite(parsed) - ? Math.max(1, parsed) - : prev.max_upload_batch_size_mb - })); - }} - min="1" - /> -

- {t('settings.general.maxUploadBatchSizeHelp')} -

-
diff --git a/frontend/src/features/settings/tabs/StatusTab.tsx b/frontend/src/features/settings/tabs/StatusTab.tsx index 8c633f96..a72fe826 100644 --- a/frontend/src/features/settings/tabs/StatusTab.tsx +++ b/frontend/src/features/settings/tabs/StatusTab.tsx @@ -7,9 +7,12 @@ import { Clock, HardDrive, Activity, + Ruler, } from 'lucide-react'; import { Button, Card, Input } from '../../../components/common'; import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { api } from '../../../config/api'; import { settingsService } from '../../../services/settings.service'; import { useStatusTab } from '../hooks/useStatusTab'; import { UpdateNotificationSettings } from '../components/UpdateNotificationSettings'; @@ -53,6 +56,27 @@ export const StatusTab: React.FC = ({ }) => { const { t } = useTranslation(); const { storageInfo, systemStatus } = useStatusTab(isActive); + const queryClient = useQueryClient(); + + const { data: dimensionStatus } = useQuery({ + queryKey: ['photo-dimension-status'], + queryFn: async () => { + const res = await api.get('/admin/photos/repair-dimensions/status'); + return res.data; + }, + enabled: isActive, + refetchInterval: 10000, + }); + + const repairMutation = useMutation({ + mutationFn: async () => { + const res = await api.post('/admin/photos/repair-dimensions'); + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['photo-dimension-status'] }); + }, + }); // Sync soft limit from storage info useEffect(() => { @@ -528,6 +552,61 @@ export const StatusTab: React.FC = ({ )} + {/* Photo Dimensions */} + {dimensionStatus && ( + +

+ + {t('settings.photoDimensions.title')} +

+ +

+ {t('settings.photoDimensions.description')} +

+ +
+
+

{dimensionStatus.total}

+

{t('settings.photoDimensions.totalPhotos')}

+
+
+

{dimensionStatus.withDimensions}

+

{t('settings.photoDimensions.withDimensions')}

+
+
0 ? 'bg-amber-50 dark:bg-amber-900/30' : 'bg-neutral-50 dark:bg-neutral-800'}`}> +

0 ? 'text-amber-600 dark:text-amber-400' : 'text-neutral-900 dark:text-neutral-100'}`}>{dimensionStatus.withoutDimensions}

+

{t('settings.photoDimensions.missingDimensions')}

+
+
+ + {dimensionStatus.lastResult && ( +

+ {t('settings.photoDimensions.resultSuccess', { + success: dimensionStatus.lastResult.success, + failed: dimensionStatus.lastResult.failed, + })} +

+ )} + +
+ +
+
+ )} + {/* Update Notification Settings */} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 7fddcc23..903ac044 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1012,8 +1012,6 @@ "maxFileSizeHelp": "Maximale Größe pro hochgeladenem Foto", "maxFilesPerUpload": "Max. Dateien pro Upload", "maxFilesPerUploadHelp": "Maximale Anzahl an Fotos pro Upload-Vorgang (1-{{max}}).", - "maxUploadBatchSize": "Max. Upload-Paketgröße (MB)", - "maxUploadBatchSizeHelp": "Maximale Größe pro Upload-Anfrage. Reduzieren Sie diesen Wert bei Nutzung eines Reverse-Proxys mit Größenbeschränkung (z.B. Cloudflare: 100MB).", "allowedFileTypes": "Erlaubte Dateitypen", "allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen", "featureToggles": "Funktionsschalter", @@ -1281,6 +1279,19 @@ "failed": "Fehlgeschlagen", "lastUpdate": "Letzte Aktualisierung" }, + "photoDimensions": { + "title": "Foto-Abmessungen", + "totalPhotos": "Fotos gesamt", + "withDimensions": "Mit Abmessungen", + "missingDimensions": "Fehlende Abmessungen", + "repairButton": "Abmessungen reparieren", + "repairing": "Repariere...", + "alreadyRunning": "Reparatur läuft bereits", + "started": "Reparatur von {{count}} Fotos gestartet", + "noneToRepair": "Alle Fotos haben bereits Abmessungen", + "resultSuccess": "Letzte Reparatur: {{success}} aktualisiert, {{failed}} fehlgeschlagen", + "description": "Fehlende Breite/Höhe für Fotos ergänzen, die vor der Dimensionsverfolgung hochgeladen wurden. Erforderlich für Masonry- und Mosaik-Layouts." + }, "updateNotifications": { "title": "Update-Benachrichtigungen", "description": "Erhalten Sie E-Mail-Benachrichtigungen, wenn neue Versionen von PicPeak verfügbar sind.", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 8bcb9f91..d71bff39 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -637,8 +637,6 @@ "maxFileSizeHelp": "Maximum size per uploaded photo", "maxFilesPerUpload": "Max Files per Upload", "maxFilesPerUploadHelp": "Maximum number of photos allowed in a single upload batch (1-{{max}}).", - "maxUploadBatchSize": "Max Upload Batch Size (MB)", - "maxUploadBatchSizeHelp": "Maximum size per upload request. Lower this if behind a reverse proxy with request size limits (e.g. Cloudflare: 100MB).", "allowedFileTypes": "Allowed File Types", "allowedFileTypesHelp": "Comma-separated list of file extensions", "featureToggles": "Feature Toggles", @@ -801,6 +799,19 @@ "failed": "Failed", "lastUpdate": "Last update" }, + "photoDimensions": { + "title": "Photo Dimensions", + "totalPhotos": "Total Photos", + "withDimensions": "With Dimensions", + "missingDimensions": "Missing Dimensions", + "repairButton": "Repair Dimensions", + "repairing": "Repairing...", + "alreadyRunning": "Repair is already running", + "started": "Started repairing {{count}} photos", + "noneToRepair": "All photos already have dimensions", + "resultSuccess": "Last repair: {{success}} updated, {{failed}} failed", + "description": "Backfill missing width/height for photos uploaded before dimension tracking was added. Required for Masonry and Mosaic layouts." + }, "updateNotifications": { "title": "Update Notifications", "description": "Receive email notifications when new versions of PicPeak are available.", diff --git a/test-assets/test-video.mp4 b/test-assets/test-video.mp4 new file mode 100644 index 00000000..951bbb52 Binary files /dev/null and b/test-assets/test-video.mp4 differ diff --git a/tests/e2e/admin-video-upload.spec.ts b/tests/e2e/admin-video-upload.spec.ts new file mode 100644 index 00000000..dbd888c1 --- /dev/null +++ b/tests/e2e/admin-video-upload.spec.ts @@ -0,0 +1,115 @@ +import { test, expect, Page } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; + +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234'; +const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!'; + +async function getAdminToken(page: Page): Promise { + const res = await page.request.post('/api/auth/admin/login', { + data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD }, + }); + expect(res.ok()).toBeTruthy(); + const { token } = await res.json(); + return token; +} + +test.describe('Admin video upload (#203)', () => { + test('Videos uploaded via admin have correct media_type and mime_type', async ({ page }, testInfo) => { + if (testInfo.project.name === 'mobile-chrome') { + test.skip(); + } + + const token = await getAdminToken(page); + + // Create event + const eventName = `PW Video ${Date.now()}`; + const eventDate = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10); + + const createRes = await page.request.post('/api/admin/events', { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + data: { + event_type: 'wedding', + event_name: eventName, + event_date: eventDate, + customer_name: 'Playwright Host', + customer_email: 'host@example.com', + admin_email: ADMIN_EMAIL, + password: GALLERY_PASSWORD, + expiration_days: 90, + allow_downloads: true, + }, + }); + expect(createRes.ok()).toBeTruthy(); + const event = await createRes.json(); + expect(event.id).toBeTruthy(); + + // Upload a video via admin endpoint + const videoPath = path.join(process.cwd(), 'test-assets', 'test-video.mp4'); + expect(fs.existsSync(videoPath)).toBeTruthy(); + const buffer = fs.readFileSync(videoPath); + + const uploadRes = await page.request.post(`/api/admin/events/${event.id}/upload`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + photos: { name: 'test-video.mp4', mimeType: 'video/mp4', buffer }, + category_id: 'individual', + }, + }); + expect(uploadRes.ok()).toBeTruthy(); + const uploadBody = await uploadRes.json(); + expect(uploadBody.successCount).toBeGreaterThanOrEqual(1); + + // Wait for background processing + await page.waitForTimeout(5000); + + // Fetch photos for this event via admin API + const photosRes = await page.request.get(`/api/admin/photos/${event.id}/photos`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(photosRes.ok()).toBeTruthy(); + const photosBody = await photosRes.json(); + const photos = photosBody.photos || photosBody; + expect(photos.length).toBeGreaterThanOrEqual(1); + + // Find our video — the critical fix: media_type and mime_type must be set + const video = photos.find((p: any) => p.media_type === 'video'); + expect(video).toBeTruthy(); + expect(video.media_type).toBe('video'); + expect(video.mime_type).toBe('video/mp4'); + // width/height/duration depend on ffprobe being available in the environment; + // if present they should be positive, but we don't fail on missing ffprobe + if (video.width !== null) { + expect(video.width).toBeGreaterThan(0); + expect(video.height).toBeGreaterThan(0); + } + + // Also upload an image and verify it gets media_type = 'image' with dimensions + const imgBuffer = fs.readFileSync(path.join(process.cwd(), 'test-assets', 'img1.png')); + const imgUploadRes = await page.request.post(`/api/admin/events/${event.id}/upload`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + photos: { name: 'img1.png', mimeType: 'image/png', buffer: imgBuffer }, + category_id: 'individual', + }, + }); + expect(imgUploadRes.ok()).toBeTruthy(); + + await page.waitForTimeout(2000); + + // Re-fetch and verify image has correct media_type and dimensions + const photosRes2 = await page.request.get(`/api/admin/photos/${event.id}/photos`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(photosRes2.ok()).toBeTruthy(); + const photosBody2 = await photosRes2.json(); + const photos2 = photosBody2.photos || photosBody2; + + const image = photos2.find((p: any) => p.media_type === 'image'); + expect(image).toBeTruthy(); + expect(image.media_type).toBe('image'); + expect(image.width).toBeGreaterThan(0); + expect(image.height).toBeGreaterThan(0); + }); +}); diff --git a/tests/e2e/gallery-premium-select-all.spec.ts b/tests/e2e/gallery-premium-select-all.spec.ts new file mode 100644 index 00000000..7c65a305 --- /dev/null +++ b/tests/e2e/gallery-premium-select-all.spec.ts @@ -0,0 +1,114 @@ +import { test, expect, Page } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; + +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234'; +const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!'; + +async function getAdminToken(page: Page): Promise { + const res = await page.request.post('/api/auth/admin/login', { + data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD }, + }); + expect(res.ok()).toBeTruthy(); + const { token } = await res.json(); + return token; +} + +async function createGalleryPremiumEvent(page: Page) { + const token = await getAdminToken(page); + + const eventName = `PW SelectAll ${Date.now()}`; + const eventDate = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10); + + const createRes = await page.request.post('/api/admin/events', { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + data: { + event_type: 'wedding', + event_name: eventName, + event_date: eventDate, + customer_name: 'Playwright Host', + customer_email: 'host@example.com', + admin_email: ADMIN_EMAIL, + password: GALLERY_PASSWORD, + expiration_days: 90, + allow_downloads: true, + gallery_theme: 'gallery-premium', + }, + }); + expect(createRes.ok()).toBeTruthy(); + const event = await createRes.json(); + + // Upload 3 images so we can verify select-all picks all of them + const imagePaths = ['img1.png', 'img2.png', 'img1.png'].map((f) => + path.join(process.cwd(), 'test-assets', f) + ); + + for (const imagePath of imagePaths) { + const buffer = fs.readFileSync(imagePath); + const uploadRes = await page.request.post(`/api/admin/events/${event.id}/upload`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: { + photos: { name: path.basename(imagePath), mimeType: 'image/png', buffer }, + category_id: 'individual', + }, + }); + expect(uploadRes.ok()).toBeTruthy(); + } + + return { shareLink: event.share_link, slug: event.slug, token }; +} + +test.describe('Gallery-Premium Select All (#220)', () => { + test('Select All button selects all photos in one click', async ({ page, browserName }, testInfo) => { + if (testInfo.project.name === 'mobile-chrome') { + test.skip(); + } + + const { shareLink } = await createGalleryPremiumEvent(page); + + // Navigate to gallery + await page.goto(shareLink); + await page.waitForLoadState('domcontentloaded'); + + // Handle password if needed + const passwordField = page.getByPlaceholder(/gallery password/i).first(); + if (await passwordField.count()) { + await passwordField.fill(GALLERY_PASSWORD); + try { + await page.getByRole('button', { name: /View Gallery/i }).click({ timeout: 5000 }); + } catch { + // Token may auto-auth + } + await page.waitForLoadState('networkidle'); + } + + // Wait for photos to render + await page.waitForTimeout(3000); + + // Look for download button to enter selection mode + const downloadBtn = page.getByRole('button', { name: /Download/i }).first(); + await expect(downloadBtn).toBeVisible({ timeout: 15000 }); + await downloadBtn.click(); + + // Now look for "Select All" button + const selectAllBtn = page.getByRole('button', { name: /Select All/i }).first(); + await expect(selectAllBtn).toBeVisible({ timeout: 10000 }); + + // Click Select All once + await selectAllBtn.click(); + await page.waitForTimeout(500); + + // Verify all photos are selected - check for checkmarks or selected state + // The selection count or "Deselect All" text should appear + const deselectAllBtn = page.getByRole('button', { name: /Deselect All/i }).first(); + await expect(deselectAllBtn).toBeVisible({ timeout: 5000 }); + + // Verify: clicking Deselect All should clear selection + await deselectAllBtn.click(); + await page.waitForTimeout(500); + + // Select All should be visible again + await expect(selectAllBtn).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/tests/e2e/photo-dimensions-repair.spec.ts b/tests/e2e/photo-dimensions-repair.spec.ts new file mode 100644 index 00000000..ed4652ac --- /dev/null +++ b/tests/e2e/photo-dimensions-repair.spec.ts @@ -0,0 +1,64 @@ +import { test, expect, Page } from '@playwright/test'; + +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234'; + +async function getAdminToken(page: Page): Promise { + const res = await page.request.post('/api/auth/admin/login', { + data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD }, + }); + expect(res.ok()).toBeTruthy(); + const { token } = await res.json(); + return token; +} + +test.describe('Photo Dimensions Repair (#180)', () => { + test('Status endpoint returns dimension counts', async ({ page }, testInfo) => { + if (testInfo.project.name === 'mobile-chrome') { + test.skip(); + } + + const token = await getAdminToken(page); + + const statusRes = await page.request.get('/api/admin/photos/repair-dimensions/status', { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(statusRes.ok()).toBeTruthy(); + + const status = await statusRes.json(); + expect(status).toHaveProperty('total'); + expect(status).toHaveProperty('withDimensions'); + expect(status).toHaveProperty('withoutDimensions'); + expect(status).toHaveProperty('isRunning'); + expect(typeof status.total).toBe('number'); + expect(typeof status.isRunning).toBe('boolean'); + }); + + test('Repair endpoint runs and returns immediately', async ({ page }, testInfo) => { + if (testInfo.project.name === 'mobile-chrome') { + test.skip(); + } + + const token = await getAdminToken(page); + + const repairRes = await page.request.post('/api/admin/photos/repair-dimensions', { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(repairRes.ok()).toBeTruthy(); + + const body = await repairRes.json(); + expect(body).toHaveProperty('message'); + expect(body).toHaveProperty('count'); + + // Wait for background job to complete + await page.waitForTimeout(3000); + + // Check status after repair + const statusRes = await page.request.get('/api/admin/photos/repair-dimensions/status', { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(statusRes.ok()).toBeTruthy(); + const status = await statusRes.json(); + expect(status.isRunning).toBe(false); + }); +}); diff --git a/tests/e2e/upload-batch-size-setting.spec.ts b/tests/e2e/upload-batch-size-setting.spec.ts deleted file mode 100644 index a06bc8f4..00000000 --- a/tests/e2e/upload-batch-size-setting.spec.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { test, expect } from '@playwright/test'; - -const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com'; -const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234'; - -async function getAdminToken(page: import('@playwright/test').Page): Promise { - const loginRes = await page.request.post('/api/auth/admin/login', { - data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD }, - }); - const body = await loginRes.json(); - return body.token; -} - -async function adminLogin(page: import('@playwright/test').Page) { - await page.goto('/admin/login'); - await page.getByLabel(/Email|E-Mail/i).fill(ADMIN_EMAIL); - await page.getByLabel(/Password|Passwort/i).fill(ADMIN_PASSWORD); - await page.getByRole('button', { name: /Sign In|Log in|Anmelden/i }).click(); - await expect(page.getByRole('heading', { name: /Dashboard|Übersicht/i })).toBeVisible({ timeout: 20000 }); -} - -test.describe('Upload batch size setting', () => { - test('setting exists in DB via API with default value 95', async ({ page }) => { - const token = await getAdminToken(page); - - const res = await page.request.get('/api/admin/settings', { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(res.ok()).toBeTruthy(); - const settings = await res.json(); - expect(settings.general_max_upload_batch_size_mb).toBe(95); - }); - - test('setting appears in General settings UI and can be changed', async ({ page }, testInfo) => { - if (testInfo.project.name === 'mobile-chrome') { - test.skip('Settings UI validated on desktop viewport'); - } - - await adminLogin(page); - await page.goto('/admin/settings'); - - // Find the batch size input by its nearby label text - const batchSizeLabel = page.locator('label', { hasText: /Max Upload Batch Size|Max\. Upload-Paketgröße/i }); - await expect(batchSizeLabel).toBeVisible({ timeout: 10000 }); - - // The input is a sibling within the same container - const batchSizeInput = batchSizeLabel.locator('..').locator('input[type="number"]'); - await expect(batchSizeInput).toBeVisible(); - await expect(batchSizeInput).toHaveValue('95'); - - // Change value to 50 - await batchSizeInput.fill('50'); - - // Save general settings - const saveButton = page.getByRole('button', { name: /Save General Settings|Allgemeine Einstellungen speichern/i }); - await saveButton.click(); - - // Wait for success toast - await expect(page.locator('.Toastify__toast').filter({ hasText: /(Settings saved|Einstellungen gespeichert)/i })).toBeVisible({ timeout: 10000 }); - - // Reload and verify persisted - await page.reload(); - const batchSizeLabelAfter = page.locator('label', { hasText: /Max Upload Batch Size|Max\. Upload-Paketgröße/i }); - await expect(batchSizeLabelAfter).toBeVisible({ timeout: 10000 }); - const batchSizeInputAfter = batchSizeLabelAfter.locator('..').locator('input[type="number"]'); - await expect(batchSizeInputAfter).toHaveValue('50'); - - // Revert to default - await batchSizeInputAfter.fill('95'); - await page.getByRole('button', { name: /Save General Settings|Allgemeine Einstellungen speichern/i }).click(); - await expect(page.locator('.Toastify__toast').filter({ hasText: /(Settings saved|Einstellungen gespeichert)/i })).toBeVisible({ timeout: 10000 }); - }); - - test('setting is used for upload chunking via API', async ({ page }) => { - const token = await getAdminToken(page); - - // Set batch size to a small value - const updateRes = await page.request.put('/api/admin/settings/general', { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - data: { general_max_upload_batch_size_mb: 10 }, - }); - expect(updateRes.ok()).toBeTruthy(); - - // Verify the setting was saved - const getRes = await page.request.get('/api/admin/settings', { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(getRes.ok()).toBeTruthy(); - const settings = await getRes.json(); - expect(settings.general_max_upload_batch_size_mb).toBe(10); - - // Revert to default - await page.request.put('/api/admin/settings/general', { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - data: { general_max_upload_batch_size_mb: 95 }, - }); - }); -});