diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1ab5c0c --- /dev/null +++ b/.dockerignore @@ -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/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 135e247..cd02a05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 15cb47b..edc6802 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/TODO.md b/TODO.md index e1ae82e..cb11347 100644 --- a/TODO.md +++ b/TODO.md @@ -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. diff --git a/backend/.dockerignore b/backend/.dockerignore index 1125523..f185386 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -1,3 +1,8 @@ node_modules npm-debug.log -upload/ \ No newline at end of file +upload/ +src/data/db/*.db +src/data/db/*.db-* +src/data/images/ +src/data/previews/ +src/data/groups/ \ No newline at end of file diff --git a/backend/src/database/DatabaseManager.js b/backend/src/database/DatabaseManager.js index b0eeee1..861f4c0 100644 --- a/backend/src/database/DatabaseManager.js +++ b/backend/src/database/DatabaseManager.js @@ -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)'); diff --git a/backend/src/database/schema.sql b/backend/src/database/schema.sql index 964856e..efd03f5 100644 --- a/backend/src/database/schema.sql +++ b/backend/src/database/schema.sql @@ -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 ); diff --git a/backend/src/repositories/GroupRepository.js b/backend/src/repositories/GroupRepository.js index c7333d4..34b9475 100644 --- a/backend/src/repositories/GroupRepository.js +++ b/backend/src/repositories/GroupRepository.js @@ -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(); \ No newline at end of file diff --git a/backend/src/routes/batchUpload.js b/backend/src/routes/batchUpload.js index 39bdd43..5b15e75 100644 --- a/backend/src/routes/batchUpload.js +++ b/backend/src/routes/batchUpload.js @@ -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`); diff --git a/backend/src/routes/groups.js b/backend/src/routes/groups.js index 73f7177..5523578 100644 --- a/backend/src/routes/groups.js +++ b/backend/src/routes/groups.js @@ -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 { diff --git a/backend/src/utils/groupFormatter.js b/backend/src/utils/groupFormatter.js index 8ec5a02..910f120 100644 --- a/backend/src/utils/groupFormatter.js +++ b/backend/src/utils/groupFormatter.js @@ -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 }; diff --git a/docker/prod/frontend/Dockerfile b/docker/prod/frontend/Dockerfile index d05afe3..21df38c 100644 --- a/docker/prod/frontend/Dockerfile +++ b/docker/prod/frontend/Dockerfile @@ -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 diff --git a/docs/FEATURE_PLAN-image-description.md b/docs/FEATURE_PLAN-image-description.md new file mode 100644 index 0000000..960308f --- /dev/null +++ b/docs/FEATURE_PLAN-image-description.md @@ -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): + + +// NEU: + +``` + +**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' && ( +
+