From 59faf73f045d513a697b0d1aef3227a9598f33ac Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sun, 22 Feb 2026 21:37:40 +0000
Subject: [PATCH 1/4] chore(main): release 2.5.1
---
.release-please-manifest.json | 2 +-
CHANGELOG.md | 8 ++++++++
backend/package.json | 2 +-
frontend/package.json | 2 +-
4 files changed, 11 insertions(+), 3 deletions(-)
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 78baf5bf..0bd1b037 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "2.5.0"
+ ".": "2.5.1"
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e560618d..3984aef8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@ All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [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/package.json b/backend/package.json
index 57115b58..242a4ee9 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
- "version": "2.5.0",
+ "version": "2.5.1",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
diff --git a/frontend/package.json b/frontend/package.json
index 3d0ed019..c1d1e889 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
- "version": "2.5.0",
+ "version": "2.5.1",
"type": "module",
"scripts": {
"dev": "vite",
From cc4503ad28e6b56f8593edf3b54cefe8c4939fc1 Mon Sep 17 00:00:00 2001
From: Paul Nothaft <53005142+the-luap@users.noreply.github.com>
Date: Thu, 5 Mar 2026 22:16:28 +0100
Subject: [PATCH 2/4] Revert "feat: configurable upload batch size for reverse
proxy compatibility"
---
.../core/072_add_max_upload_batch_size.js | 20 ----
frontend/src/components/admin/PhotoUpload.tsx | 3 +-
.../settings/hooks/useSettingsState.ts | 3 -
.../src/features/settings/tabs/GeneralTab.tsx | 22 ----
frontend/src/i18n/locales/de.json | 2 -
frontend/src/i18n/locales/en.json | 2 -
tests/e2e/upload-batch-size-setting.spec.ts | 104 ------------------
7 files changed, 1 insertion(+), 155 deletions(-)
delete mode 100644 backend/migrations/core/072_add_max_upload_batch_size.js
delete mode 100644 tests/e2e/upload-batch-size-setting.spec.ts
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/frontend/src/components/admin/PhotoUpload.tsx b/frontend/src/components/admin/PhotoUpload.tsx
index ae53557d..a0dd5d61 100644
--- a/frontend/src/components/admin/PhotoUpload.tsx
+++ b/frontend/src/components/admin/PhotoUpload.tsx
@@ -98,8 +98,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/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/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index 30066b41..33f8ac02 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",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index eb1b9819..89e0022f 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",
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 },
- });
- });
-});
From fc75bcdfc38673d6e4dd1cd943cfb4638d3a306c Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Wed, 11 Mar 2026 11:50:43 +0100
Subject: [PATCH 3/4] fix: video upload media type, select all, and dimension
repair (#203, #220, #180)
- Fix admin video upload missing media_type/mime_type and video processing (#203)
- Fix Gallery-Premium Select All using atomic callbacks instead of stale closure loop (#220)
- Add photo dimension repair endpoint and admin UI (#180)
- Add E2E tests for all three fixes
---
backend/server.js | 1 +
backend/src/routes/adminPhotoDimensions.js | 151 ++++++++++++++++++
backend/src/routes/adminPhotos.js | 81 ++++++++--
.../gallery/PhotoGridWithLayouts.tsx | 2 +
.../gallery/layouts/BaseGalleryLayout.tsx | 2 +
.../gallery/layouts/GalleryPremiumLayout.tsx | 14 +-
.../src/features/settings/tabs/StatusTab.tsx | 79 +++++++++
frontend/src/i18n/locales/de.json | 13 ++
frontend/src/i18n/locales/en.json | 13 ++
test-assets/test-video.mp4 | Bin 0 -> 54846 bytes
tests/e2e/admin-video-upload.spec.ts | 115 +++++++++++++
tests/e2e/gallery-premium-select-all.spec.ts | 114 +++++++++++++
tests/e2e/photo-dimensions-repair.spec.ts | 64 ++++++++
13 files changed, 625 insertions(+), 24 deletions(-)
create mode 100644 backend/src/routes/adminPhotoDimensions.js
create mode 100644 test-assets/test-video.mp4
create mode 100644 tests/e2e/admin-video-upload.spec.ts
create mode 100644 tests/e2e/gallery-premium-select-all.spec.ts
create mode 100644 tests/e2e/photo-dimensions-repair.spec.ts
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 a3c4d540..f3fca911 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');
@@ -263,6 +264,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),
@@ -273,7 +278,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);
@@ -317,26 +324,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));
}
@@ -801,6 +847,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/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/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 33f8ac02..0e9cfafe 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -1250,6 +1250,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 89e0022f..fa899fd9 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -799,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 0000000000000000000000000000000000000000..951bbb52b4344eeaceb23f544a099c6e139753c5
GIT binary patch
literal 54846
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