Merge pull request 'feature/preview-images' (#3) from feature/preview-images into main
Reviewed-on: #3
This commit is contained in:
commit
889f61d9bc
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal 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/
|
||||
42
README.md
42
README.md
|
|
@ -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
|
||||
|
|
|
|||
2
TODO.md
2
TODO.md
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
res.json({ filePath: `${endpoints.UPLOAD_STATIC_DIRECTORY}/${fileName}`});
|
||||
});
|
||||
try {
|
||||
// Save the uploaded file
|
||||
await new Promise((resolve, reject) => {
|
||||
file.mv(savePath, err => {
|
||||
if(err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// 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;
|
||||
195
backend/src/services/ImagePreviewService.js
Normal file
195
backend/src/services/ImagePreviewService.js
Normal 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();
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
312
docs/FEATURE_PLAN-preview-images.md
Normal file
312
docs/FEATURE_PLAN-preview-images.md
Normal 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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})) : []}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
64
frontend/src/Utils/imageUtils.js
Normal file
64
frontend/src/Utils/imageUtils.js
Normal 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 '';
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user