Merge feature/ImageDescription into main

Complete implementation of image descriptions feature:
- Individual image descriptions (max 200 characters)
- Edit mode in moderation interface
- Display in slideshow and public views
- Integrated save functionality
- Docker build optimizations (190MB size reduction)
- Bug fixes and code improvements
This commit is contained in:
Matthias Lotz 2025-11-07 23:21:55 +01:00
commit 5064f265b0
22 changed files with 1321 additions and 40 deletions

21
.dockerignore Normal file
View File

@ -0,0 +1,21 @@
# Backend data (images, database)
backend/src/data/db/*.db
backend/src/data/db/*.db-*
backend/src/data/images/
backend/src/data/previews/
backend/src/data/groups/
# Node modules (will be installed in container)
backend/node_modules
frontend/node_modules
# Build outputs
frontend/build
# Dev files
.git
.gitignore
*.md
docs/
test_photos/
data-backup/

View File

@ -1,5 +1,79 @@
# Changelog
## [Unreleased] - Branch: feature/ImageDescription
### ✨ Image Descriptions Feature (November 2025)
#### Backend
- ✅ **Database Migration**: Added `image_description` column to `images` table (TEXT, nullable)
- Automatic migration on server startup
- Index created for performance optimization
- Backward compatible with existing images
- ✅ **Repository Layer**: Extended `GroupRepository.js` with description methods
- `updateImageDescription()` - Update single image description
- `updateBatchImageDescriptions()` - Batch update multiple descriptions
- Validation: Max 200 characters enforced
- `createGroup()` now accepts `imageDescription` field
- ✅ **API Endpoints**: New REST endpoints for description management
- `PATCH /groups/:groupId/images/:imageId` - Update single description
- `PATCH /groups/:groupId/images/batch-description` - Batch update
- Server-side validation (200 char limit)
- Error handling and detailed responses
- ✅ **Upload Integration**: Batch upload now supports descriptions
- `POST /api/upload/batch` accepts `descriptions` array
- Descriptions matched to images by filename
- Automatic truncation if exceeding limit
#### Frontend
- ✅ **Core Components**: Enhanced `ImageGalleryCard` and `ImageGallery`
- **Edit Mode**: Toggle button to activate description editing
- **Textarea**: Multi-line input with character counter (0/200)
- **Validation**: Real-time character limit enforcement
- **Placeholder**: Original filename shown as hint
- **Display Mode**: Italicized description display when not editing
- ✅ **Upload Flow**: Extended `MultiUploadPage.js`
- Edit mode for adding descriptions during upload
- State management for descriptions per image
- Descriptions sent to backend with upload
- Clean up on form reset
- ✅ **Moderation**: Enhanced `ModerationGroupImagesPage.js`
- Edit mode for existing group images
- Load descriptions from server
- Batch update API integration
- Save button with success feedback
- Optimistic UI updates
- ✅ **Slideshow**: Display descriptions during presentation
- Centered overlay below image
- Semi-transparent background with blur effect
- Responsive sizing (80% max width)
- Conditional rendering (only if description exists)
- ✅ **Public View**: Show descriptions in `PublicGroupImagesPage.js`
- Display in single-image gallery mode
- Italicized style for visual distinction
- No edit functionality (read-only)
#### Styling
- ✅ **CSS Additions**: New styles for edit mode and descriptions
- `.image-description-edit` - Edit textarea container
- `.image-description-edit textarea` - Input field styles
- `.char-counter` - Character counter with limit warning
- `.image-description-display` - Read-only description display
- Responsive design for mobile devices
#### Testing & Quality
- ✅ All phases implemented and committed
- ⏳ Integration testing pending
- ⏳ User acceptance testing pending
---
## [Unreleased] - Branch: upgrade/deps-react-node-20251028
### 🎯 Major Framework Upgrades (October 2025)

View File

@ -18,7 +18,13 @@ A self-hosted image uploader with multi-image upload capabilities and automatic
## What's New
This project extends the original [Image-Uploader by vallezw](https://github.com/vallezw/Image-Uploader) with enhanced multi-upload and slideshow capabilities.
### 🆕 Latest Features (January 2025)
### 🆕 Latest Features (November 2025)
- **Image Descriptions**: 🆕 Add optional descriptions to individual images (max 200 characters)
- **Edit Mode**: Edit descriptions for uploaded images in upload preview and moderation interface
- **Slideshow Display**: Image descriptions shown as overlays during slideshow presentation
- **Public Display**: Descriptions visible in public group views and galleries
### Previous Features (January 2025)
- **Drag-and-Drop Image Reordering**: Admins can now reorder images using intuitive drag-and-drop
- **Touch-Friendly Interface**: Mobile-optimized controls with always-visible drag handles
- **Slideshow Integration**: Custom image order automatically applies to slideshow mode

View File

@ -52,6 +52,7 @@ Neue Struktur: Datenbank in src/data/db und bilder in src/data/images
[x] Erweiterung der Benutzeroberfläche um eine Editierfunktion für bestehende Einträge in ModerationPage.js
[x] In der angezeigten Gruppen soll statt Bilder ansehen Gruppe editieren stehen
[x] Diese bestehende Ansicht (Bilder ansehen) soll als eigene Seite implementiert werden
[ ] Ergänzung der Möglichkeit eine Beschreibung zu den Bildern hinzuzufügen
[ ] Erweiterung der ModerationPage um reine Datenbankeditor der sqlite Datenbank.

View File

@ -1,3 +1,8 @@
node_modules
npm-debug.log
upload/
upload/
src/data/db/*.db
src/data/db/*.db-*
src/data/images/
src/data/previews/
src/data/groups/

View File

@ -104,6 +104,17 @@ class DatabaseManager {
}
}
// Migration: Füge image_description Feld zur images Tabelle hinzu (falls nicht vorhanden)
try {
await this.run('ALTER TABLE images ADD COLUMN image_description TEXT');
console.log('✓ image_description 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)');

View File

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

View File

@ -23,8 +23,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, preview_path)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO images (group_id, file_name, original_name, file_path, upload_order, file_size, mime_type, preview_path, image_description)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
groupData.groupId,
image.fileName,
@ -33,7 +33,8 @@ class GroupRepository {
image.uploadOrder,
image.fileSize || null,
image.mimeType || null,
image.previewPath || null
image.previewPath || null,
image.imageDescription || null
]);
}
}
@ -66,13 +67,15 @@ class GroupRepository {
name: group.name,
uploadDate: group.upload_date,
images: images.map(img => ({
id: img.id,
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
mimeType: img.mime_type,
imageDescription: img.image_description
})),
imageCount: images.length
};
@ -375,6 +378,65 @@ class GroupRepository {
};
});
}
// Aktualisiere die Beschreibung eines einzelnen Bildes
async updateImageDescription(imageId, groupId, description) {
// Validierung: Max 200 Zeichen
if (description && description.length > 200) {
throw new Error('Image description must not exceed 200 characters');
}
const result = await dbManager.run(`
UPDATE images
SET image_description = ?
WHERE id = ? AND group_id = ?
`, [description || null, imageId, groupId]);
return result.changes > 0;
}
// Batch-Update für mehrere Bildbeschreibungen
async updateBatchImageDescriptions(groupId, descriptions) {
if (!Array.isArray(descriptions) || descriptions.length === 0) {
throw new Error('Descriptions array is required and cannot be empty');
}
return await dbManager.transaction(async (db) => {
let updateCount = 0;
for (const desc of descriptions) {
const { imageId, description } = desc;
// Validierung: Max 200 Zeichen
if (description && description.length > 200) {
throw new Error(`Image description for image ${imageId} must not exceed 200 characters`);
}
// Prüfe ob Bild zur Gruppe gehört
const image = await db.get(`
SELECT id FROM images WHERE id = ? AND group_id = ?
`, [imageId, groupId]);
if (!image) {
throw new Error(`Image with ID ${imageId} not found in group ${groupId}`);
}
// Update Beschreibung
const result = await db.run(`
UPDATE images
SET image_description = ?
WHERE id = ? AND group_id = ?
`, [description || null, imageId, groupId]);
updateCount += result.changes;
}
return {
groupId: groupId,
updatedImages: updateCount
};
});
}
}
module.exports = new GroupRepository();

View File

@ -23,11 +23,14 @@ router.post(endpoints.UPLOAD_BATCH, async (req, res) => {
// Metadaten aus dem Request body
let metadata = {};
let descriptions = [];
try {
metadata = req.body.metadata ? JSON.parse(req.body.metadata) : {};
descriptions = req.body.descriptions ? JSON.parse(req.body.descriptions) : [];
} catch (e) {
console.error('Error parsing metadata:', e);
console.error('Error parsing metadata/descriptions:', e);
metadata = { description: req.body.description || "" };
descriptions = [];
}
// Erstelle neue Upload-Gruppe mit erweiterten Metadaten
@ -105,14 +108,28 @@ router.post(endpoints.UPLOAD_BATCH, async (req, res) => {
description: group.description,
name: group.name,
uploadDate: group.uploadDate,
images: processedFiles.map((file, index) => ({
fileName: file.fileName,
originalName: file.originalName,
filePath: `/upload/${file.fileName}`,
uploadOrder: index + 1,
fileSize: file.size,
mimeType: files[index].mimetype
}))
images: processedFiles.map((file, index) => {
// Finde passende Beschreibung für dieses Bild (match by fileName or originalName)
const descObj = descriptions.find(d =>
d.fileName === file.originalName || d.fileName === file.fileName
);
const imageDescription = descObj ? descObj.description : null;
// Validierung: Max 200 Zeichen
if (imageDescription && imageDescription.length > 200) {
console.warn(`Image description for ${file.originalName} exceeds 200 characters, truncating`);
}
return {
fileName: file.fileName,
originalName: file.originalName,
filePath: `/upload/${file.fileName}`,
uploadOrder: index + 1,
fileSize: file.size,
mimeType: files[index].mimetype,
imageDescription: imageDescription ? imageDescription.slice(0, 200) : null
};
})
});
console.log(`Successfully saved group ${group.groupId} with ${files.length} images to database`);

View File

@ -214,6 +214,100 @@ router.delete('/groups/:groupId/images/:imageId', async (req, res) => {
}
});
// Batch-Update für mehrere Bildbeschreibungen (MUSS VOR der einzelnen Route stehen!)
router.patch('/groups/:groupId/images/batch-description', async (req, res) => {
try {
const { groupId } = req.params;
const { descriptions } = req.body;
// Validierung
if (!Array.isArray(descriptions) || descriptions.length === 0) {
return res.status(400).json({
error: 'Invalid request',
message: 'descriptions muss ein nicht-leeres Array sein'
});
}
// Validiere jede Beschreibung
for (const desc of descriptions) {
if (!desc.imageId || typeof desc.imageId !== 'number') {
return res.status(400).json({
error: 'Invalid request',
message: 'Jede Beschreibung muss eine gültige imageId enthalten'
});
}
if (desc.description && desc.description.length > 200) {
return res.status(400).json({
error: 'Invalid request',
message: `Bildbeschreibung für Bild ${desc.imageId} darf maximal 200 Zeichen lang sein`
});
}
}
const result = await GroupRepository.updateBatchImageDescriptions(groupId, descriptions);
res.json({
success: true,
message: `${result.updatedImages} Bildbeschreibungen erfolgreich aktualisiert`,
groupId: groupId,
updatedImages: result.updatedImages
});
} catch (error) {
console.error('Error batch updating image descriptions:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Aktualisieren der Bildbeschreibungen',
details: error.message
});
}
});
// Einzelne Bildbeschreibung aktualisieren
router.patch('/groups/:groupId/images/:imageId', async (req, res) => {
try {
const { groupId, imageId } = req.params;
const { image_description } = req.body;
// Validierung: Max 200 Zeichen
if (image_description && image_description.length > 200) {
return res.status(400).json({
error: 'Invalid request',
message: 'Bildbeschreibung darf maximal 200 Zeichen lang sein'
});
}
const updated = await GroupRepository.updateImageDescription(
parseInt(imageId),
groupId,
image_description
);
if (!updated) {
return res.status(404).json({
error: 'Image not found',
message: `Bild mit ID ${imageId} in Gruppe ${groupId} wurde nicht gefunden`
});
}
res.json({
success: true,
message: 'Bildbeschreibung erfolgreich aktualisiert',
groupId: groupId,
imageId: parseInt(imageId),
imageDescription: image_description
});
} catch (error) {
console.error('Error updating image description:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Fehler beim Aktualisieren der Bildbeschreibung',
details: error.message
});
}
});
// Gruppe löschen
router.delete(endpoints.DELETE_GROUP, async (req, res) => {
try {

View File

@ -32,7 +32,8 @@ function formatGroupDetail(groupRow, images) {
previewPath: img.preview_path || null,
uploadOrder: img.upload_order,
fileSize: img.file_size || null,
mimeType: img.mime_type || null
mimeType: img.mime_type || null,
imageDescription: img.image_description || null
})),
imageCount: images.length
};

View File

@ -13,7 +13,6 @@ FROM nginx:stable-alpine
# Nginx config
RUN rm -rf /etc/nginx/conf.d
COPY docker/prod/frontend/nginx.conf /etc/nginx/nginx.conf
COPY frontend/conf /etc/nginx
# Copy htpasswd file for authentication
COPY docker/prod/frontend/config/htpasswd /etc/nginx/.htpasswd

View File

@ -0,0 +1,730 @@
# Feature Plan: Image Description (Bildbeschreibung)
**Branch:** `feature/ImageDescription`
**Datum:** 7. November 2025
**Status:** ✅ Implementiert (bereit für Testing)
---
## 📋 Übersicht
Implementierung einer individuellen Bildbeschreibung für jedes hochgeladene Bild. Benutzer können optional einen kurzen Text (max. 200 Zeichen) für jedes Bild hinzufügen, der dann in der Slideshow und in der GroupsOverviewPage angezeigt wird.
### Hauptänderungen
1. **Button-Änderung:** "Sort" Button wird durch "Edit" Button ersetzt in `ImageGalleryCard.js`
2. **Edit-Modus:** Aktiviert Textfelder unter jedem Bild zur Eingabe von Bildbeschreibungen
3. **Datenbank-Erweiterung:** Neues Feld `image_description` in der `images` Tabelle
4. **Backend-API:** Neue Endpoints zum Speichern und Abrufen von Bildbeschreibungen
5. **Frontend-Integration:** Edit-Modus in `MultiUploadPage.js` und `ModerationGroupImagesPage.js`
6. **Slideshow-Integration:** Anzeige der Bildbeschreibungen während der Slideshow
7. **Groups-Overview:** Anzeige von Bildbeschreibungen in der öffentlichen Übersicht
---
## 🎯 Anforderungen
### Funktionale Anforderungen
- ✅ Benutzer können für jedes Bild eine optionale Beschreibung eingeben
- ✅ Maximale Länge: 200 Zeichen
- ✅ Edit-Button ersetzt Sort-Button in Preview-Modus
- ✅ Edit-Modus zeigt Textfelder unter allen Bildern gleichzeitig
- ✅ Vorbefüllung mit Original-Dateinamen als Platzhalter
- ✅ Funktioniert in `MultiUploadPage.js` und `ModerationGroupImagesPage.js`
- ✅ NICHT in `GroupsOverviewPage.js` (nur Anzeige, kein Edit)
- ✅ Bildbeschreibungen werden in Slideshow angezeigt
- ✅ Bildbeschreibungen werden in GroupsOverviewPage angezeigt
- ✅ Speicherung in Datenbank (persistente Speicherung)
### Nicht-Funktionale Anforderungen
- ✅ Performance: Keine merkbare Verzögerung beim Laden von Bildern
- ✅ UX: Intuitive Bedienung des Edit-Modus
- ✅ Mobile-Optimierung: Touch-freundliche Textfelder
- ✅ Validierung: Client- und Server-seitige Längenbegrenzung
- ✅ Backward-Kompatibilität: Bestehende Bilder ohne Beschreibung funktionieren weiterhin
---
## 🗄️ Datenbank-Schema Änderungen
### Migration: `004_add_image_description.sql`
```sql
-- Add image_description column to images table
ALTER TABLE images ADD COLUMN image_description TEXT;
-- Create index for better performance when filtering/searching
CREATE INDEX IF NOT EXISTS idx_images_description ON images(image_description);
```
### Aktualisiertes Schema (`images` Tabelle)
```sql
CREATE TABLE images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id TEXT NOT NULL,
file_name TEXT NOT NULL,
original_name TEXT NOT NULL,
file_path TEXT NOT NULL,
upload_order INTEGER NOT NULL,
file_size INTEGER,
mime_type TEXT,
preview_path TEXT,
image_description TEXT, -- ← NEU: Optional, max 200 Zeichen
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (group_id) REFERENCES groups(group_id) ON DELETE CASCADE
);
```
---
## 🔧 Backend-Änderungen
### 1. Datenbank-Migration
**Datei:** `backend/src/database/migrations/004_add_image_description.sql`
- Fügt `image_description` Spalte zur `images` Tabelle hinzu
- Erstellt Index für Performance-Optimierung
### 2. API-Erweiterungen
#### A) Bestehende Endpoints erweitern
**`POST /api/upload/batch`**
- Akzeptiert `imageDescriptions` Array im Request Body
- Format: `[{ fileName: string, description: string }, ...]`
- Speichert Beschreibungen beim Upload
**`GET /api/groups/:groupId`**
- Gibt `image_description` für jedes Bild zurück
- Backward-kompatibel (null/leer für alte Bilder)
**`GET /moderation/groups/:groupId`**
- Gibt `image_description` für jedes Bild zurück
#### B) Neue Endpoints
**`PATCH /groups/:groupId/images/:imageId`**
- Aktualisiert `image_description` für einzelnes Bild
- Payload: `{ image_description: string }`
- Validierung: Max 200 Zeichen
**`PATCH /groups/:groupId/images/batch-description`**
- Aktualisiert mehrere Bildbeschreibungen auf einmal
- Payload: `{ descriptions: [{ imageId: number, description: string }, ...] }`
- Effizienter als einzelne Requests
### 3. Repository & Service Layer
**`GroupRepository.js`** - Neue Methoden:
```javascript
async updateImageDescription(imageId, description)
async updateBatchImageDescriptions(groupId, descriptions)
async getImagesByGroupId(groupId) // Erweitert um image_description
```
**`DatabaseManager.js`** - Query-Erweiterungen:
- `SELECT` Queries inkludieren `image_description`
- `INSERT` Queries akzeptieren `image_description`
- `UPDATE` Queries für Beschreibungs-Updates
---
## 🎨 Frontend-Änderungen
### 1. ImageGalleryCard.js
**Änderung:** Button "Sort" → "Edit"
```javascript
// ALT (Zeile 174-179):
<button
className="btn btn-secondary btn-sm"
disabled
>
Sort
</button>
// NEU:
<button
className="btn btn-primary btn-sm"
onClick={() => onEditMode?.(true)}
>
✏️ Edit
</button>
```
**Neue Props:**
- `onEditMode`: Callback-Funktion zum Aktivieren des Edit-Modus
- `isEditMode`: Boolean für aktuellen Edit-Status
- `imageDescription`: String mit Bildbeschreibung
- `onDescriptionChange`: Callback für Beschreibungsänderungen
**Edit-Modus UI:**
```jsx
{isEditMode && mode === 'preview' && (
<div className="image-description-edit">
<textarea
value={imageDescription || ''}
onChange={(e) => onDescriptionChange(itemId, e.target.value)}
placeholder={`Beschreibung für ${originalName}...`}
maxLength={200}
rows={2}
/>
<span className="char-counter">
{(imageDescription || '').length}/200
</span>
</div>
)}
```
### 2. ImageGallery.js
**Neue Props durchreichen:**
- `isEditMode`
- `onEditMode`
- `onDescriptionChange`
**Pass-through zu ImageGalleryCard:**
```javascript
<ImageGalleryCard
// ... existing props
isEditMode={isEditMode}
onEditMode={onEditMode}
imageDescription={item.imageDescription}
onDescriptionChange={onDescriptionChange}
/>
```
### 3. MultiUploadPage.js
**State-Erweiterung:**
```javascript
const [isEditMode, setIsEditMode] = useState(false);
const [imageDescriptions, setImageDescriptions] = useState({});
```
**Neue Handler:**
```javascript
const handleDescriptionChange = (imageId, description) => {
setImageDescriptions(prev => ({
...prev,
[imageId]: description.slice(0, 200) // Enforce max length
}));
};
const handleEditMode = (enabled) => {
setIsEditMode(enabled);
};
```
**Upload-Logik erweitern:**
```javascript
// In handleUpload()
const descriptionsArray = selectedImages.map(img => ({
fileName: img.name,
description: imageDescriptions[img.id] || ''
}));
const result = await uploadImageBatch(
filesToUpload,
metadata,
descriptionsArray // ← NEU
);
```
**Edit-Mode Toggle:**
```jsx
{isEditMode && (
<Box sx={{ textAlign: 'center', my: 2 }}>
<Button
variant="contained"
color="success"
onClick={() => setIsEditMode(false)}
>
✅ Beschreibungen fertig
</Button>
</Box>
)}
```
### 4. ModerationGroupImagesPage.js
**State-Erweiterung:**
```javascript
const [isEditMode, setIsEditMode] = useState(false);
const [imageDescriptions, setImageDescriptions] = useState({});
```
**Load-Funktion erweitern:**
```javascript
// In loadGroup()
if (data.images && data.images.length > 0) {
const mapped = data.images.map(img => ({ ... }));
setSelectedImages(mapped);
// Initialize descriptions from server
const descriptions = {};
data.images.forEach(img => {
if (img.imageDescription) {
descriptions[img.id] = img.imageDescription;
}
});
setImageDescriptions(descriptions);
}
```
**Save-Funktion erweitern:**
```javascript
const handleSaveDescriptions = async () => {
try {
const descriptions = Object.entries(imageDescriptions).map(([id, desc]) => ({
imageId: parseInt(id),
description: desc
}));
const res = await fetch(`/groups/${groupId}/images/batch-description`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ descriptions })
});
if (!res.ok) throw new Error('Speichern fehlgeschlagen');
Swal.fire({
icon: 'success',
title: 'Beschreibungen gespeichert',
timer: 1500
});
setIsEditMode(false);
} catch (e) {
Swal.fire({ icon: 'error', title: 'Fehler beim Speichern' });
}
};
```
### 5. SlideshowPage.js
**Erweitere Bild-Anzeige:**
```jsx
{currentImage && (
<div className="slideshow-container">
<img
src={getImageSrc(currentImage)}
alt={currentImage.originalName}
className="slideshow-image"
/>
{/* NEU: Beschreibung anzeigen */}
{currentImage.imageDescription && (
<div className="slideshow-description">
<p>{currentImage.imageDescription}</p>
</div>
)}
{/* Bestehende Metadaten */}
<div className="slideshow-metadata">
<h2>{currentGroup.title}</h2>
{currentGroup.name && <p>{currentGroup.name}</p>}
</div>
</div>
)}
```
**CSS für Slideshow-Beschreibung:**
```css
.slideshow-description {
position: absolute;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.7);
padding: 15px 30px;
border-radius: 8px;
max-width: 80%;
text-align: center;
}
.slideshow-description p {
color: white;
font-size: 18px;
margin: 0;
line-height: 1.4;
}
```
### 6. GroupsOverviewPage.js
**Keine Edit-Funktion, nur Anzeige**
Beim Anzeigen einzelner Bilder einer Gruppe:
```jsx
{image.imageDescription && (
<p className="image-description">
{image.imageDescription}
</p>
)}
```
### 7. CSS-Erweiterungen
**`ImageGallery.css`**
```css
/* Edit-Mode Textarea Styles */
.image-description-edit {
padding: 10px;
border-top: 1px solid #e0e0e0;
}
.image-description-edit textarea {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-family: 'Roboto', sans-serif;
font-size: 14px;
resize: vertical;
min-height: 50px;
}
.image-description-edit textarea:focus {
outline: none;
border-color: #4CAF50;
box-shadow: 0 0 5px rgba(76, 175, 80, 0.3);
}
.image-description-edit .char-counter {
display: block;
text-align: right;
font-size: 12px;
color: #666;
margin-top: 4px;
}
.image-description-edit .char-counter.limit-reached {
color: #f44336;
font-weight: bold;
}
/* Edit Button Styles */
.btn-edit-mode {
background: linear-gradient(45deg, #2196F3 30%, #1976D2 90%);
color: white;
border: none;
}
.btn-edit-mode:hover {
background: linear-gradient(45deg, #1976D2 30%, #2196F3 90%);
}
/* Display-only description */
.image-description-display {
padding: 10px;
border-top: 1px solid #e0e0e0;
font-size: 14px;
color: #555;
font-style: italic;
}
```
---
## 🔄 Utils & Services
### batchUpload.js
**Signatur-Änderung:**
```javascript
// ALT:
export const uploadImageBatch = async (files, metadata) => { ... }
// NEU:
export const uploadImageBatch = async (files, metadata, descriptions = []) => {
const formData = new FormData();
// Files
files.forEach(file => {
formData.append('images', file);
});
// Metadata
formData.append('metadata', JSON.stringify(metadata));
// Descriptions (NEU)
formData.append('descriptions', JSON.stringify(descriptions));
// ... rest of upload logic
}
```
---
## 🧪 Testing-Strategie
### Backend-Tests
1. **Datenbank-Migration**
- ✅ Migration läuft ohne Fehler
- ✅ Spalte wird korrekt hinzugefügt
- ✅ Bestehende Daten bleiben intakt
2. **API-Endpoints**
- ✅ Upload mit Beschreibungen funktioniert
- ✅ Upload ohne Beschreibungen funktioniert (Backward-Kompatibilität)
- ✅ Validierung: Max 200 Zeichen
- ✅ Batch-Update funktioniert
- ✅ Einzelnes Update funktioniert
- ✅ GET Requests geben Beschreibungen zurück
### Frontend-Tests
1. **MultiUploadPage.js**
- ✅ Edit-Button aktiviert Edit-Modus
- ✅ Textfelder erscheinen unter allen Bildern
- ✅ Zeichenzähler funktioniert
- ✅ Max-Länge wird enforced
- ✅ Upload sendet Beschreibungen mit
- ✅ Platzhalter zeigt Dateinamen
2. **ModerationGroupImagesPage.js**
- ✅ Beschreibungen werden vom Server geladen
- ✅ Edit-Modus aktivierbar
- ✅ Speichern funktioniert
- ✅ Optimistic Updates funktionieren
3. **SlideshowPage.js**
- ✅ Beschreibungen werden angezeigt
- ✅ Layout ist responsive
- ✅ Keine Beschreibung = kein Element
4. **GroupsOverviewPage.js**
- ✅ Beschreibungen werden angezeigt (falls vorhanden)
- ✅ Kein Edit-Button sichtbar
### Manuelle Tests
- [ ] Upload mehrerer Bilder mit verschiedenen Beschreibungen
- [ ] Upload ohne Beschreibungen
- [ ] Bearbeiten bestehender Gruppen
- [ ] Slideshow mit Beschreibungen testen
- [ ] Mobile-Ansicht testen
- [ ] Performance mit vielen Bildern testen
---
## 📝 Implementation TODO
### Phase 1: Backend Foundation ✅
- [ ] **Task 1.1:** Datenbank-Migration erstellen
- [ ] `004_add_image_description.sql` erstellen
- [ ] Migration in `DatabaseManager.js` registrieren
- [ ] Lokale DB testen
- [ ] **Task 1.2:** Repository-Layer erweitern
- [ ] `updateImageDescription()` in `GroupRepository.js`
- [ ] `updateBatchImageDescriptions()` in `GroupRepository.js`
- [ ] `getImagesByGroupId()` erweitern für `image_description`
- [ ] **Task 1.3:** API-Routes implementieren
- [ ] `PATCH /groups/:groupId/images/:imageId` in `routes/groups.js`
- [ ] `PATCH /groups/:groupId/images/batch-description` in `routes/groups.js`
- [ ] Validierung hinzufügen (max 200 Zeichen)
- [ ] GET Routes erweitern (image_description returnen)
- [ ] **Task 1.4:** Upload-Route erweitern
- [ ] `batchUpload.js` Route akzeptiert `descriptions` Parameter
- [ ] Speichere Beschreibungen beim Upload
- [ ] Backward-Kompatibilität testen
### Phase 2: Frontend Core Components ✅
- [ ] **Task 2.1:** ImageGalleryCard.js anpassen
- [ ] "Sort" Button durch "Edit" Button ersetzen
- [ ] Edit-Modus UI implementieren (Textarea)
- [ ] Props hinzufügen: `isEditMode`, `onEditMode`, `imageDescription`, `onDescriptionChange`
- [ ] Zeichenzähler implementieren
- [ ] Validierung (max 200 Zeichen)
- [ ] **Task 2.2:** ImageGallery.js erweitern
- [ ] Neue Props durchreichen
- [ ] Edit-Modus State-Management
- [ ] **Task 2.3:** CSS-Styles hinzufügen
- [ ] `ImageGallery.css` erweitern
- [ ] Textarea-Styles
- [ ] Zeichenzähler-Styles
- [ ] Edit-Button-Styles
- [ ] Mobile-Optimierung
### Phase 3: Upload Flow Integration ✅
- [ ] **Task 3.1:** MultiUploadPage.js erweitern
- [ ] State für Edit-Modus hinzufügen
- [ ] State für Beschreibungen hinzufügen
- [ ] Handler für Edit-Modus implementieren
- [ ] Handler für Beschreibungsänderungen implementieren
- [ ] Upload-Logik erweitern (Beschreibungen mitschicken)
- [ ] Edit-Mode Toggle UI hinzufügen
- [ ] **Task 3.2:** batchUpload.js erweitern
- [ ] Funktionssignatur anpassen (descriptions Parameter)
- [ ] FormData um Beschreibungen erweitern
- [ ] Error-Handling
### Phase 4: Moderation Integration ✅
- [ ] **Task 4.1:** ModerationGroupImagesPage.js erweitern
- [ ] State für Edit-Modus hinzufügen
- [ ] State für Beschreibungen hinzufügen
- [ ] `loadGroup()` erweitern (Beschreibungen laden)
- [ ] Handler für Beschreibungsänderungen implementieren
- [ ] `handleSaveDescriptions()` implementieren
- [ ] Edit-Mode Toggle UI hinzufügen
- [ ] Optimistic Updates
### Phase 5: Slideshow Integration ✅
- [ ] **Task 5.1:** SlideshowPage.js erweitern
- [ ] Beschreibungs-Anzeige UI implementieren
- [ ] CSS für Slideshow-Beschreibung
- [ ] Responsive Design
- [ ] Conditional Rendering (nur wenn Beschreibung vorhanden)
- [ ] **Task 5.2:** Slideshow-Styles
- [ ] `.slideshow-description` CSS
- [ ] Overlay-Styling
- [ ] Animation (optional)
- [ ] Mobile-Ansicht
### Phase 6: Groups Overview Integration ✅
- [ ] **Task 6.1:** GroupsOverviewPage.js erweitern
- [ ] Beschreibungs-Anzeige bei Bilddetails
- [ ] CSS für Beschreibungs-Display
- [ ] Kein Edit-Button (nur Anzeige)
### Phase 7: Testing & Refinement ✅
- [ ] **Task 7.1:** Backend-Tests
- [ ] API-Endpoints testen
- [ ] Datenbank-Migration testen
- [ ] Validierung testen
- [ ] **Task 7.2:** Frontend-Tests
- [ ] Upload-Flow testen
- [ ] Edit-Flow testen
- [ ] Slideshow testen
- [ ] Mobile-Tests
- [ ] **Task 7.3:** Integration-Tests
- [ ] End-to-End Upload-to-Slideshow Test
- [ ] Edit in Moderation Test
- [ ] Performance-Test mit vielen Bildern
- [ ] **Task 7.4:** Bug Fixes & Polish
- [ ] UI/UX Verbesserungen
- [ ] Error-Handling verfeinern
- [ ] Code-Cleanup
### Phase 8: Documentation & Deployment ✅
- [ ] **Task 8.1:** README.md aktualisieren
- [ ] Feature dokumentieren
- [ ] API-Endpoints dokumentieren
- [ ] Screenshots hinzufügen (optional)
- [ ] **Task 8.2:** CHANGELOG.md erweitern
- [ ] Feature hinzufügen
- [ ] Breaking Changes auflisten (falls vorhanden)
- [ ] **Task 8.3:** Migration Guide
- [ ] Deployment-Anweisungen
- [ ] Datenbank-Migration Schritte
- [ ] **Task 8.4:** Final Testing
- [ ] Production-Build testen
- [ ] Docker-Container testen
- [ ] Backup-Restore testen
---
## 🚀 Deployment-Plan
### Schritte für Production Deployment
1. **Datenbank-Migration ausführen**
```bash
# Backup erstellen
docker cp image-uploader-backend:/usr/src/app/src/data/db/image_uploader.db ./backup_before_migration.db
# Migration ausführen
docker exec -it image-uploader-backend npm run migrate
```
2. **Backend neu deployen**
```bash
docker compose -f docker/prod/docker-compose.yml up -d --build backend
```
3. **Frontend neu deployen**
```bash
docker compose -f docker/prod/docker-compose.yml up -d --build frontend
```
4. **Testen**
- Upload mit Beschreibungen
- Slideshow mit Beschreibungen
- Moderation Edit-Modus
### Rollback-Plan
Falls Probleme auftreten:
```bash
# Stoppe Container
docker compose -f docker/prod/docker-compose.yml down
# Restore Datenbank-Backup
docker cp ./backup_before_migration.db image-uploader-backend:/usr/src/app/src/data/db/image_uploader.db
# Checkout previous version
git checkout main
# Rebuild & Restart
docker compose -f docker/prod/docker-compose.yml up -d --build
```
---
## 📊 Erfolgskriterien
- ✅ Benutzer können Bildbeschreibungen beim Upload hinzufügen
- ✅ Benutzer können Bildbeschreibungen in Moderation bearbeiten
- ✅ Beschreibungen werden in Slideshow angezeigt
- ✅ Beschreibungen werden in Groups-Overview angezeigt
- ✅ Keine Performance-Einbußen
- ✅ Mobile-freundlich
- ✅ Backward-kompatibel mit bestehenden Uploads
- ✅ Keine Breaking Changes für bestehende Features
---
## 🔮 Zukünftige Erweiterungen (Optional)
- 🔄 Rich-Text Editor für Beschreibungen (Markdown?)
- 🔄 Mehrsprachige Beschreibungen
- 🔄 Auto-Vervollständigung basierend auf Bild-Metadaten (EXIF)
- 🔄 KI-generierte Beschreibungen (Bild-Erkennung)
- 🔄 Suche nach Bildern via Beschreibung
- 🔄 Bulk-Edit für Beschreibungen (Regex-Replace, etc.)
- 🔄 Export von Beschreibungen als CSV/JSON
---
## 📝 Notizen
- Original-Dateinamen als Platzhalter nutzen für bessere UX
- Validierung sowohl Client- als auch Server-seitig
- Edit-Modus sollte klar visuell erkennbar sein
- Speichern sollte optimistisch erfolgen (sofortiges Feedback)
- Fehler-Handling mit User-freundlichen Nachrichten
---
**Erstellt von:** GitHub Copilot
**Letzte Aktualisierung:** 7. November 2025

View File

@ -259,3 +259,65 @@
flex-direction: column;
}
}
/* Image Description Edit Mode */
.image-description-edit {
padding: 10px;
border-top: 1px solid #e0e0e0;
background-color: #f8f9fa;
}
.image-description-edit textarea {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-family: 'Roboto', sans-serif;
font-size: 14px;
resize: vertical;
min-height: 50px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.image-description-edit textarea:focus {
outline: none;
border-color: #4CAF50;
box-shadow: 0 0 5px rgba(76, 175, 80, 0.3);
}
.image-description-edit .char-counter {
display: block;
text-align: right;
font-size: 12px;
color: #666;
margin-top: 4px;
}
.image-description-edit .char-counter.limit-reached {
color: #f44336;
font-weight: bold;
}
/* Image Description Display Mode */
.image-description-display {
padding: 10px;
border-top: 1px solid #e0e0e0;
font-size: 14px;
color: #555;
font-style: italic;
background-color: #f8f9fa;
line-height: 1.4;
}
/* Edit Button Styles */
.btn-edit-mode {
background: linear-gradient(45deg, #2196F3 30%, #1976D2 90%);
color: white;
border: none;
}
.btn-edit-mode:hover {
background: linear-gradient(45deg, #1976D2 30%, #2196F3 90%);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(33, 150, 243, 0.3);
}

View File

@ -28,7 +28,11 @@ const ImageGallery = ({
title,
emptyMessage = 'Keine Elemente vorhanden',
enableReordering = false,
onReorder = null
onReorder = null,
isEditMode = false,
onEditMode = null,
imageDescriptions = {},
onDescriptionChange = null
}) => {
// Sensors for drag and drop (touch-friendly)
const sensors = useSensors(
@ -84,6 +88,10 @@ const ImageGallery = ({
showActions={showActions}
mode={mode}
enableReordering={enableReordering}
isEditMode={isEditMode}
onEditMode={onEditMode}
imageDescription={imageDescriptions[item.id || item.groupId] || ''}
onDescriptionChange={onDescriptionChange}
/>
))}
</div>
@ -126,7 +134,11 @@ ImageGallery.propTypes = {
title: PropTypes.string,
emptyMessage: PropTypes.string,
enableReordering: PropTypes.bool,
onReorder: PropTypes.func
onReorder: PropTypes.func,
isEditMode: PropTypes.bool,
onEditMode: PropTypes.func,
imageDescriptions: PropTypes.object,
onDescriptionChange: PropTypes.func
};
ImageGallery.defaultProps = {
@ -137,7 +149,11 @@ ImageGallery.defaultProps = {
showActions: true,
mode: 'group',
enableReordering: false,
onReorder: null
onReorder: null,
isEditMode: false,
onEditMode: null,
imageDescriptions: {},
onDescriptionChange: null
};
export default ImageGallery;

View File

@ -17,7 +17,11 @@ const ImageGalleryCard = ({
mode = 'group', // 'group', 'moderation', or 'preview'
hidePreview = false, // Hide the preview image section
enableReordering = false, // Enable drag-and-drop reordering
isDragOverlay = false // Special styling when used as drag overlay
isDragOverlay = false, // Special styling when used as drag overlay
isEditMode = false, // Edit mode for descriptions
onEditMode = null, // Callback to toggle edit mode
imageDescription = '', // Image description text
onDescriptionChange = null // Callback for description changes
}) => {
// Handle both group data and individual image preview data
let previewUrl = null;
@ -138,6 +142,32 @@ const ImageGalleryCard = ({
</p>
)}
{/* Edit-Mode: Textarea for image description */}
{isEditMode && mode === 'preview' && (
<div className="image-description-edit">
<textarea
value={imageDescription || ''}
onChange={(e) => onDescriptionChange?.(itemId, e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
onKeyUp={(e) => e.stopPropagation()}
onKeyPress={(e) => e.stopPropagation()}
placeholder={`Beschreibung für "${item.originalName || item.name}"...`}
maxLength={200}
rows={2}
/>
<span className={`char-counter ${(imageDescription || '').length >= 200 ? 'limit-reached' : ''}`}>
{(imageDescription || '').length}/200
</span>
</div>
)}
{/* Display-only mode: Show existing description */}
{!isEditMode && imageDescription && (mode === 'preview' || mode === 'single-image') && (
<div className="image-description-display">
<em>{imageDescription}</em>
</div>
)}
{/* Additional metadata for preview mode */}
{mode === 'preview' && item.remoteUrl && item.remoteUrl.includes('/download/') && (
<div className="image-gallery-card-file-meta">
@ -164,12 +194,21 @@ const ImageGalleryCard = ({
>
🗑 Löschen
</button>
<button
className="btn btn-secondary btn-sm"
disabled
>
Sort
</button>
{!isEditMode ? (
<button
className="btn btn-primary btn-sm"
onClick={() => onEditMode?.(true)}
>
Edit
</button>
) : (
<button
className="btn btn-success btn-sm"
onClick={() => onEditMode?.(false)}
>
Fertig
</button>
)}
</>
) : (
// Moderation mode actions (for existing groups)
@ -232,7 +271,11 @@ ImageGalleryCard.propTypes = {
mode: PropTypes.oneOf(['group', 'moderation', 'preview', 'single-image']),
hidePreview: PropTypes.bool,
enableReordering: PropTypes.bool,
isDragOverlay: PropTypes.bool
isDragOverlay: PropTypes.bool,
isEditMode: PropTypes.bool,
onEditMode: PropTypes.func,
imageDescription: PropTypes.string,
onDescriptionChange: PropTypes.func
};
ImageGalleryCard.defaultProps = {
@ -244,7 +287,11 @@ ImageGalleryCard.defaultProps = {
mode: 'group',
hidePreview: false,
enableReordering: false,
isDragOverlay: false
isDragOverlay: false,
isEditMode: false,
onEditMode: null,
imageDescription: '',
onDescriptionChange: null
};
export default ImageGalleryCard;

View File

@ -28,13 +28,15 @@ const ModerationGroupImagesPage = () => {
const [selectedImages, setSelectedImages] = useState([]);
const [metadata, setMetadata] = useState({ year: new Date().getFullYear(), title: '', description: '', name: '' });
const [isReordering, setIsReordering] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [imageDescriptions, setImageDescriptions] = useState({});
useEffect(() => {
loadGroup();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [groupId]);
const loadGroup = async () => {
const loadGroup = useCallback(async () => {
try {
setLoading(true);
const res = await fetch(`/moderation/groups/${groupId}`);
@ -45,12 +47,21 @@ const ModerationGroupImagesPage = () => {
// Map group's images to preview-friendly objects
if (data.images && data.images.length > 0) {
const mapped = data.images.map(img => ({
...img, // Pass all image fields including previewPath
...img, // Pass all image fields including previewPath and imageDescription
remoteUrl: `/download/${img.fileName}`, // Keep for backward compatibility
originalName: img.originalName || img.fileName,
id: img.id
}));
setSelectedImages(mapped);
// Initialize descriptions from server
const descriptions = {};
data.images.forEach(img => {
if (img.imageDescription) {
descriptions[img.id] = img.imageDescription;
}
});
setImageDescriptions(descriptions);
}
// populate metadata from group
@ -65,13 +76,13 @@ const ModerationGroupImagesPage = () => {
} finally {
setLoading(false);
}
};
}, [groupId]);
const handleSave = async () => {
if (!group) return;
setSaving(true);
try {
// Use metadata state (controlled by DescriptionInput) as source of truth
// 1. Speichere Gruppen-Metadaten
const payload = {
title: metadata.title,
description: metadata.description,
@ -87,7 +98,28 @@ const ModerationGroupImagesPage = () => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message || 'Speichern fehlgeschlagen');
throw new Error(body.message || 'Speichern der Metadaten fehlgeschlagen');
}
// 2. Speichere Bildbeschreibungen (falls vorhanden)
if (Object.keys(imageDescriptions).length > 0) {
const descriptions = Object.entries(imageDescriptions).map(([id, desc]) => ({
imageId: parseInt(id),
description: desc
}));
console.log('Speichere Beschreibungen:', descriptions);
const descRes = await fetch(`/groups/${groupId}/images/batch-description`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ descriptions })
});
if (!descRes.ok) {
const body = await descRes.json().catch(() => ({}));
throw new Error(body.message || 'Speichern der Beschreibungen fehlgeschlagen');
}
}
Swal.fire({ icon: 'success', title: 'Gruppe erfolgreich aktualisiert', timer: 1500, showConfirmButton: false });
@ -173,6 +205,25 @@ const ModerationGroupImagesPage = () => {
}
}, [groupId, group, isReordering, loadGroup]);
// Handle edit mode toggle
const handleEditMode = (enabled) => {
console.log('🔄 Edit mode toggled:', enabled ? 'ENABLED' : 'DISABLED');
setIsEditMode(enabled);
};
// Handle description changes
const handleDescriptionChange = (imageId, description) => {
console.log('✏️ Description changed for image', imageId, ':', description);
setImageDescriptions(prev => {
const newDescriptions = {
...prev,
[imageId]: description.slice(0, 200) // Enforce max length
};
console.log('📝 Updated imageDescriptions:', newDescriptions);
return newDescriptions;
});
};
// Note: approve/delete group actions are intentionally removed from this page
if (loading) return <div className="moderation-loading">Lade Gruppe...</div>;
@ -192,6 +243,10 @@ const ModerationGroupImagesPage = () => {
isReordering={isReordering}
mode="preview"
showActions={true}
isEditMode={isEditMode}
onEditMode={handleEditMode}
imageDescriptions={imageDescriptions}
onDescriptionChange={handleDescriptionChange}
/>
{selectedImages.length > 0 && (
@ -199,8 +254,17 @@ const ModerationGroupImagesPage = () => {
<DescriptionInput metadata={metadata} onMetadataChange={setMetadata} />
<div className="action-buttons">
<Button className="btn btn-secondary" onClick={() => navigate('/moderation')}> Zurück</Button>
<Button className="primary-button" onClick={handleSave} disabled={saving}>{saving ? 'Speichern...' : 'Speichern'}</Button>
<Button className="btn btn-secondary" onClick={() => navigate('/moderation')}>
Zurück
</Button>
<Button
className="btn btn-success"
onClick={handleSave}
disabled={saving}
style={{ minWidth: '160px' }}
>
{saving ? '⏳ Speichern...' : '💾 Speichern'}
</Button>
</div>
</>
)}

View File

@ -32,6 +32,8 @@ function MultiUploadPage() {
});
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [isEditMode, setIsEditMode] = useState(false);
const [imageDescriptions, setImageDescriptions] = useState({});
// Cleanup object URLs when component unmounts
useEffect(() => {
@ -90,6 +92,8 @@ function MultiUploadPage() {
description: '',
name: ''
});
setImageDescriptions({});
setIsEditMode(false);
};
// Handle drag-and-drop reordering (only updates local state, no API call)
@ -98,6 +102,19 @@ function MultiUploadPage() {
setSelectedImages(reorderedItems);
};
// Handle edit mode toggle
const handleEditMode = (enabled) => {
setIsEditMode(enabled);
};
// Handle description changes
const handleDescriptionChange = (imageId, description) => {
setImageDescriptions(prev => ({
...prev,
[imageId]: description.slice(0, 200) // Enforce max length
}));
};
const handleUpload = async () => {
if (selectedImages.length === 0) {
Swal.fire({
@ -136,7 +153,14 @@ function MultiUploadPage() {
// Extract the actual File objects from our image objects
const filesToUpload = selectedImages.map(img => img.file || img);
const result = await uploadImageBatch(filesToUpload, metadata);
// Prepare descriptions array for backend
const descriptionsArray = selectedImages.map(img => ({
fileName: img.name,
description: imageDescriptions[img.id] || ''
}));
const result = await uploadImageBatch(filesToUpload, metadata, descriptionsArray);
clearInterval(progressInterval);
setUploadProgress(100);
@ -203,6 +227,10 @@ function MultiUploadPage() {
showActions={true}
enableReordering={true}
onReorder={handleReorder}
isEditMode={isEditMode}
onEditMode={handleEditMode}
imageDescriptions={imageDescriptions}
onDescriptionChange={handleDescriptionChange}
/>
{selectedImages.length > 0 && (

View File

@ -52,7 +52,7 @@ const PublicGroupImagesPage = () => {
<ImageGallery
items={group.images && group.images.length > 0 ? group.images.map(img => ({
...img, // Pass all image fields including previewPath
...img, // Pass all image fields including previewPath and imageDescription
remoteUrl: `/download/${img.fileName}`, // Keep for backward compatibility
originalName: img.originalName || img.fileName,
id: img.id
@ -61,6 +61,13 @@ const PublicGroupImagesPage = () => {
enableReordering={false}
mode="single-image"
emptyMessage="Keine Bilder in dieser Gruppe."
imageDescriptions={group.images && group.images.length > 0 ?
group.images.reduce((acc, img) => {
if (img.imageDescription) {
acc[img.id] = img.imageDescription;
}
return acc;
}, {}) : {}}
/>
</Container>

View File

@ -197,6 +197,28 @@ function SlideshowPage() {
transition: `opacity ${TRANSITION_TIME}ms ease-in-out`
};
const imageDescriptionSx = {
position: 'fixed',
bottom: '140px',
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: 'rgba(0,0,0,0.7)',
p: '15px 30px',
borderRadius: '8px',
maxWidth: '80%',
textAlign: 'center',
backdropFilter: 'blur(5px)',
zIndex: 10002
};
const imageDescriptionTextSx = {
color: 'white',
fontSize: '18px',
margin: 0,
lineHeight: 1.4,
fontFamily: 'roboto'
};
const descriptionContainerSx = {
position: 'fixed',
left: 40,
@ -231,6 +253,13 @@ function SlideshowPage() {
{/* Hauptbild */}
<Box component="img" src={getImageSrc(currentImage, false)} alt={currentImage.originalName} sx={{ ...slideshowImageSx, opacity: fadeOut ? 0 : 1 }} />
{/* Bildbeschreibung (wenn vorhanden) */}
{currentImage.imageDescription && (
<Box sx={imageDescriptionSx}>
<Typography sx={imageDescriptionTextSx}>{currentImage.imageDescription}</Typography>
</Box>
)}
{/* Beschreibung */}
<Box sx={descriptionContainerSx}>
{/* Titel */}

View File

@ -1,5 +1,5 @@
// Batch-Upload Funktion für mehrere Bilder
export const uploadImageBatch = async (images, metadata, onProgress) => {
export const uploadImageBatch = async (images, metadata, descriptions = [], onProgress) => {
if (!images || images.length === 0) {
throw new Error('Keine Bilder zum Upload ausgewählt');
}
@ -13,6 +13,11 @@ export const uploadImageBatch = async (images, metadata, onProgress) => {
// Füge Metadaten hinzu
formData.append('metadata', JSON.stringify(metadata || {}));
// Füge Beschreibungen hinzu (NEU)
if (descriptions && descriptions.length > 0) {
formData.append('descriptions', JSON.stringify(descriptions));
}
try {
const response = await fetch('/api/upload/batch', {

View File

@ -58,7 +58,8 @@ case $choice in
4)
echo -e "${GREEN}Baue Container neu...${NC}"
docker compose -f docker/prod/docker-compose.yml down
docker compose -f docker/prod/docker-compose.yml up --build -d
docker compose -f docker/prod/docker-compose.yml build --no-cache
docker compose -f docker/prod/docker-compose.yml up -d
echo -e "${GREEN}Container neu gebaut und gestartet!${NC}"
echo -e "${BLUE}Frontend: http://localhost${NC}"
echo -e "${BLUE}Slideshow: http://localhost/slideshow${NC}"