Merge pull request 'feature/preview-images' (#3) from feature/preview-images into main

Reviewed-on: #3
This commit is contained in:
Matthias Lotz 2025-11-01 12:33:21 +01:00
commit 889f61d9bc
25 changed files with 959 additions and 48 deletions

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Development data backups
backend/src/data-backup/
# Node modules
node_modules/
# Environment files
.env
.env.local
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Build outputs
dist/
build/

View File

@ -6,6 +6,7 @@ A self-hosted image uploader with multi-image upload capabilities and automatic
**Multi-Image Upload**: Upload multiple images at once with batch processing
**Slideshow Mode**: Automatic fullscreen slideshow with smooth transitions
**Preview Image Optimization**: Automatic thumbnail generation for faster gallery loading (96-98% size reduction)
**Persistent Storage**: Docker volumes ensure data persistence across restarts
**Clean UI**: Minimalist design focused on user experience
**Self-Hosted**: Complete control over your data and infrastructure
@ -106,6 +107,31 @@ docker compose up -d
- **Spacebar / Arrow Right**: Manually advance to next image
- **Home Button**: Return to main upload interface
### Preview Image Optimization
The application automatically generates optimized preview thumbnails for all uploaded images to significantly improve gallery loading performance.
- **Automatic Generation**:
- Preview images are created automatically on server startup
- Existing images without previews are processed on-demand
- New uploads generate previews immediately during upload
- **Technical Specifications**:
- **Max Width**: 800px (maintains aspect ratio)
- **Format**: JPEG with 85% quality
- **Size Reduction**: 96-98% smaller than originals (e.g., 2076KB → 58.5KB)
- **Performance**: ~30x faster gallery loading times
- **Smart Image Loading**:
- **Galleries & Overview**: Load lightweight preview images (~50-100KB)
- **Slideshow Mode**: Uses full-resolution originals for best quality
- **Fallback**: Automatically uses originals if preview generation fails
- **Storage**:
- Originals: `backend/src/data/images/` (~2-4MB per image)
- Previews: `backend/src/data/previews/` (~50-100KB per image)
- Database: `preview_path` column stores preview filename
### Moderation Interface (Protected)
- **Access**: `http://localhost/moderation` (requires authentication)
@ -152,6 +178,7 @@ CREATE TABLE images (
file_name TEXT NOT NULL,
original_name TEXT NOT NULL,
file_path TEXT NOT NULL,
preview_path TEXT,
upload_order INTEGER NOT NULL,
file_size INTEGER,
mime_type TEXT,
@ -175,7 +202,8 @@ CREATE TRIGGER update_groups_timestamp
### Backend (Node.js + Express)
- **Multi-upload API**: `/api/upload/batch` - Handles batch file processing
- **Groups API**: `/api/groups` - Retrieves slideshow collections
- **File Storage**: Organized in `/upload` directory
- **Preview Generation**: Automatic thumbnail creation using Sharp (800px JPEG, 85% quality)
- **File Storage**: Organized in `/upload` directory (originals) and `/data/previews` (thumbnails)
- **Database Storage**: sqlite database in `/app/src/data/db/image_uploader.db`
### Frontend (React + Material-UI)
@ -195,12 +223,16 @@ Docker Volume (app-data)
src
└── app
├── src
├── upload
├── upload (originals, ~2-4MB each)
│ ├── ZMmHXzHbqw.jpg
│ ├── tjjnngOmXS.jpg
│ └── ...
└── data
└── db
├── previews (thumbnails, ~50-100KB each)
│ ├── ZMmHXzHbqw.jpg
│ ├── tjjnngOmXS.jpg
│ └── ...
└── db
└── image_uploader.db
```
@ -229,7 +261,9 @@ src
- `DELETE /groups/:id/images/:imageId` - Delete individual image from group
### File Access
- `GET /api/upload/:filename` - Access uploaded image files
- `GET /api/upload/:filename` - Access uploaded image files (legacy, use `/api/download` instead)
- `GET /api/download/:filename` - Download original full-resolution images
- `GET /api/previews/:filename` - Access optimized preview thumbnails (~100KB, 800px width)
## Configuration
### Environment Variables

View File

@ -23,7 +23,7 @@ Neue Struktur: Datenbank in src/data/db und bilder in src/data/images
### Frontend
- [ ] Code Cleanup & Refactoring
- [x] Code Cleanup & Refactoring
- [x] Überprüfung der Komponentenstruktur
- [x] Entfernen ungenutzter Dateien
- [x] Vereinheitlichung der ImageGallery Komponente:

View File

@ -20,6 +20,7 @@
"express-fileupload": "^1.2.1",
"find-remove": "^2.0.3",
"fs": "^0.0.1-security",
"sharp": "^0.34.4",
"shortid": "^2.2.16",
"sqlite3": "^5.1.7"
},

View File

@ -2,6 +2,7 @@ const endpoints = {
UPLOAD_STATIC_DIRECTORY: '/upload',
UPLOAD_FILE: '/upload',
UPLOAD_BATCH: '/upload/batch',
PREVIEW_STATIC_DIRECTORY: '/previews',
DOWNLOAD_FILE: '/download/:id',
GET_GROUP: '/groups/:groupId',
GET_ALL_GROUPS: '/groups',
@ -12,9 +13,20 @@ const endpoints = {
// Use path.join(__dirname, '..', UPLOAD_FS_DIR, fileName) in code
const UPLOAD_FS_DIR = 'data/images';
// Filesystem directory (relative to backend/src) where preview images will be stored
// Use path.join(__dirname, '..', PREVIEW_FS_DIR, fileName) in code
const PREVIEW_FS_DIR = 'data/previews';
// Preview generation configuration
const PREVIEW_CONFIG = {
maxWidth: 800, // Maximum width in pixels
quality: 85, // JPEG quality (0-100)
format: 'jpeg' // Output format
};
const time = {
HOURS_24: 86400000,
WEEK_1: 604800000
};
module.exports = { endpoints, time, UPLOAD_FS_DIR };
module.exports = { endpoints, time, UPLOAD_FS_DIR, PREVIEW_FS_DIR, PREVIEW_CONFIG };

View File

@ -34,6 +34,9 @@ class DatabaseManager {
// Erstelle Schema
await this.createSchema();
// Generate missing previews for existing images
await this.generateMissingPreviews();
console.log('✓ Datenbank erfolgreich initialisiert');
} catch (error) {
console.error('Fehler bei Datenbank-Initialisierung:', error);
@ -90,6 +93,17 @@ class DatabaseManager {
`);
console.log('✓ Images Tabelle erstellt');
// Migration: Füge preview_path Feld zur images Tabelle hinzu (falls nicht vorhanden)
try {
await this.run('ALTER TABLE images ADD COLUMN preview_path TEXT');
console.log('✓ preview_path Feld zur images Tabelle hinzugefügt');
} catch (error) {
// Feld existiert bereits - das ist okay
if (!error.message.includes('duplicate column')) {
console.warn('Migration Warnung:', error.message);
}
}
// Erstelle Indizes
await this.run('CREATE INDEX IF NOT EXISTS idx_groups_group_id ON groups(group_id)');
await this.run('CREATE INDEX IF NOT EXISTS idx_groups_year ON groups(year)');
@ -195,6 +209,68 @@ class DatabaseManager {
return false;
}
}
// Generate missing previews for existing images
async generateMissingPreviews() {
try {
console.log('🔍 Checking for images without previews...');
// Get all images that don't have a preview_path yet
const imagesWithoutPreview = await this.all(`
SELECT id, group_id, file_name, file_path
FROM images
WHERE preview_path IS NULL OR preview_path = ''
`);
if (imagesWithoutPreview.length === 0) {
console.log('✓ All images have previews');
return;
}
console.log(`📸 Found ${imagesWithoutPreview.length} image(s) without preview, generating...`);
const ImagePreviewService = require('../services/ImagePreviewService');
const fsp = require('fs').promises;
let successCount = 0;
let failCount = 0;
for (const image of imagesWithoutPreview) {
try {
// Check if original file exists
const originalPath = ImagePreviewService.getOriginalPath(image.file_name);
await fsp.access(originalPath);
// Generate preview
const previewFileName = ImagePreviewService._getPreviewFileName(image.file_name);
const previewPath = ImagePreviewService.getPreviewPath(previewFileName);
const result = await ImagePreviewService.generatePreview(originalPath, previewPath);
if (result.success) {
// Update database with preview_path
await this.run(`
UPDATE images
SET preview_path = ?
WHERE id = ?
`, [previewFileName, image.id]);
successCount++;
} else {
console.warn(` ⚠️ Preview generation failed for ${image.file_name}: ${result.error}`);
failCount++;
}
} catch (error) {
console.warn(` ⚠️ Could not process ${image.file_name}: ${error.message}`);
failCount++;
}
}
console.log(`✓ Preview generation complete: ${successCount} success, ${failCount} failed`);
} catch (error) {
console.warn('⚠️ Preview generation check failed:', error.message);
// Don't throw - this shouldn't prevent DB initialization
}
}
}
// Singleton Instance

View File

@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS images (
upload_order INTEGER NOT NULL,
file_size INTEGER,
mime_type TEXT,
preview_path TEXT, -- Path to preview/thumbnail image (added in migration 003)
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (group_id) REFERENCES groups(group_id) ON DELETE CASCADE
);

View File

@ -22,8 +22,8 @@ class GroupRepository {
if (groupData.images && groupData.images.length > 0) {
for (const image of groupData.images) {
await db.run(`
INSERT INTO images (group_id, file_name, original_name, file_path, upload_order, file_size, mime_type)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO images (group_id, file_name, original_name, file_path, upload_order, file_size, mime_type, preview_path)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, [
groupData.groupId,
image.fileName,
@ -31,7 +31,8 @@ class GroupRepository {
image.filePath,
image.uploadOrder,
image.fileSize || null,
image.mimeType || null
image.mimeType || null,
image.previewPath || null
]);
}
}
@ -67,6 +68,7 @@ class GroupRepository {
fileName: img.file_name,
originalName: img.original_name,
filePath: img.file_path,
previewPath: img.preview_path,
uploadOrder: img.upload_order,
fileSize: img.file_size,
mimeType: img.mime_type
@ -250,19 +252,25 @@ class GroupRepository {
// Alle Gruppen für Moderation (mit Freigabestatus und Bildanzahl)
async getAllGroupsWithModerationInfo() {
const groupFormatter = require('../utils/groupFormatter');
const groups = await dbManager.all(`
SELECT
g.*,
COUNT(i.id) as image_count,
MIN(i.file_path) as preview_image
FROM groups g
LEFT JOIN images i ON g.group_id = i.group_id
GROUP BY g.group_id
ORDER BY g.approved ASC, g.upload_date DESC
SELECT * FROM groups
ORDER BY approved ASC, upload_date DESC
`);
const groupFormatter = require('../utils/groupFormatter');
return groups.map(group => groupFormatter.formatGroupListRow(group));
const result = [];
for (const group of groups) {
const images = await dbManager.all(`
SELECT * FROM images
WHERE group_id = ?
ORDER BY upload_order ASC
`, [group.group_id]);
result.push(groupFormatter.formatGroupDetail(group, images));
}
return result;
}
// Hole Gruppe für Moderation (inkl. nicht-freigegebene)

View File

@ -5,6 +5,7 @@ const { endpoints } = require('../constants');
const UploadGroup = require('../models/uploadGroup');
const GroupRepository = require('../repositories/GroupRepository');
const dbManager = require('../database/DatabaseManager');
const ImagePreviewService = require('../services/ImagePreviewService');
const router = Router();
@ -64,6 +65,37 @@ router.post(endpoints.UPLOAD_BATCH, async (req, res) => {
});
}
// Generate previews for all uploaded images asynchronously
const previewDir = path.join(__dirname, '..', require('../constants').PREVIEW_FS_DIR);
const uploadDir = path.join(__dirname, '..', require('../constants').UPLOAD_FS_DIR);
// Generate previews in background (don't wait)
ImagePreviewService.generatePreviewsForGroup(
processedFiles.map(f => ({ file_name: f.fileName, file_path: `/upload/${f.fileName}` })),
uploadDir,
previewDir
).then(results => {
const successCount = results.filter(r => r.success).length;
console.log(`Preview generation completed: ${successCount}/${results.length} successful`);
// Update preview_path in database for successful previews
results.forEach(async (result) => {
if (result.success) {
try {
await dbManager.run(`
UPDATE images
SET preview_path = ?
WHERE group_id = ? AND file_name = ?
`, [result.previewPath, group.groupId, result.fileName]);
} catch (err) {
console.error(`Failed to update preview_path for ${result.fileName}:`, err);
}
}
});
}).catch(err => {
console.error('Preview generation failed:', err);
});
// Speichere Gruppe in SQLite
await GroupRepository.createGroup({
groupId: group.groupId,

View File

@ -1,15 +1,19 @@
const generateId = require("shortid");
const express = require('express');
const { Router } = require('express');
const { endpoints, UPLOAD_FS_DIR } = require('../constants');
const { endpoints, UPLOAD_FS_DIR, PREVIEW_FS_DIR } = require('../constants');
const path = require('path');
const ImagePreviewService = require('../services/ImagePreviewService');
const router = Router();
// Serve uploaded images via URL /upload but store files under data/images
router.use(endpoints.UPLOAD_STATIC_DIRECTORY, express.static( path.join(__dirname, '..', UPLOAD_FS_DIR) ));
router.post(endpoints.UPLOAD_FILE, (req, res) => {
// Serve preview images via URL /previews but store files under data/previews
router.use(endpoints.PREVIEW_STATIC_DIRECTORY, express.static( path.join(__dirname, '..', PREVIEW_FS_DIR) ));
router.post(endpoints.UPLOAD_FILE, async (req, res) => {
if(req.files === null){
console.log('No file uploaded');
return res.status(400).json({ msg: 'No file uploaded' });
@ -22,14 +26,40 @@ router.post(endpoints.UPLOAD_FILE, (req, res) => {
fileName = generateId() + '.' + fileEnding
const savePath = path.join(__dirname, '..', UPLOAD_FS_DIR, fileName);
file.mv(savePath, err => {
if(err) {
console.error(err);
return res.status(500).send(err);
}
try {
// Save the uploaded file
await new Promise((resolve, reject) => {
file.mv(savePath, err => {
if(err) reject(err);
else resolve();
});
});
res.json({ filePath: `${endpoints.UPLOAD_STATIC_DIRECTORY}/${fileName}`});
});
// Generate preview asynchronously (don't wait for it)
const previewFileName = ImagePreviewService._getPreviewFileName(fileName);
const previewPath = ImagePreviewService.getPreviewPath(previewFileName);
ImagePreviewService.generatePreview(savePath, previewPath)
.then(result => {
if (!result.success) {
console.warn(`Preview generation failed for ${fileName}:`, result.error);
}
})
.catch(err => {
console.error(`Unexpected error during preview generation for ${fileName}:`, err);
});
// Return immediately with file path
res.json({
filePath: `${endpoints.UPLOAD_STATIC_DIRECTORY}/${fileName}`,
fileName: fileName
});
} catch(err) {
console.error(err);
return res.status(500).send(err);
}
});
module.exports = router;

View File

@ -0,0 +1,195 @@
const sharp = require('sharp');
const path = require('path');
const fs = require('fs').promises;
const { PREVIEW_FS_DIR, PREVIEW_CONFIG, UPLOAD_FS_DIR } = require('../constants');
class ImagePreviewService {
/**
* Generates a preview/thumbnail image from the original
* @param {string} originalPath - Absolute path to the original image
* @param {string} previewPath - Absolute path where preview should be saved
* @returns {Promise<{success: boolean, previewPath?: string, error?: string}>}
*/
async generatePreview(originalPath, previewPath) {
try {
// Ensure preview directory exists
const previewDir = path.dirname(previewPath);
await fs.mkdir(previewDir, { recursive: true });
// Check if original file exists
await fs.access(originalPath);
// Generate preview using sharp
await sharp(originalPath)
.resize(PREVIEW_CONFIG.maxWidth, null, {
fit: 'inside',
withoutEnlargement: true
})
.jpeg({ quality: PREVIEW_CONFIG.quality })
.toFile(previewPath);
// Get file sizes for logging
const [originalStats, previewStats] = await Promise.all([
fs.stat(originalPath),
fs.stat(previewPath)
]);
const reductionPercent = ((1 - previewStats.size / originalStats.size) * 100).toFixed(1);
console.log(`Preview generated: ${path.basename(previewPath)}`);
console.log(` Original: ${(originalStats.size / 1024).toFixed(1)} KB`);
console.log(` Preview: ${(previewStats.size / 1024).toFixed(1)} KB`);
console.log(` Reduction: ${reductionPercent}%`);
return {
success: true,
previewPath,
originalSize: originalStats.size,
previewSize: previewStats.size,
reduction: parseFloat(reductionPercent)
};
} catch (error) {
console.error(`Error generating preview for ${originalPath}:`, error.message);
return {
success: false,
error: error.message
};
}
}
/**
* Generates previews for all images in a group
* @param {Array<{file_path: string, file_name: string}>} images - Array of image objects
* @param {string} baseDir - Base directory for original images (absolute path)
* @param {string} previewBaseDir - Base directory for preview images (absolute path)
* @returns {Promise<Array<{fileName: string, previewPath: string, success: boolean}>>}
*/
async generatePreviewsForGroup(images, baseDir, previewBaseDir) {
const results = [];
for (const image of images) {
const originalPath = path.join(baseDir, image.file_name);
const previewFileName = this._getPreviewFileName(image.file_name);
const previewPath = path.join(previewBaseDir, previewFileName);
const result = await this.generatePreview(originalPath, previewPath);
results.push({
fileName: image.file_name,
originalPath: image.file_path,
previewFileName,
previewPath: previewFileName, // Relative path for DB storage
success: result.success,
error: result.error
});
}
return results;
}
/**
* Gets the preview file path for a given original filename
* @param {string} originalFileName - Original image filename
* @returns {string} Preview filename (always .jpg extension)
*/
_getPreviewFileName(originalFileName) {
const baseName = path.parse(originalFileName).name;
return `${baseName}.jpg`;
}
/**
* Gets the absolute preview path for a given preview filename
* @param {string} previewFileName - Preview filename
* @returns {string} Absolute path to preview file
*/
getPreviewPath(previewFileName) {
return path.join(__dirname, '..', PREVIEW_FS_DIR, previewFileName);
}
/**
* Gets the absolute original image path for a given filename
* @param {string} fileName - Original image filename
* @returns {string} Absolute path to original file
*/
getOriginalPath(fileName) {
return path.join(__dirname, '..', UPLOAD_FS_DIR, fileName);
}
/**
* Cleans up orphaned preview files that don't have corresponding originals
* @returns {Promise<{removed: number, errors: number}>}
*/
async cleanupOrphanedPreviews() {
try {
const previewDir = path.join(__dirname, '..', PREVIEW_FS_DIR);
const originalDir = path.join(__dirname, '..', UPLOAD_FS_DIR);
// Check if preview directory exists
try {
await fs.access(previewDir);
} catch {
console.log('Preview directory does not exist yet, skipping cleanup');
return { removed: 0, errors: 0 };
}
const previewFiles = await fs.readdir(previewDir);
let removed = 0;
let errors = 0;
for (const previewFile of previewFiles) {
try {
// Construct corresponding original filename (might have different extension)
const baseName = path.parse(previewFile).name;
// Check all possible extensions
const possibleExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'];
let originalExists = false;
for (const ext of possibleExtensions) {
const originalPath = path.join(originalDir, baseName + ext);
try {
await fs.access(originalPath);
originalExists = true;
break;
} catch {
// Continue checking
}
}
if (!originalExists) {
const previewPath = path.join(previewDir, previewFile);
await fs.unlink(previewPath);
console.log(`Removed orphaned preview: ${previewFile}`);
removed++;
}
} catch (error) {
console.error(`Error processing ${previewFile}:`, error.message);
errors++;
}
}
return { removed, errors };
} catch (error) {
console.error('Error during preview cleanup:', error.message);
return { removed: 0, errors: 1 };
}
}
/**
* Checks if a preview exists for a given original filename
* @param {string} originalFileName - Original image filename
* @returns {Promise<boolean>}
*/
async previewExists(originalFileName) {
try {
const previewFileName = this._getPreviewFileName(originalFileName);
const previewPath = this.getPreviewPath(previewFileName);
await fs.access(previewPath);
return true;
} catch {
return false;
}
}
}
module.exports = new ImagePreviewService();

View File

@ -29,6 +29,7 @@ function formatGroupDetail(groupRow, images) {
fileName: img.file_name,
originalName: img.original_name,
filePath: img.file_path,
previewPath: img.preview_path || null,
uploadOrder: img.upload_order,
fileSize: img.file_size || null,
mimeType: img.mime_type || null

View File

@ -1,5 +1,3 @@
version: '3.8'
# Development override to mount the frontend source into a node container
# and run the React dev server with HMR so you can edit files locally
# without rebuilding images. This file is intended to be used together

View File

@ -0,0 +1,312 @@
# Feature: Preview-Bilder für Galerie
**Branch:** `feature/preview-images`
**Datum:** 29. Oktober 2025
## 🎯 Ziel
Generierung und Verwendung von optimierten Preview-Bildern (Thumbnails) für Galerie-Ansichten, um:
- Ladezeiten drastisch zu reduzieren
- Bandbreite zu sparen
- User Experience zu verbessern
- Mobile Performance zu optimieren
---
## 📐 Architektur-Überblick
### Aktuelle Situation
- Bilder werden in voller Auflösung in Galerien geladen
- Path: `backend/src/data/images/` (Original-Bilder)
- Keine Thumbnails oder Preview-Varianten
### Ziel-Architektur
```
backend/src/data/
├── images/ # Original-Bilder (unverändert)
│ └── X_JeZj0TsQ.JPG
└── previews/ # NEU: Preview-Bilder (optimiert)
└── X_JeZj0TsQ.JPG (oder .webp)
```
### Thumbnail-Spezifikationen
- **Größe:** Max. 800px Breite (beibehält Aspect Ratio)
- **Format:** JPEG (85% Qualität) oder WebP (falls unterstützt)
- **Verwendung:** Galerie-Views (GroupsOverviewPage, ModerationGroupsPage)
- **Original:** Slideshow-View und Download behalten Original-Bilder
---
## 🔧 Technische Implementierung
### Backend: Sharp Library
**Warum Sharp?**
- Schnell (nutzt libvips)
- Unterstützt JPEG, PNG, WebP
- Läuft in Node.js ohne externe Binaries
- Unterstützt Resize, Optimize, Format-Konvertierung
**Installation:**
```bash
npm install sharp --save
```
### Datenbankschema-Erweiterung
```sql
-- Erweitere images Tabelle um preview_path
ALTER TABLE images ADD COLUMN preview_path TEXT;
-- Index für schnelle Abfragen
CREATE INDEX IF NOT EXISTS idx_images_preview_path ON images(preview_path);
```
---
## 📋 Implementierungs-Phasen
### Phase 1: Backend - Preview-Generierung (6-10h)
#### 1.1 Service-Layer erstellen
**Datei:** `backend/src/services/ImagePreviewService.js`
**Funktionen:**
- `generatePreview(originalPath, outputPath, options)` - Generiert einzelnes Preview
- `generatePreviewsForGroup(groupId)` - Batch-Generierung für Upload-Gruppe
- `cleanupOrphanedPreviews()` - Entfernt nicht mehr verwendete Previews
- `getPreviewPath(originalFileName)` - Gibt Preview-Pfad zurück
**Optionen:**
```javascript
{
width: 800, // Max-Breite in px
quality: 85, // JPEG-Qualität
format: 'jpeg', // 'jpeg' oder 'webp'
withoutEnlargement: true // Keine Vergrößerung kleiner Bilder
}
```
#### 1.2 Upload-Route erweitern
**Datei:** `backend/src/routes/upload.js` & `backend/src/routes/batchUpload.js`
- Nach erfolgreichem Upload: Preview-Generierung triggern
- Preview-Pfad in DB speichern
- Fehlerbehandlung: Bei Preview-Fehler Upload trotzdem erfolgreich
#### 1.3 DB-Schema Migration
**Datei:** `backend/src/database/migrations/003_add_preview_path.sql` (NEU)
```sql
-- Migration 003: Add preview_path column
ALTER TABLE images ADD COLUMN preview_path TEXT;
CREATE INDEX IF NOT EXISTS idx_images_preview_path ON images(preview_path);
```
**Datei:** `backend/src/database/DatabaseManager.js`
- Erweitere `initDatabase()` um Migration-Support
- Auto-run Migrations beim Start
#### 1.4 API-Endpunkte erweitern
**Bestehende Endpoints anpassen:**
- `GET /groups` - Rückgabe mit `previewPath` für jedes Bild
- `GET /groups/:groupId` - Rückgabe mit `previewPath`
- `GET /moderation/groups` - Rückgabe mit `previewPath`
**Neue Endpoints (optional):**
- `POST /groups/:groupId/regenerate-previews` - Regeneriert Previews für Gruppe
- `GET /previews/:fileName` - Serve Preview-Bilder (analog zu `/upload/:fileName`)
#### 1.5 Constants & Config
**Datei:** `backend/src/constants.js`
```javascript
const PREVIEW_FS_DIR = 'data/previews';
const PREVIEW_CONFIG = {
width: 800,
quality: 85,
format: 'jpeg'
};
```
---
### Phase 2: Backend - Batch-Migration bestehender Bilder (2-4h)
#### 2.1 Migration-Script
**Datei:** `backend/src/scripts/generatePreviewsForExisting.js`
- Liest alle Bilder aus DB ohne `preview_path`
- Generiert Previews für alle bestehenden Bilder
- Aktualisiert DB mit Preview-Pfaden
- Progress-Logging
**Ausführung:**
```bash
node backend/src/scripts/generatePreviewsForExisting.js
```
Oder als Express-Endpoint für Admin:
- `POST /admin/migrate-previews` (mit Basic Auth)
---
### Phase 3: Frontend - Preview-Nutzung (3-6h)
#### 3.1 API-Response erweitern
**Typ-Definitionen (optional):**
```typescript
interface Image {
id: number;
fileName: string;
originalName: string;
filePath: string; // Original-Bild
previewPath?: string; // NEU: Preview-Bild
uploadOrder: number;
fileSize: number;
mimeType: string;
}
```
#### 3.2 Component Updates
**Dateien zu ändern:**
- `frontend/src/Components/ComponentUtils/GroupCard.js`
- `frontend/src/Components/Pages/GroupsOverviewPage.js`
- `frontend/src/Components/Pages/ModerationGroupsPage.js`
**Logik:**
```javascript
const imageSrc = image.previewPath || image.filePath;
// Fallback auf Original, falls Preview fehlt
```
#### 3.3 Slideshow beibehält Original
**Datei:** `frontend/src/Components/Pages/SlideshowPage.js`
- **Keine Änderung** - Slideshow nutzt weiterhin `filePath` (Original)
---
### Phase 4: Testing & Optimierung (2-4h)
#### 4.1 Unit Tests
- `ImagePreviewService.test.js` - Service-Logik
- Test mit verschiedenen Bildformaten (JPEG, PNG)
- Error-Handling Tests
#### 4.2 Integration Tests
- Upload → Preview-Generierung → DB-Update
- API-Response enthält `previewPath`
- Frontend lädt Preview-Bilder
#### 4.3 Performance-Messung
- Vergleich Ladezeiten: Original vs. Preview
- Bundle-Size (Sharp Library Impact)
- Memory-Usage bei Batch-Generierung
#### 4.4 Cleanup & Docs
- `README.md` Update mit Preview-Feature
- `CHANGELOG.md` Eintrag
- Code-Kommentare & JSDoc
---
## ⏱️ Zeitschätzung
| Phase | Aufgabe | Geschätzt |
|-------|---------|-----------|
| 1.1 | ImagePreviewService | 2-3h |
| 1.2 | Upload-Route erweitern | 1-2h |
| 1.3 | DB-Migration | 1h |
| 1.4 | API-Endpoints | 1-2h |
| 1.5 | Config/Constants | 0.5h |
| 2.1 | Batch-Migration Script | 2-4h |
| 3.1-3.3 | Frontend Updates | 3-6h |
| 4.1-4.4 | Testing & Docs | 2-4h |
| **Gesamt** | | **12-22h** |
**Realistische Schätzung:** 2-3 Arbeitstage
---
## 🚀 Deployment-Strategie
### Schritt 1: Backend-Deploy mit Preview-Generierung
- Deploy neuer Backend-Code
- DB-Migration läuft automatisch beim Start
- Neue Uploads erhalten automatisch Previews
### Schritt 2: Batch-Migration (einmalig)
```bash
docker exec image-uploader-backend node src/scripts/generatePreviewsForExisting.js
```
oder via Admin-Endpoint
### Schritt 3: Frontend-Deploy
- Deploy Frontend mit Preview-Support
- Fallback auf Original-Bilder bleibt aktiv
### Rollback-Plan
- Preview-Feature ist opt-in (Fallback auf `filePath`)
- Bei Problemen: Frontend-Rollback ohne Backend-Änderung nötig
---
## 📊 Erwartete Verbesserungen
### Dateigrößen (Beispiel)
- **Original JPEG (3000x2000px):** ~2-3 MB
- **Preview JPEG (800x533px, 85%):** ~80-150 KB
- **Einsparung:** ~95%
### Ladezeiten (Galerie mit 20 Bildern)
- **Vorher:** 20 × 2.5 MB = 50 MB
- **Nachher:** 20 × 100 KB = 2 MB
- **Verbesserung:** ~96% schneller
---
## ⚠️ Risiken & Mitigation
### Risiko 1: Sharp Build-Fehler in Docker
**Mitigation:**
- Alpine-kompatible Sharp-Version testen
- Falls nötig: `python3`, `make`, `g++` in Dockerfile hinzufügen
### Risiko 2: Disk-Space Verdopplung
**Mitigation:**
- Previews sind ~5% der Original-Größe
- Cleanup-Script für verwaiste Previews
- Optional: alte Previews nach X Tagen löschen
### Risiko 3: CPU-Last bei Batch-Migration
**Mitigation:**
- Batch-Processing mit Chunk-Size (z.B. 10 Bilder parallel)
- Progress-Logging
- Optional: Background-Job Queue (Bull, Agenda)
---
## 🔄 Zukünftige Erweiterungen
- **WebP-Support:** Kleinere Dateien, bessere Compression
- **Responsive Previews:** Mehrere Größen (400px, 800px, 1200px)
- **Lazy-Loading:** Intersection Observer für On-Demand-Loading
- **CDN-Integration:** Previews auf S3/CloudFront auslagern
- **Image-Optimization:** Auto-Rotation (EXIF), Strip Metadata
---
## 📝 Acceptance Criteria
- [ ] Neue Uploads generieren automatisch Previews
- [ ] Bestehende Bilder haben Previews nach Migration
- [ ] Galerie-Views laden Preview-Bilder (keine Originals)
- [ ] Slideshow lädt weiterhin Original-Bilder
- [ ] API liefert sowohl `filePath` als auch `previewPath`
- [ ] Fallback auf Original funktioniert, wenn Preview fehlt
- [ ] Ladezeit-Verbesserung messbar (>80%)
- [ ] Tests bestehen (Unit + Integration)
- [ ] Dokumentation aktualisiert
---
**Erstellt am:** 29. Oktober 2025
**Zuletzt aktualisiert:** 29. Oktober 2025

View File

@ -28,6 +28,24 @@ server {
client_max_body_size 100M;
}
# API - Download original images
location /api/download {
proxy_pass http://image-uploader-backend:5000/download;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API - Preview/thumbnail images (optimized for gallery views)
location /api/previews {
proxy_pass http://image-uploader-backend:5000/previews;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API - Groups (NO PASSWORD PROTECTION)
location /api/groups {
proxy_pass http://image-uploader-backend:5000/groups;

View File

@ -28,6 +28,24 @@ server {
client_max_body_size 100M;
}
# API - Download original images
location /api/download {
proxy_pass http://image-uploader-backend:5000/download;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API - Preview/thumbnail images (optimized for gallery views)
location /api/previews {
proxy_pass http://image-uploader-backend:5000/previews;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API - Groups (NO PASSWORD PROTECTION)
location /api/groups {
proxy_pass http://image-uploader-backend:5000/groups;

View File

@ -2,6 +2,68 @@ server {
listen 80;
server_name localhost;
# API proxy to backend - must come before / location
# Upload endpoint
location /api/upload {
proxy_pass http://image-uploader-backend:5000/upload;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 100M;
}
# Download original images
location /api/download {
proxy_pass http://image-uploader-backend:5000/download;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Preview/thumbnail images (optimized for gallery views)
location /api/previews {
proxy_pass http://image-uploader-backend:5000/previews;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Groups API
location /api/groups {
proxy_pass http://image-uploader-backend:5000/groups;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Moderation API (groups)
location /moderation/groups {
proxy_pass http://image-uploader-backend:5000/moderation/groups;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Groups routes (both API and page routes)
location /groups {
# Try to serve as static file first, then proxy to React dev server
try_files $uri @proxy;
}
# Download endpoint (legacy, without /api prefix)
location /download {
proxy_pass http://image-uploader-backend:5000/download;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy requests to the CRA dev server so nginx can be used as reverse proxy
location /sockjs-node/ {
proxy_pass http://127.0.0.1:3000;
@ -21,6 +83,16 @@ server {
proxy_set_header Host $host;
}
location @proxy {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;

View File

@ -9,7 +9,7 @@
/* Page-specific styles for ModerationPage */
.moderation-page { font-family: roboto; max-width: 1200px; margin: 0 auto; padding: 20px; }
.moderation-content h1 { font-family: roboto; text-align:center; color:#333; margin-bottom:30px; }
.moderation-content h1 { font-family: roboto; text-align:left; color:#333; margin-bottom:30px; }
.moderation-loading, .moderation-error { text-align:center; padding:50px; font-size:18px; }
.moderation-error { color:#dc3545; }

View File

@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import './Css/ImageGallery.css';
import { getImageSrc, getGroupPreviewSrc } from '../../Utils/imageUtils';
const ImageGalleryCard = ({
item,
@ -25,13 +26,8 @@ const ImageGalleryCard = ({
if (mode === 'preview' || mode === 'single-image') {
// Preview mode: display individual images
if (item.remoteUrl) {
previewUrl = item.remoteUrl;
} else if (item.url) {
previewUrl = item.url;
} else if (item.filePath) {
previewUrl = item.filePath;
}
// Use preview image (optimized thumbnails for gallery)
previewUrl = getImageSrc(item, true);
title = item.originalName || item.name || 'Bild';
@ -45,14 +41,12 @@ const ImageGalleryCard = ({
// Group mode: display group information
const group = item;
if (group.previewImage) {
previewUrl = `/download/${group.previewImage.split('/').pop()}`;
} else if (group.images && group.images.length > 0 && group.images[0].filePath) {
previewUrl = group.images[0].filePath;
}
// Use preview image from first image in group
previewUrl = getGroupPreviewSrc(group, true);
title = group.title;
subtitle = `${group.year}${group.name}`;
// Only show name if it exists and is not empty/null/undefined
subtitle = (group.name && group.name !== 'null') ? `${group.year}${group.name}` : `${group.year}`;
description = group.description;
uploadDate = group.uploadDate;
imageCount = group.imageCount;

View File

@ -41,7 +41,8 @@ const ModerationGroupImagesPage = () => {
// Map group's images to preview-friendly objects
if (data.images && data.images.length > 0) {
const mapped = data.images.map(img => ({
remoteUrl: `/download/${img.fileName}`,
...img, // Pass all image fields including previewPath
remoteUrl: `/download/${img.fileName}`, // Keep for backward compatibility
originalName: img.originalName || img.fileName,
id: img.id
}));

View File

@ -5,6 +5,7 @@ import { Container } from '@mui/material';
import Navbar from '../ComponentUtils/Headers/Navbar';
import Footer from '../ComponentUtils/Footer';
import ImageGallery from '../ComponentUtils/ImageGallery';
import { getImageSrc } from '../../Utils/imageUtils';
const ModerationGroupsPage = () => {
const [groups, setGroups] = useState([]);
@ -246,7 +247,7 @@ const ImageModal = ({ group, onClose, onDeleteImage }) => {
{group.images.map(image => (
<div key={image.id} className="image-item">
<img
src={`/download/${image.fileName}`}
src={getImageSrc(image, true)}
alt={image.originalName}
className="modal-image"
/>

View File

@ -52,7 +52,8 @@ const PublicGroupImagesPage = () => {
<ImageGallery
items={group.images && group.images.length > 0 ? group.images.map(img => ({
remoteUrl: `/download/${img.fileName}`,
...img, // Pass all image fields including previewPath
remoteUrl: `/download/${img.fileName}`, // Keep for backward compatibility
originalName: img.originalName || img.fileName,
id: img.id
})) : []}

View File

@ -13,6 +13,7 @@ import {
// Utils
import { fetchAllGroups } from '../../Utils/batchUpload';
import { getImageSrc } from '../../Utils/imageUtils';
// Styles moved inline to sx props below
@ -228,7 +229,7 @@ function SlideshowPage() {
</IconButton>
{/* Hauptbild */}
<Box component="img" src={`/api${currentImage.filePath}`} alt={currentImage.originalName} sx={{ ...slideshowImageSx, opacity: fadeOut ? 0 : 1 }} />
<Box component="img" src={getImageSrc(currentImage, false)} alt={currentImage.originalName} sx={{ ...slideshowImageSx, opacity: fadeOut ? 0 : 1 }} />
{/* Beschreibung */}
<Box sx={descriptionContainerSx}>

View File

@ -0,0 +1,64 @@
/**
* Helper functions for image handling and preview generation
*/
/**
* Get the optimal image source URL based on context
* @param {Object} image - Image object from API
* @param {boolean} usePreview - Whether to prefer preview over original (default: true)
* @returns {string} Image URL
*/
export const getImageSrc = (image, usePreview = true) => {
if (!image) {
return '';
}
// If previews are enabled and available, use preview
if (usePreview && image.previewPath) {
// previewPath is just the filename, not a full path
const previewFileName = image.previewPath.includes('/')
? image.previewPath.split('/').pop()
: image.previewPath;
return `/api/previews/${previewFileName}`;
}
// Fallback chain for original image
if (image.filePath) {
return `/api${image.filePath}`;
}
if (image.fileName) {
return `/api/download/${image.fileName}`;
}
// Legacy fallback
if (image.remoteUrl) {
return image.remoteUrl;
}
return '';
};
/**
* Get preview image for a group (first image)
* @param {Object} group - Group object from API
* @param {boolean} usePreview - Whether to prefer preview over original
* @returns {string} Image URL for group preview
*/
export const getGroupPreviewSrc = (group, usePreview = true) => {
if (!group) {
return '';
}
// Legacy support: direct previewImage field
if (group.previewImage) {
return `/api/download/${group.previewImage.split('/').pop()}`;
}
// Use first image from group
if (group.images && group.images.length > 0) {
return getImageSrc(group.images[0], usePreview);
}
return '';
};

View File

@ -43,6 +43,22 @@ server {
client_max_body_size 200M;
}
location /api/download {
proxy_pass http://image-uploader-backend:5000/download;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/previews {
proxy_pass http://image-uploader-backend:5000/previews;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/groups {
proxy_pass http://image-uploader-backend:5000/groups;
proxy_set_header Host $host;