Compare commits

...

6 Commits

Author SHA1 Message Date
Gitea Actions Bot 0a2b010332 chore: bump version to 1.0.30
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-14 19:17:01 +00:00
paul 6906c8bcf7 feat: increase file upload limit from 20 to 500 with performance optimizations
Test and Lint / backend-test (push) Successful in 1m10s
Test and Lint / frontend-test (push) Successful in 2m22s
continuous-integration/drone/push Build is passing
Version and Release / version-bump (push) Successful in 37s
Version and Release / trigger-drone (push) Successful in 3s
Backend changes:
- Update multer configuration to accept up to 500 files per upload
- Implement batch processing (10 files per transaction) for better performance
- Add memory-efficient Sharp configuration for thumbnail generation
- Increase Express body parser limits to handle large payloads
- Add proper error handling and reporting for partial upload failures

Frontend changes:
- Update validation to allow 500 files maximum
- Implement chunked uploads (50 files per chunk) to prevent timeouts
- Add progress tracking with chunk information display
- Update error messages and translations (EN/DE)

Performance optimizations:
- Disable Sharp cache to prevent memory buildup
- Limit Sharp concurrency to 2 operations
- Use sequential read for large images
- Process files in database transaction batches
- Return detailed upload results including success/failure counts

This implementation ensures the application can handle large photo uploads
efficiently without running into memory or timeout issues.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 21:12:35 +02:00
Gitea Actions Bot ac48bfdd0d chore: bump version to 1.0.29
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-14 19:09:25 +00:00
paul ec99243b6f test: add database compatibility tests
Test and Lint / backend-test (push) Successful in 1m6s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Successful in 3s
- Add comprehensive tests for formatBoolean() function
- Test PostgreSQL vs SQLite boolean handling
- Test date formatting and manipulation functions
- Verify insertAndGetId handles both database result formats

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 21:05:18 +02:00
paul 9932621e14 fix: comprehensive boolean compatibility for PostgreSQL and SQLite
- Add formatBoolean() usage to all boolean database queries
- Fix 36 boolean comparisons across 18 backend files
- Ensure compatibility between SQLite (0/1) and PostgreSQL (true/false)
- Update all WHERE clauses and UPDATE operations with boolean values

This completes the database compatibility fixes for production deployment.
All boolean operations now work correctly with both database systems.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 21:05:18 +02:00
paul 0fb17c78fa fix: photo upload issues with file limit and date formatting
- Add 20-file limit validation to PhotoUpload component
- Prevent Multer "Unexpected field" errors by enforcing client-side limit
- Fix JSON parsing error in dateFormatter when value is already an object
- Add missing translation keys for upload error messages
- Handle both string and object values for date format settings

These fixes resolve the 400 error when uploading more than 20 files
and the "Unexpected token o in JSON" error during email queue creation.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 21:05:18 +02:00
34 changed files with 430 additions and 162 deletions
+34 -12
View File
@@ -1,13 +1,18 @@
# 📸 PicPeak - Open Source Photo Sharing for Events # 📸 PicPeak - Open Source Photo Sharing for Events
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) <div align="center">
[![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=flat&logo=docker&logoColor=white)](https://www.docker.com/) <img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
[![Node.js](https://img.shields.io/badge/node.js-6DA55F?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/)
[![React](https://img.shields.io/badge/react-%2320232a.svg?style=flat&logo=react&logoColor=%2361DAFB)](https://reactjs.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=flat&logo=docker&logoColor=white)](https://www.docker.com/)
[![Node.js](https://img.shields.io/badge/node.js-6DA55F?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/)
[![React](https://img.shields.io/badge/react-%2320232a.svg?style=flat&logo=react&logoColor=%2361DAFB)](https://reactjs.org/)
</div>
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding. **PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
![PicPeak Gallery Preview](https://github.com/the-luap/picpeak/assets/placeholder-hero.png) ![PicPeak Gallery Preview](https://github.com/the-luap/picpeak/assets/placeholder-hero.png)
> 📸 *Gallery preview will be updated soon with latest interface*
## 🌟 Why Choose PicPeak? ## 🌟 Why Choose PicPeak?
@@ -124,17 +129,34 @@ Found a security issue? Please email security@example.com
## 📸 Screenshots ## 📸 Screenshots
### 🎛️ **Admin Dashboard**
Get a complete overview of your photo galleries, analytics, and system status.
<img src="docs/screenshot-dashboard.png" alt="PicPeak Admin Dashboard" width="800" />
### 📊 **Analytics & Insights**
Track gallery performance, view statistics, and monitor user engagement.
<img src="docs/screenshot-analytics.png" alt="PicPeak Analytics Dashboard" width="800" />
### 📁 **Event Management**
Organize and manage your photo galleries with intuitive event management tools.
<img src="docs/screenshots-events.png" alt="PicPeak Events Management" width="800" />
### ✨ **Key Interface Highlights**
<details> <details>
<summary>View Gallery Examples</summary> <summary>👆 Click to see more interface details</summary>
### Admin Dashboard #### What makes PicPeak's interface special:
![Admin Dashboard](https://github.com/the-luap/picpeak/assets/placeholder-admin.png)
### Client Gallery View - **🎨 Clean Design**: Modern, photographer-friendly interface
![Gallery View](https://github.com/the-luap/picpeak/assets/placeholder-gallery.png) - **📱 Responsive**: Perfect on desktop, tablet, and mobile
- **⚡ Fast Loading**: Optimized for quick photo browsing
### Mobile Experience - **🔒 Secure Access**: Password-protected galleries with expiration
![Mobile View](https://github.com/the-luap/picpeak/assets/placeholder-mobile.png) - **📤 Easy Uploads**: Drag & drop functionality for effortless photo management
- **🎯 Client-Focused**: Intuitive gallery experience for your clients
</details> </details>
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "1.0.28", "version": "1.0.30",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "1.0.28", "version": "1.0.30",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"archiver": "^5.3.1", "archiver": "^5.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "1.0.28", "version": "1.0.30",
"description": "Backend for PicPeak event photo sharing platform", "description": "Backend for PicPeak event photo sharing platform",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
+3 -3
View File
@@ -125,9 +125,9 @@ const authLimiter = rateLimit({
app.use('/api/', limiter); app.use('/api/', limiter);
app.use('/api/auth', authLimiter); app.use('/api/auth', authLimiter);
// Body parsing middleware // Body parsing middleware with increased limits for large uploads
app.use(express.json()); app.use(express.json({ limit: '100mb' }));
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true, limit: '100mb' }));
// Maintenance mode middleware - add after body parsing but before routes // Maintenance mode middleware - add after body parsing but before routes
app.use(maintenanceMiddleware); app.use(maintenanceMiddleware);
+83
View File
@@ -0,0 +1,83 @@
const { formatBoolean, isPostgreSQL, addDays, formatDateForDB, insertAndGetId } = require('../utils/dbCompat');
describe('Database Compatibility', () => {
// Save original env
const originalEnv = process.env.DATABASE_CLIENT;
afterEach(() => {
// Restore original env after each test
if (originalEnv) {
process.env.DATABASE_CLIENT = originalEnv;
} else {
delete process.env.DATABASE_CLIENT;
}
});
describe('formatBoolean', () => {
test('should format boolean values correctly', () => {
// Mock for SQLite
process.env.DATABASE_CLIENT = 'sqlite3';
expect(formatBoolean(true)).toBe(1);
expect(formatBoolean(false)).toBe(0);
// Mock for PostgreSQL
process.env.DATABASE_CLIENT = 'pg';
expect(formatBoolean(true)).toBe(true);
expect(formatBoolean(false)).toBe(false);
// Default (no env var) should be SQLite
delete process.env.DATABASE_CLIENT;
expect(formatBoolean(true)).toBe(1);
expect(formatBoolean(false)).toBe(0);
});
});
describe('isPostgreSQL', () => {
test('should detect PostgreSQL correctly', () => {
process.env.DATABASE_CLIENT = 'pg';
expect(isPostgreSQL()).toBe(true);
process.env.DATABASE_CLIENT = 'sqlite3';
expect(isPostgreSQL()).toBe(false);
delete process.env.DATABASE_CLIENT;
expect(isPostgreSQL()).toBe(false); // Default to SQLite
});
});
describe('formatDateForDB', () => {
test('should format dates as ISO strings', () => {
const date = new Date('2024-01-15T10:30:00Z');
expect(formatDateForDB(date)).toBe('2024-01-15T10:30:00.000Z');
});
});
describe('addDays', () => {
test('should add days correctly', () => {
const date = new Date('2024-01-15');
const result = addDays(date, 30);
expect(result.toISOString().split('T')[0]).toBe('2024-02-14');
const negativeResult = addDays(date, -7);
expect(negativeResult.toISOString().split('T')[0]).toBe('2024-01-08');
});
});
describe('insertAndGetId', () => {
test('should handle PostgreSQL result format', async () => {
const mockQuery = {
returning: jest.fn().mockResolvedValue([{ id: 123 }])
};
const result = await insertAndGetId(mockQuery);
expect(result).toBe(123);
});
test('should handle SQLite result format', async () => {
const mockQuery = {
returning: jest.fn().mockResolvedValue([456])
};
const result = await insertAndGetId(mockQuery);
expect(result).toBe(456);
});
});
});
+2 -1
View File
@@ -1,5 +1,6 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isTokenRevoked } = require('../utils/tokenRevocation'); const { isTokenRevoked } = require('../utils/tokenRevocation');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
@@ -57,7 +58,7 @@ async function adminAuth(req, res, next) {
// Check if admin still exists and is active // Check if admin still exists and is active
const admin = await db('admin_users') const admin = await db('admin_users')
.where({ id: decoded.id, is_active: true }) .where({ id: decoded.id, is_active: formatBoolean(true) })
.first(); .first();
if (!admin) { if (!admin) {
+3 -2
View File
@@ -1,5 +1,6 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
/** /**
@@ -50,7 +51,7 @@ async function adminAuth(req, res, next) {
// Check if admin still exists and is active // Check if admin still exists and is active
const admin = await db('admin_users') const admin = await db('admin_users')
.where({ id: decoded.id, is_active: true }) .where({ id: decoded.id, is_active: formatBoolean(true) })
.first(); .first();
if (!admin) { if (!admin) {
@@ -165,7 +166,7 @@ async function photoAuth(req, res, next) {
// Allow both admin and gallery tokens // Allow both admin and gallery tokens
if (decoded.type === 'admin') { if (decoded.type === 'admin') {
const admin = await db('admin_users') const admin = await db('admin_users')
.where({ id: decoded.id, is_active: true }) .where({ id: decoded.id, is_active: formatBoolean(true) })
.first(); .first();
if (!admin) { if (!admin) {
+2 -1
View File
@@ -1,5 +1,6 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
async function adminAuth(req, res, next) { async function adminAuth(req, res, next) {
try { try {
@@ -9,7 +10,7 @@ async function adminAuth(req, res, next) {
} }
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, process.env.JWT_SECRET);
const admin = await db('admin_users').where({ id: decoded.id, is_active: true }).first(); const admin = await db('admin_users').where({ id: decoded.id, is_active: formatBoolean(true) }).first();
if (!admin) { if (!admin) {
return res.status(401).json({ error: 'Invalid token' }); return res.status(401).json({ error: 'Invalid token' });
+2 -1
View File
@@ -1,5 +1,6 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
// Middleware to verify gallery access // Middleware to verify gallery access
async function verifyGalleryAccess(req, res, next) { async function verifyGalleryAccess(req, res, next) {
@@ -10,7 +11,7 @@ async function verifyGalleryAccess(req, res, next) {
} }
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, process.env.JWT_SECRET);
const event = await db('events').where({ id: decoded.eventId, is_active: true }).first(); const event = await db('events').where({ id: decoded.eventId, is_active: formatBoolean(true) }).first();
if (!event) { if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' }); return res.status(404).json({ error: 'Gallery not found or expired' });
+4 -3
View File
@@ -1,6 +1,7 @@
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
async function photoAuth(req, res, next) { async function photoAuth(req, res, next) {
try { try {
@@ -27,7 +28,7 @@ async function photoAuth(req, res, next) {
if (decoded.type === 'gallery') { if (decoded.type === 'gallery') {
// For thumbnails, we accept any valid gallery token // For thumbnails, we accept any valid gallery token
if (!eventSlug) { if (!eventSlug) {
const event = await db('events').where({ slug: decoded.eventSlug, is_active: true }).first(); const event = await db('events').where({ slug: decoded.eventSlug, is_active: formatBoolean(true) }).first();
if (event) { if (event) {
req.event = event; req.event = event;
return next(); return next();
@@ -35,7 +36,7 @@ async function photoAuth(req, res, next) {
} }
// For regular photos, check if token matches the event // For regular photos, check if token matches the event
else if (decoded.eventSlug === eventSlug) { else if (decoded.eventSlug === eventSlug) {
const event = await db('events').where({ slug: eventSlug, is_active: true }).first(); const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
if (event) { if (event) {
req.event = event; req.event = event;
return next(); return next();
@@ -72,7 +73,7 @@ async function photoAuth(req, res, next) {
return res.status(401).json({ error: 'Authentication required for thumbnails' }); return res.status(401).json({ error: 'Authentication required for thumbnails' });
} }
const event = await db('events').where({ slug: eventSlug, is_active: true }).first(); const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
if (!event) { if (!event) {
return res.status(404).json({ error: 'Gallery not found' }); return res.status(404).json({ error: 'Gallery not found' });
} }
+7 -6
View File
@@ -2,6 +2,7 @@ const express = require('express');
const path = require('path'); const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth-enhanced-v2'); const { adminAuth } = require('../middleware/auth-enhanced-v2');
const archiver = require('archiver'); const archiver = require('archiver');
const AdmZip = require('adm-zip'); const AdmZip = require('adm-zip');
@@ -16,7 +17,7 @@ router.get('/', adminAuth, async (req, res) => {
// Get total count // Get total count
const totalCount = await db('events') const totalCount = await db('events')
.where('is_archived', true) .where('is_archived', formatBoolean(true))
.count('id as count') .count('id as count')
.first(); .first();
@@ -28,7 +29,7 @@ router.get('/', adminAuth, async (req, res) => {
db.raw('SUM(photos.size_bytes) as total_size') db.raw('SUM(photos.size_bytes) as total_size')
) )
.leftJoin('photos', 'events.id', 'photos.event_id') .leftJoin('photos', 'events.id', 'photos.event_id')
.where('events.is_archived', true) .where('events.is_archived', formatBoolean(true))
.groupBy('events.id') .groupBy('events.id')
.orderBy('events.archived_at', 'desc') .orderBy('events.archived_at', 'desc')
.limit(limit) .limit(limit)
@@ -84,7 +85,7 @@ router.get('/:id', adminAuth, async (req, res) => {
try { try {
const archive = await db('events') const archive = await db('events')
.where('id', req.params.id) .where('id', req.params.id)
.where('is_archived', true) .where('is_archived', formatBoolean(true))
.first(); .first();
if (!archive) { if (!archive) {
@@ -140,7 +141,7 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
try { try {
const archive = await db('events') const archive = await db('events')
.where('id', req.params.id) .where('id', req.params.id)
.where('is_archived', true) .where('is_archived', formatBoolean(true))
.first(); .first();
if (!archive) { if (!archive) {
@@ -303,7 +304,7 @@ router.get('/:id/download', adminAuth, async (req, res) => {
try { try {
const archive = await db('events') const archive = await db('events')
.where('id', req.params.id) .where('id', req.params.id)
.where('is_archived', true) .where('is_archived', formatBoolean(true))
.first(); .first();
if (!archive) { if (!archive) {
@@ -352,7 +353,7 @@ router.delete('/:id', adminAuth, async (req, res) => {
try { try {
const archive = await db('events') const archive = await db('events')
.where('id', req.params.id) .where('id', req.params.id)
.where('is_archived', true) .where('is_archived', formatBoolean(true))
.first(); .first();
if (!archive) { if (!archive) {
+4 -3
View File
@@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const { body, validationResult } = require('express-validator'); const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth-enhanced-v2'); const { adminAuth } = require('../middleware/auth-enhanced-v2');
const router = express.Router(); const router = express.Router();
@@ -8,7 +9,7 @@ const router = express.Router();
router.get('/global', adminAuth, async (req, res) => { router.get('/global', adminAuth, async (req, res) => {
try { try {
const categories = await db('photo_categories') const categories = await db('photo_categories')
.where('is_global', true) .where('is_global', formatBoolean(true))
.orderBy('name', 'asc'); .orderBy('name', 'asc');
res.json(categories); res.json(categories);
@@ -25,7 +26,7 @@ router.get('/event/:eventId', adminAuth, async (req, res) => {
const categories = await db('photo_categories') const categories = await db('photo_categories')
.where(function() { .where(function() {
this.where('is_global', true) this.where('is_global', formatBoolean(true))
.orWhere('event_id', eventId); .orWhere('event_id', eventId);
}) })
.orderBy('is_global', 'desc') .orderBy('is_global', 'desc')
@@ -65,7 +66,7 @@ router.post('/', adminAuth, [
.where('slug', categorySlug) .where('slug', categorySlug)
.where(function() { .where(function() {
if (is_global) { if (is_global) {
this.where('is_global', true); this.where('is_global', formatBoolean(true));
} else { } else {
this.where('event_id', event_id); this.where('event_id', event_id);
} }
+118 -67
View File
@@ -61,7 +61,10 @@ const { validateFileType } = require('../utils/fileSecurityUtils');
const upload = multer({ const upload = multer({
storage: storage, storage: storage,
limits: { limits: {
fileSize: 50 * 1024 * 1024, // 50MB limit fileSize: 50 * 1024 * 1024, // 50MB limit per file
files: 500, // Maximum 500 files
// Set a reasonable field size limit to prevent memory issues
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
}, },
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
// Accept images only with proper validation // Accept images only with proper validation
@@ -85,13 +88,17 @@ const validateUploadContent = createFileUploadValidator({
}); });
// Upload photos for an event // Upload photos for an event
// Increased limit to 500 files, but recommend chunked uploads for better performance
router.post('/:eventId/upload', adminAuth, (req, res, next) => { router.post('/:eventId/upload', adminAuth, (req, res, next) => {
upload.array('photos', 20)(req, res, (err) => { upload.array('photos', 500)(req, res, (err) => {
if (err) { if (err) {
console.error('Multer error:', err); console.error('Multer error:', err);
if (err instanceof multer.MulterError) { if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') { if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' }); return res.status(400).json({ error: 'File too large. Maximum size is 50MB per file.' });
}
if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({ error: 'Too many files. Maximum 500 files per upload.' });
} }
return res.status(400).json({ error: `Upload error: ${err.message}` }); return res.status(400).json({ error: `Upload error: ${err.message}` });
} }
@@ -136,90 +143,122 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
} }
const uploadedPhotos = []; const uploadedPhotos = [];
const errors = [];
// Process files in batches to optimize database operations
const BATCH_SIZE = 10; // Process 10 files at a time for database operations
for (let i = 0; i < req.files.length; i += BATCH_SIZE) {
const batch = req.files.slice(i, i + BATCH_SIZE);
// Start a single transaction for the batch
const trx = await db.transaction();
// Process each uploaded file
for (const file of req.files) {
let trx;
try { try {
// Start transaction for atomic counter update // Get initial counter for this batch
trx = await db.transaction(); let batchCounter = 1;
// Get and increment the counter for this category
let counter = 1;
if (category) { if (category) {
// Lock the category row and get current counter
const categoryData = await trx('photo_categories') const categoryData = await trx('photo_categories')
.where({ id: parsedCategoryId }) .where({ id: parsedCategoryId })
.forUpdate() .forUpdate()
.first(); .first();
batchCounter = (categoryData.photo_counter || 0) + 1;
counter = (categoryData.photo_counter || 0) + 1;
// Update counter
await trx('photo_categories')
.where({ id: parsedCategoryId })
.update({ photo_counter: counter });
} else { } else {
// For uncategorized photos, count existing uncategorized photos
const uncategorizedCount = await trx('photos') const uncategorizedCount = await trx('photos')
.where({ event_id: eventId }) .where({ event_id: eventId })
.whereNull('category_id') .whereNull('category_id')
.count('id as count') .count('id as count')
.first(); .first();
batchCounter = (uncategorizedCount.count || 0) + 1;
counter = (uncategorizedCount.count || 0) + 1;
} }
// Generate new filename const batchPhotos = [];
const extension = path.extname(file.originalname);
const newFilename = generatePhotoFilename(
event.event_name,
category ? category.name : 'uncategorized',
counter,
extension
);
// Rename the file for (let fileIndex = 0; fileIndex < batch.length; fileIndex++) {
const oldPath = file.path; const file = batch[fileIndex];
const newPath = path.join(path.dirname(oldPath), newFilename); const counter = batchCounter + fileIndex;
await fs.rename(oldPath, newPath);
// Update file object try {
file.filename = newFilename; // Generate new filename
file.path = newPath; const extension = path.extname(file.originalname);
const newFilename = generatePhotoFilename(
event.event_name,
category ? category.name : 'uncategorized',
counter,
extension
);
// Generate thumbnail with new filename // Rename the file
const thumbnailPath = await generateThumbnail(file.path); const oldPath = file.path;
const newPath = path.join(path.dirname(oldPath), newFilename);
await fs.rename(oldPath, newPath);
// Calculate relative paths // Update file object
const storagePath = getStoragePath(); file.filename = newFilename;
const relativePath = path.relative(path.join(storagePath, 'events/active'), file.path); file.path = newPath;
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
// Add to database // Generate thumbnail with new filename
const [photoId] = await trx('photos').insert({ const thumbnailPath = await generateThumbnail(file.path);
event_id: eventId,
filename: file.filename,
path: relativePath,
thumbnail_path: relativeThumbPath,
category_id: parsedCategoryId || null,
type: 'individual', // Keep for backwards compatibility
size_bytes: file.size
});
// Commit transaction // Calculate relative paths
const storagePath = getStoragePath();
const relativePath = path.relative(path.join(storagePath, 'events/active'), file.path);
const relativeThumbPath = thumbnailPath;
// Prepare photo data for batch insert
batchPhotos.push({
event_id: eventId,
filename: file.filename,
path: relativePath,
thumbnail_path: relativeThumbPath,
category_id: parsedCategoryId || null,
type: 'individual',
size_bytes: file.size
});
} catch (error) {
console.error(`Error processing file ${file.originalname}:`, error);
errors.push({ filename: file.originalname, error: error.message });
// Delete the file if it was partially processed
if (file.path) {
try { await fs.unlink(file.path); } catch (e) {}
}
}
}
// Batch insert all photos from this batch
if (batchPhotos.length > 0) {
const insertedIds = await trx('photos').insert(batchPhotos).returning('id');
// Update category counter if needed
if (category) {
await trx('photo_categories')
.where({ id: parsedCategoryId })
.update({ photo_counter: batchCounter + batchPhotos.length - 1 });
}
// Add to uploaded photos array
batchPhotos.forEach((photo, index) => {
uploadedPhotos.push({
id: insertedIds[index]?.id || insertedIds[index],
filename: photo.filename,
size: photo.size_bytes,
category_id: photo.category_id
});
});
}
// Commit the batch transaction
await trx.commit(); await trx.commit();
uploadedPhotos.push({
id: photoId,
filename: file.filename,
size: file.size,
category_id: parsedCategoryId || null
});
} catch (error) { } catch (error) {
console.error(`Error processing file ${file.filename}:`, error); console.error(`Error processing batch starting at index ${i}:`, error);
if (trx) await trx.rollback(); await trx.rollback();
// Continue with other files
// Try to clean up files from failed batch
for (const file of batch) {
if (file.path) {
try { await fs.unlink(file.path); } catch (e) {}
}
}
} }
} }
@@ -230,10 +269,22 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
{ type: 'admin', id: req.admin.id, name: req.admin.username } { type: 'admin', id: req.admin.id, name: req.admin.username }
); );
res.json({ // Prepare response
const response = {
message: `Successfully uploaded ${uploadedPhotos.length} photos`, message: `Successfully uploaded ${uploadedPhotos.length} photos`,
photos: uploadedPhotos photos: uploadedPhotos,
}); totalFiles: req.files.length,
successCount: uploadedPhotos.length,
failureCount: errors.length
};
// Include error details if any files failed
if (errors.length > 0) {
response.errors = errors;
response.message = `Uploaded ${uploadedPhotos.length} of ${req.files.length} photos. ${errors.length} failed.`;
}
res.json(response);
} catch (error) { } catch (error) {
console.error('Error uploading photos:', error); console.error('Error uploading photos:', error);
res.status(500).json({ error: 'Failed to upload photos' }); res.status(500).json({ error: 'Failed to upload photos' });
+2 -1
View File
@@ -4,6 +4,7 @@ const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const { body, validationResult } = require('express-validator'); const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { clearMaintenanceCache } = require('../middleware/maintenance'); const { clearMaintenanceCache } = require('../middleware/maintenance');
const router = express.Router(); const router = express.Router();
@@ -513,7 +514,7 @@ router.get('/storage/info', adminAuth, async (req, res) => {
// Get archive storage // Get archive storage
const archives = await db('events') const archives = await db('events')
.where('is_archived', true) .where('is_archived', formatBoolean(true))
.whereNotNull('archive_path') .whereNotNull('archive_path')
.select('archive_path'); .select('archive_path');
+2 -1
View File
@@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator'); const { body, validationResult } = require('express-validator');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyRecaptcha } = require('../services/recaptcha'); const { verifyRecaptcha } = require('../services/recaptcha');
const { const {
trackFailedAttempt, trackFailedAttempt,
@@ -248,7 +249,7 @@ router.post('/gallery/verify', [
return res.status(400).json({ error: 'reCAPTCHA verification failed' }); return res.status(400).json({ error: 'reCAPTCHA verification failed' });
} }
const event = await db('events').where({ slug, is_active: true, is_archived: false }).first(); const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
if (!event) { if (!event) {
// Don't reveal if gallery exists // Don't reveal if gallery exists
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
+2 -1
View File
@@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator'); const { body, validationResult } = require('express-validator');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyRecaptcha } = require('../services/recaptcha'); const { verifyRecaptcha } = require('../services/recaptcha');
const { const {
trackFailedAttempt, trackFailedAttempt,
@@ -167,7 +168,7 @@ router.post('/gallery/verify', [
return res.status(400).json({ error: 'reCAPTCHA verification failed' }); return res.status(400).json({ error: 'reCAPTCHA verification failed' });
} }
const event = await db('events').where({ slug, is_active: true, is_archived: false }).first(); const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
if (!event) { if (!event) {
// Don't reveal if gallery exists // Don't reveal if gallery exists
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
+2 -1
View File
@@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator'); const { body, validationResult } = require('express-validator');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyRecaptcha } = require('../services/recaptcha'); const { verifyRecaptcha } = require('../services/recaptcha');
const router = express.Router(); const router = express.Router();
@@ -76,7 +77,7 @@ router.post('/gallery/verify', [
return res.status(400).json({ error: 'reCAPTCHA verification failed' }); return res.status(400).json({ error: 'reCAPTCHA verification failed' });
} }
const event = await db('events').where({ slug, is_active: true, is_archived: false }).first(); const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
if (!event) { if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' }); return res.status(404).json({ error: 'Gallery not found or expired' });
} }
+5 -4
View File
@@ -3,6 +3,7 @@ const { body, validationResult } = require('express-validator');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const crypto = require('crypto'); const crypto = require('crypto');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth-enhanced-v2'); const { adminAuth } = require('../middleware/auth-enhanced-v2');
const fs = require('fs').promises; const fs = require('fs').promises;
const path = require('path'); const path = require('path');
@@ -113,9 +114,9 @@ router.get('/', adminAuth, async (req, res) => {
let query = db('events').select('*'); let query = db('events').select('*');
if (status === 'active') { if (status === 'active') {
query = query.where('is_active', true); query = query.where('is_active', formatBoolean(true));
} else if (status === 'archived') { } else if (status === 'archived') {
query = query.where('is_archived', true); query = query.where('is_archived', formatBoolean(true));
} }
const events = await query.orderBy('created_at', 'desc'); const events = await query.orderBy('created_at', 'desc');
@@ -162,7 +163,7 @@ router.delete('/:id', adminAuth, async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
await db('events').where('id', id).update({ is_active: false }); await db('events').where('id', id).update({ is_active: formatBoolean(false) });
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
@@ -188,7 +189,7 @@ router.post('/:id/extend', adminAuth, [
await db('events').where('id', id).update({ await db('events').where('id', id).update({
expires_at: newExpiration, expires_at: newExpiration,
is_active: true // Reactivate if expired is_active: formatBoolean(true) // Reactivate if expired
}); });
res.json({ expires_at: newExpiration }); res.json({ expires_at: newExpiration });
+4 -3
View File
@@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const archiver = require('archiver'); const archiver = require('archiver');
const path = require('path'); const path = require('path');
const router = express.Router(); const router = express.Router();
@@ -19,7 +20,7 @@ async function verifyGalleryAccess(req, res, next) {
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, process.env.JWT_SECRET);
const event = await db('events') const event = await db('events')
.where({ id: decoded.eventId, is_active: true, is_archived: false }) .where({ id: decoded.eventId, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first(); .first();
if (!event) { if (!event) {
@@ -40,7 +41,7 @@ router.get('/:slug/verify-token/:token', async (req, res) => {
const { slug, token } = req.params; const { slug, token } = req.params;
const event = await db('events') const event = await db('events')
.where({ slug, is_active: true, is_archived: false }) .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.select('id', 'share_link') .select('id', 'share_link')
.first(); .first();
@@ -121,7 +122,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
// Get all categories for this event // Get all categories for this event
const categories = await db('photo_categories') const categories = await db('photo_categories')
.where(function() { .where(function() {
this.where('is_global', true) this.where('is_global', formatBoolean(true))
.orWhere('event_id', req.event.id); .orWhere('event_id', req.event.id);
}) })
.orderBy('is_global', 'desc') .orderBy('is_global', 'desc')
+2 -1
View File
@@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const path = require('path'); const path = require('path');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyGalleryAccess } = require('../middleware/gallery'); const { verifyGalleryAccess } = require('../middleware/gallery');
const watermarkService = require('../services/watermarkService'); const watermarkService = require('../services/watermarkService');
const { getStoragePath } = require('../config/storage'); const { getStoragePath } = require('../config/storage');
@@ -141,7 +142,7 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
// Get event // Get event
const event = await db('events') const event = await db('events')
.where({ slug }) .where({ slug })
.where('is_active', true) .where('is_active', formatBoolean(true))
.first(); .first();
if (!event) { if (!event) {
+1 -1
View File
@@ -75,7 +75,7 @@ async function queueExpirationWarning(event) {
async function handleExpiredEvent(event) { async function handleExpiredEvent(event) {
try { try {
// Mark as inactive // Mark as inactive
await db('events').where('id', event.id).update({ is_active: false }); await db('events').where('id', event.id).update({ is_active: formatBoolean(false) });
// Queue expiration emails // Queue expiration emails
await queueEmail(event.id, event.host_email, 'gallery_expired', { await queueEmail(event.id, event.host_email, 'gallery_expired', {
+2 -1
View File
@@ -2,6 +2,7 @@ const chokidar = require('chokidar');
const path = require('path'); const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { generateThumbnail } = require('./imageProcessor'); const { generateThumbnail } = require('./imageProcessor');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
@@ -51,7 +52,7 @@ async function processNewPhoto(filePath) {
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return; if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
// Find the event // Find the event
const event = await db('events').where({ slug: eventSlug, is_active: true }).first(); const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
if (!event) return; if (!event) return;
// Get file stats // Get file stats
+25 -8
View File
@@ -2,6 +2,10 @@ const sharp = require('sharp');
const path = require('path'); const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
// Configure sharp for better memory management with large batches
sharp.cache(false); // Disable cache to prevent memory buildup
sharp.concurrency(2); // Limit concurrent operations
const THUMBNAIL_WIDTH = 300; const THUMBNAIL_WIDTH = 300;
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails'); const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
@@ -15,16 +19,29 @@ async function generateThumbnail(imagePath) {
// Ensure thumbnail directory exists // Ensure thumbnail directory exists
await fs.mkdir(thumbnailDir, { recursive: true }); await fs.mkdir(thumbnailDir, { recursive: true });
// Generate thumbnail try {
await sharp(imagePath) // Generate thumbnail with memory-efficient settings
.resize(THUMBNAIL_WIDTH, null, { await sharp(imagePath, {
withoutEnlargement: true, limitInputPixels: 268402689, // ~16k x 16k max
fit: 'inside' sequentialRead: true // More memory efficient for large images
}) })
.jpeg({ quality: 80 }) .resize(THUMBNAIL_WIDTH, null, {
.toFile(thumbnailPath); withoutEnlargement: true,
fit: 'inside'
})
.jpeg({
quality: 80,
progressive: true, // Progressive JPEG for better loading
mozjpeg: true // Better compression
})
.toFile(thumbnailPath);
return path.relative(getStoragePath(), thumbnailPath); return path.relative(getStoragePath(), thumbnailPath);
} catch (error) {
console.error(`Failed to generate thumbnail for ${filename}:`, error);
// Return null if thumbnail generation fails, don't fail the whole upload
return null;
}
} }
module.exports = { generateThumbnail }; module.exports = { generateThumbnail };
+3 -2
View File
@@ -4,6 +4,7 @@
*/ */
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('./dbCompat');
const logger = require('./logger'); const logger = require('./logger');
// Configuration constants // Configuration constants
@@ -59,7 +60,7 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
const cutoffTime = new Date(Date.now() - ATTEMPT_WINDOW); const cutoffTime = new Date(Date.now() - ATTEMPT_WINDOW);
await db('login_attempts') await db('login_attempts')
.where('identifier', identifier) .where('identifier', identifier)
.where('success', false) .where('success', formatBoolean(false))
.where('attempt_time', '<', cutoffTime.toISOString()) .where('attempt_time', '<', cutoffTime.toISOString())
.delete(); .delete();
} catch (error) { } catch (error) {
@@ -79,7 +80,7 @@ async function checkAccountLockout(identifier) {
// Get recent failed attempts // Get recent failed attempts
const failedAttempts = await db('login_attempts') const failedAttempts = await db('login_attempts')
.where('identifier', identifier) .where('identifier', identifier)
.where('success', false) .where('success', formatBoolean(false))
.where('attempt_time', '>=', recentWindow.toISOString()) .where('attempt_time', '>=', recentWindow.toISOString())
.orderBy('attempt_time', 'desc') .orderBy('attempt_time', 'desc')
.limit(MAX_LOGIN_ATTEMPTS); .limit(MAX_LOGIN_ATTEMPTS);
+15 -1
View File
@@ -11,7 +11,21 @@ async function formatDate(date, language = 'en') {
try { try {
// Get date format setting from database // Get date format setting from database
const setting = await db('app_settings').where('setting_key', 'general_date_format').first(); const setting = await db('app_settings').where('setting_key', 'general_date_format').first();
const dateConfig = setting ? JSON.parse(setting.setting_value) : DEFAULT_FORMAT; let dateConfig = DEFAULT_FORMAT;
if (setting && setting.setting_value) {
// Handle both string and object values
if (typeof setting.setting_value === 'string') {
try {
dateConfig = JSON.parse(setting.setting_value);
} catch (e) {
console.warn('Failed to parse date format setting:', e.message);
dateConfig = DEFAULT_FORMAT;
}
} else {
dateConfig = setting.setting_value;
}
}
const dateObj = date instanceof Date ? date : new Date(date); const dateObj = date instanceof Date ? date : new Date(date);
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "picpeak-frontend", "name": "picpeak-frontend",
"version": "1.0.28", "version": "1.0.30",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picpeak-frontend", "name": "picpeak-frontend",
"version": "1.0.28", "version": "1.0.30",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"@tiptap/extension-link": "^2.25.0", "@tiptap/extension-link": "^2.25.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "picpeak-frontend", "name": "picpeak-frontend",
"private": true, "private": true,
"version": "1.0.28", "version": "1.0.30",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+84 -25
View File
@@ -18,6 +18,8 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]); const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
const [currentChunk, setCurrentChunk] = useState(0);
const [totalChunks, setTotalChunks] = useState(0);
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null); const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -32,6 +34,20 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
const imageFiles = files.filter(file => const imageFiles = files.filter(file =>
['image/jpeg', 'image/png', 'image/webp'].includes(file.type) ['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
); );
// Check total file count with existing files
const totalFiles = selectedFiles.length + imageFiles.length;
if (totalFiles > 500) {
const allowedNewFiles = 500 - selectedFiles.length;
if (allowedNewFiles <= 0) {
toast.error(t('upload.maxFilesReached') || 'Maximum 500 files allowed');
return;
}
toast.warning(t('upload.someFilesSkipped') || `Only ${allowedNewFiles} more files can be added (500 max)`);
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
return;
}
setSelectedFiles(prev => [...prev, ...imageFiles]); setSelectedFiles(prev => [...prev, ...imageFiles]);
}; };
@@ -42,37 +58,63 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
const handleUpload = async () => { const handleUpload = async () => {
if (selectedFiles.length === 0) return; if (selectedFiles.length === 0) return;
// Validate file count
if (selectedFiles.length > 500) {
toast.error(t('upload.tooManyFiles') || 'Maximum 500 files can be uploaded at once');
return;
}
setIsUploading(true); setIsUploading(true);
setUploadProgress(0); setUploadProgress(0);
const formData = new FormData(); // For large uploads, chunk the files to prevent memory issues
selectedFiles.forEach((file, index) => { const CHUNK_SIZE = 50; // Upload 50 files at a time
console.log(`Adding file ${index}: ${file.name}, size: ${file.size}`); const chunks = [];
formData.append('photos', file);
});
if (selectedCategoryId) { for (let i = 0; i < selectedFiles.length; i += CHUNK_SIZE) {
formData.append('category_id', selectedCategoryId.toString()); chunks.push(selectedFiles.slice(i, i + CHUNK_SIZE));
} }
// Debug: Log FormData contents setTotalChunks(chunks.length);
console.log('FormData entries:'); let totalUploaded = 0;
for (let pair of formData.entries()) { let failedFiles = [];
console.log(pair[0], pair[1]);
}
try { try {
const response = await api.post(`/admin/events/${eventId}/upload`, formData, { for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
// Don't set Content-Type header - axios will set it with the boundary setCurrentChunk(chunkIndex + 1);
onUploadProgress: (progressEvent) => { const chunk = chunks[chunkIndex];
if (progressEvent.total) { const formData = new FormData();
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
setUploadProgress(progress);
}
},
});
console.log('Upload result:', response.data); chunk.forEach((file) => {
formData.append('photos', file);
});
if (selectedCategoryId) {
formData.append('category_id', selectedCategoryId.toString());
}
try {
const response = await api.post(`/admin/events/${eventId}/upload`, formData, {
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
// Calculate overall progress across all chunks
const chunkProgress = progressEvent.loaded / progressEvent.total;
const overallProgress = ((chunkIndex + chunkProgress) / chunks.length) * 100;
setUploadProgress(Math.round(overallProgress));
}
},
});
totalUploaded += chunk.length;
console.log(`Chunk ${chunkIndex + 1}/${chunks.length} uploaded:`, response.data);
} catch (error: any) {
console.error(`Error uploading chunk ${chunkIndex + 1}:`, error);
failedFiles.push(...chunk.map(f => f.name));
// Continue with next chunk even if one fails
continue;
}
}
// Clear selected files // Clear selected files
setSelectedFiles([]); setSelectedFiles([]);
@@ -80,8 +122,15 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
fileInputRef.current.value = ''; fileInputRef.current.value = '';
} }
// Show success message // Show appropriate message
toast.success(t('toast.uploadSuccess')); if (failedFiles.length === 0) {
toast.success(t('upload.uploadComplete') || `Successfully uploaded ${totalUploaded} files`);
} else {
toast.warning(
t('upload.someFilesFailed') ||
`Uploaded ${totalUploaded} files. ${failedFiles.length} files failed.`
);
}
// Call callback // Call callback
if (onUploadComplete) { if (onUploadComplete) {
@@ -93,6 +142,8 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
} finally { } finally {
setIsUploading(false); setIsUploading(false);
setUploadProgress(0); setUploadProgress(0);
setCurrentChunk(0);
setTotalChunks(0);
} }
}; };
@@ -203,7 +254,10 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
{isUploading && ( {isUploading && (
<div className="mt-4"> <div className="mt-4">
<div className="flex justify-between text-sm text-neutral-600 mb-1"> <div className="flex justify-between text-sm text-neutral-600 mb-1">
<span>{t('upload.uploading')}</span> <span>
{t('upload.uploading')}
{totalChunks > 1 && ` (${t('common.chunk') || 'Chunk'} ${currentChunk}/${totalChunks})`}
</span>
<span>{uploadProgress}%</span> <span>{uploadProgress}%</span>
</div> </div>
<div className="w-full bg-neutral-200 rounded-full h-2"> <div className="w-full bg-neutral-200 rounded-full h-2">
@@ -212,6 +266,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
style={{ width: `${uploadProgress}%` }} style={{ width: `${uploadProgress}%` }}
/> />
</div> </div>
{totalChunks > 1 && (
<p className="text-xs text-neutral-500 mt-1">
{t('upload.uploadingChunks') || `Uploading ${selectedFiles.length} files in ${totalChunks} batches...`}
</p>
)}
</div> </div>
)} )}
</div> </div>
+4 -1
View File
@@ -47,7 +47,10 @@
"uploadComplete": "Upload abgeschlossen!", "uploadComplete": "Upload abgeschlossen!",
"uploadFailed": "Upload fehlgeschlagen", "uploadFailed": "Upload fehlgeschlagen",
"someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden", "someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden",
"uploadPhotos": "Fotos hochladen" "uploadPhotos": "Fotos hochladen",
"maxFilesReached": "Maximal 500 Dateien erlaubt",
"someFilesSkipped": "Einige Dateien wurden übersprungen (500 Dateien Limit)",
"tooManyFiles": "Maximal 500 Dateien können gleichzeitig hochgeladen werden"
}, },
"navigation": { "navigation": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
+4 -1
View File
@@ -47,7 +47,10 @@
"uploadComplete": "Upload complete!", "uploadComplete": "Upload complete!",
"uploadFailed": "Upload failed", "uploadFailed": "Upload failed",
"someFilesFailed": "Some files failed to upload", "someFilesFailed": "Some files failed to upload",
"uploadPhotos": "Upload Photos" "uploadPhotos": "Upload Photos",
"maxFilesReached": "Maximum 500 files allowed",
"someFilesSkipped": "Some files were skipped (500 file limit)",
"tooManyFiles": "Maximum 500 files can be uploaded at once"
}, },
"navigation": { "navigation": {
"dashboard": "Dashboard", "dashboard": "Dashboard",