Merge main into beta for release/beta-to-main
This commit is contained in:
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "2.5.0"
|
".": "2.6.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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))
|
* **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)
|
## [2.5.0](https://github.com/the-luap/picpeak/compare/v2.4.0...v2.5.0) (2026-02-21)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
};
|
|
||||||
@@ -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/feedback', require('./src/routes/adminFeedback'));
|
||||||
app.use('/api/admin/image-security', require('./src/routes/adminImageSecurity'));
|
app.use('/api/admin/image-security', require('./src/routes/adminImageSecurity'));
|
||||||
app.use('/api/admin/thumbnails', require('./src/routes/adminThumbnails'));
|
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/photos', require('./src/routes/adminPhotos'));
|
||||||
app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
|
app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
|
||||||
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
|
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -6,6 +6,7 @@ const { db, logActivity } = require('../database/db');
|
|||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { requirePermission } = require('../middleware/permissions');
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const { generateThumbnail, ensureThumbnail, extractCaptureDate } = require('../services/imageProcessor');
|
const { generateThumbnail, ensureThumbnail, extractCaptureDate } = require('../services/imageProcessor');
|
||||||
|
const { processUploadedVideo, isVideoMimeType } = require('../services/videoProcessor');
|
||||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||||
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||||
@@ -271,6 +272,10 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
|||||||
console.log(`Could not extract EXIF date for ${file.originalname}`);
|
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
|
// Prepare photo data for batch insert
|
||||||
const photoData = {
|
const photoData = {
|
||||||
event_id: parseInt(eventId),
|
event_id: parseInt(eventId),
|
||||||
@@ -281,7 +286,9 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
|||||||
type: photoType,
|
type: photoType,
|
||||||
category_id: parsedCategoryId, // Save the selected category
|
category_id: parsedCategoryId, // Save the selected category
|
||||||
size_bytes: tempStats.size, // Use actual file size from stat
|
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);
|
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}`);
|
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
|
||||||
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];
|
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||||
|
const isVideoFile = isVideoMimeType(operation.photoData.mime_type);
|
||||||
|
let thumbnailPath = null;
|
||||||
|
|
||||||
|
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')
|
await db('photos')
|
||||||
.where({ id: photoId })
|
.where({ id: photoId })
|
||||||
.update({ thumbnail_path: thumbnailPath });
|
.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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (thumbError) {
|
} else {
|
||||||
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue watermark generation in background (non-blocking)
|
if (Object.keys(updateData).length > 0) {
|
||||||
// This pre-generates watermarked versions for fast serving in lightbox
|
await db('photos')
|
||||||
if (insertedIds[idx]) {
|
.where({ id: photoId })
|
||||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
.update(updateData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (thumbError) {
|
||||||
|
console.error(`Thumbnail/metadata processing failed for ${operation.filename}:`, thumbError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue watermark generation in background (non-blocking, images only)
|
||||||
|
if (photoId && !isVideoFile) {
|
||||||
watermarkGeneratorService.generateForPhoto(photoId)
|
watermarkGeneratorService.generateForPhoto(photoId)
|
||||||
.catch(err => console.warn(`Watermark generation queued failed for photo ${photoId}:`, err.message));
|
.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_id: photo.category_id || photo.type,
|
||||||
category_name: photo.pc_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
|
category_name: photo.pc_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
|
||||||
category_slug: photo.pc_slug || photo.type,
|
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,
|
size: photo.size_bytes,
|
||||||
uploaded_at: photo.uploaded_at,
|
uploaded_at: photo.uploaded_at,
|
||||||
// Feedback data
|
// Feedback data
|
||||||
|
|||||||
@@ -109,8 +109,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
|
|
||||||
// For large uploads, chunk the files by both count AND size to prevent memory/network issues
|
// 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 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 = 500 * 1024 * 1024; // Max 500MB per chunk (nginx limit is 1GB)
|
||||||
const MAX_BYTES_PER_CHUNK = maxBatchSizeMb * 1024 * 1024;
|
|
||||||
const chunks: File[][] = [];
|
const chunks: File[][] = [];
|
||||||
|
|
||||||
let currentChunk: File[] = [];
|
let currentChunk: File[] = [];
|
||||||
|
|||||||
@@ -215,6 +215,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
useCanvasRendering,
|
useCanvasRendering,
|
||||||
isSelectionMode,
|
isSelectionMode,
|
||||||
onPhotoSelect: handlePhotoSelect,
|
onPhotoSelect: handlePhotoSelect,
|
||||||
|
onSelectAll: selectAll,
|
||||||
|
onDeselectAll: deselectAll,
|
||||||
eventName,
|
eventName,
|
||||||
eventLogo,
|
eventLogo,
|
||||||
eventDate,
|
eventDate,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface BaseGalleryLayoutProps {
|
|||||||
selectedPhotos?: Set<number>;
|
selectedPhotos?: Set<number>;
|
||||||
isSelectionMode?: boolean;
|
isSelectionMode?: boolean;
|
||||||
onPhotoSelect?: (photoId: number) => void;
|
onPhotoSelect?: (photoId: number) => void;
|
||||||
|
onSelectAll?: () => void;
|
||||||
|
onDeselectAll?: () => void;
|
||||||
eventName?: string;
|
eventName?: string;
|
||||||
eventLogo?: string | null;
|
eventLogo?: string | null;
|
||||||
eventDate?: string | null;
|
eventDate?: string | null;
|
||||||
|
|||||||
@@ -165,6 +165,8 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
|||||||
selectedPhotos = new Set(),
|
selectedPhotos = new Set(),
|
||||||
isSelectionMode = false,
|
isSelectionMode = false,
|
||||||
onPhotoSelect,
|
onPhotoSelect,
|
||||||
|
onSelectAll,
|
||||||
|
onDeselectAll,
|
||||||
eventName,
|
eventName,
|
||||||
eventDate,
|
eventDate,
|
||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
@@ -287,17 +289,11 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
|||||||
|
|
||||||
const handleSelectAll = useCallback(() => {
|
const handleSelectAll = useCallback(() => {
|
||||||
if (selectedPhotos.size === filteredPhotos.length) {
|
if (selectedPhotos.size === filteredPhotos.length) {
|
||||||
// Deselect all
|
onDeselectAll?.();
|
||||||
filteredPhotos.forEach(p => onPhotoSelect?.(p.id));
|
|
||||||
} else {
|
} else {
|
||||||
// Select all
|
onSelectAll?.();
|
||||||
filteredPhotos.forEach(p => {
|
|
||||||
if (!selectedPhotos.has(p.id)) {
|
|
||||||
onPhotoSelect?.(p.id);
|
|
||||||
}
|
}
|
||||||
});
|
}, [selectedPhotos, filteredPhotos, onSelectAll, onDeselectAll]);
|
||||||
}
|
|
||||||
}, [selectedPhotos, filteredPhotos, onPhotoSelect]);
|
|
||||||
|
|
||||||
const handleDownloadSelected = useCallback(async () => {
|
const handleDownloadSelected = useCallback(async () => {
|
||||||
if (selectedPhotos.size === 0) return;
|
if (selectedPhotos.size === 0) return;
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export interface GeneralSettings {
|
|||||||
max_file_size_mb: number;
|
max_file_size_mb: number;
|
||||||
max_files_per_upload: number;
|
max_files_per_upload: number;
|
||||||
allowed_file_types: string;
|
allowed_file_types: string;
|
||||||
max_upload_batch_size_mb: number;
|
|
||||||
enable_analytics: boolean;
|
enable_analytics: boolean;
|
||||||
enable_registration: boolean;
|
enable_registration: boolean;
|
||||||
maintenance_mode: boolean;
|
maintenance_mode: boolean;
|
||||||
@@ -88,7 +87,6 @@ export function useSettingsState() {
|
|||||||
max_file_size_mb: 50,
|
max_file_size_mb: 50,
|
||||||
max_files_per_upload: 500,
|
max_files_per_upload: 500,
|
||||||
allowed_file_types: 'jpg,jpeg,png,gif,webp',
|
allowed_file_types: 'jpg,jpeg,png,gif,webp',
|
||||||
max_upload_batch_size_mb: 95,
|
|
||||||
enable_analytics: true,
|
enable_analytics: true,
|
||||||
enable_registration: false,
|
enable_registration: false,
|
||||||
maintenance_mode: false,
|
maintenance_mode: false,
|
||||||
@@ -171,7 +169,6 @@ export function useSettingsState() {
|
|||||||
Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
|
Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
|
||||||
),
|
),
|
||||||
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
|
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_analytics: toBoolean(settings.general_enable_analytics, true),
|
||||||
enable_registration: toBoolean(settings.general_enable_registration, false),
|
enable_registration: toBoolean(settings.general_enable_registration, false),
|
||||||
maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
|
maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
|
||||||
|
|||||||
@@ -161,28 +161,6 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
|
|||||||
{t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })}
|
{t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
|
||||||
{t('settings.general.maxUploadBatchSize')}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={generalSettings.max_upload_batch_size_mb}
|
|
||||||
onChange={(e) => {
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
|
||||||
{t('settings.general.maxUploadBatchSizeHelp')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Activity,
|
Activity,
|
||||||
|
Ruler,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button, Card, Input } from '../../../components/common';
|
import { Button, Card, Input } from '../../../components/common';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { settingsService } from '../../../services/settings.service';
|
||||||
import { useStatusTab } from '../hooks/useStatusTab';
|
import { useStatusTab } from '../hooks/useStatusTab';
|
||||||
import { UpdateNotificationSettings } from '../components/UpdateNotificationSettings';
|
import { UpdateNotificationSettings } from '../components/UpdateNotificationSettings';
|
||||||
@@ -53,6 +56,27 @@ export const StatusTab: React.FC<StatusTabProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { storageInfo, systemStatus } = useStatusTab(isActive);
|
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
|
// Sync soft limit from storage info
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -528,6 +552,61 @@ export const StatusTab: React.FC<StatusTabProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Photo Dimensions */}
|
||||||
|
{dimensionStatus && (
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
|
||||||
|
<Ruler className="w-5 h-5" />
|
||||||
|
{t('settings.photoDimensions.title')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||||
|
{t('settings.photoDimensions.description')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||||
|
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{dimensionStatus.total}</p>
|
||||||
|
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.photoDimensions.totalPhotos')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-green-600 dark:text-green-400">{dimensionStatus.withDimensions}</p>
|
||||||
|
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.photoDimensions.withDimensions')}</p>
|
||||||
|
</div>
|
||||||
|
<div className={`rounded-lg p-3 text-center ${Number(dimensionStatus.withoutDimensions) > 0 ? 'bg-amber-50 dark:bg-amber-900/30' : 'bg-neutral-50 dark:bg-neutral-800'}`}>
|
||||||
|
<p className={`text-2xl font-bold ${Number(dimensionStatus.withoutDimensions) > 0 ? 'text-amber-600 dark:text-amber-400' : 'text-neutral-900 dark:text-neutral-100'}`}>{dimensionStatus.withoutDimensions}</p>
|
||||||
|
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.photoDimensions.missingDimensions')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dimensionStatus.lastResult && (
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||||
|
{t('settings.photoDimensions.resultSuccess', {
|
||||||
|
success: dimensionStatus.lastResult.success,
|
||||||
|
failed: dimensionStatus.lastResult.failed,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => repairMutation.mutate()}
|
||||||
|
isLoading={repairMutation.isPending || dimensionStatus.isRunning}
|
||||||
|
disabled={Number(dimensionStatus.withoutDimensions) === 0 || dimensionStatus.isRunning}
|
||||||
|
leftIcon={<Ruler className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
{dimensionStatus.isRunning
|
||||||
|
? t('settings.photoDimensions.repairing')
|
||||||
|
: Number(dimensionStatus.withoutDimensions) === 0
|
||||||
|
? t('settings.photoDimensions.noneToRepair')
|
||||||
|
: t('settings.photoDimensions.repairButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Update Notification Settings */}
|
{/* Update Notification Settings */}
|
||||||
<UpdateNotificationSettings />
|
<UpdateNotificationSettings />
|
||||||
|
|
||||||
|
|||||||
@@ -1012,8 +1012,6 @@
|
|||||||
"maxFileSizeHelp": "Maximale Größe pro hochgeladenem Foto",
|
"maxFileSizeHelp": "Maximale Größe pro hochgeladenem Foto",
|
||||||
"maxFilesPerUpload": "Max. Dateien pro Upload",
|
"maxFilesPerUpload": "Max. Dateien pro Upload",
|
||||||
"maxFilesPerUploadHelp": "Maximale Anzahl an Fotos pro Upload-Vorgang (1-{{max}}).",
|
"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",
|
"allowedFileTypes": "Erlaubte Dateitypen",
|
||||||
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
|
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
|
||||||
"featureToggles": "Funktionsschalter",
|
"featureToggles": "Funktionsschalter",
|
||||||
@@ -1281,6 +1279,19 @@
|
|||||||
"failed": "Fehlgeschlagen",
|
"failed": "Fehlgeschlagen",
|
||||||
"lastUpdate": "Letzte Aktualisierung"
|
"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": {
|
"updateNotifications": {
|
||||||
"title": "Update-Benachrichtigungen",
|
"title": "Update-Benachrichtigungen",
|
||||||
"description": "Erhalten Sie E-Mail-Benachrichtigungen, wenn neue Versionen von PicPeak verfügbar sind.",
|
"description": "Erhalten Sie E-Mail-Benachrichtigungen, wenn neue Versionen von PicPeak verfügbar sind.",
|
||||||
|
|||||||
@@ -637,8 +637,6 @@
|
|||||||
"maxFileSizeHelp": "Maximum size per uploaded photo",
|
"maxFileSizeHelp": "Maximum size per uploaded photo",
|
||||||
"maxFilesPerUpload": "Max Files per Upload",
|
"maxFilesPerUpload": "Max Files per Upload",
|
||||||
"maxFilesPerUploadHelp": "Maximum number of photos allowed in a single upload batch (1-{{max}}).",
|
"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",
|
"allowedFileTypes": "Allowed File Types",
|
||||||
"allowedFileTypesHelp": "Comma-separated list of file extensions",
|
"allowedFileTypesHelp": "Comma-separated list of file extensions",
|
||||||
"featureToggles": "Feature Toggles",
|
"featureToggles": "Feature Toggles",
|
||||||
@@ -801,6 +799,19 @@
|
|||||||
"failed": "Failed",
|
"failed": "Failed",
|
||||||
"lastUpdate": "Last update"
|
"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": {
|
"updateNotifications": {
|
||||||
"title": "Update Notifications",
|
"title": "Update Notifications",
|
||||||
"description": "Receive email notifications when new versions of PicPeak are available.",
|
"description": "Receive email notifications when new versions of PicPeak are available.",
|
||||||
|
|||||||
Binary file not shown.
@@ -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 || '[email protected]';
|
||||||
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||||
|
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!';
|
||||||
|
|
||||||
|
async function getAdminToken(page: Page): Promise<string> {
|
||||||
|
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: '[email protected]',
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 || '[email protected]';
|
||||||
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||||
|
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!';
|
||||||
|
|
||||||
|
async function getAdminToken(page: Page): Promise<string> {
|
||||||
|
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: '[email protected]',
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
|
||||||
|
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
||||||
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||||
|
|
||||||
|
async function getAdminToken(page: Page): Promise<string> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
|
||||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
|
||||||
|
|
||||||
async function getAdminToken(page: import('@playwright/test').Page): Promise<string> {
|
|
||||||
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 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user