feat: Complete image description feature implementation

Features:
- Add image description field (max 200 chars) for individual images
- Replace 'Sort' button with 'Edit' button in image gallery cards
- Enable edit mode with text fields for each image in moderation
- Display descriptions in slideshow and public views
- Integrate description saving with main save button

Frontend changes:
- ImageGalleryCard: Add edit mode UI with textarea and character counter
- ModerationGroupImagesPage: Integrate description editing into main save flow
- Fix keyboard event propagation in textarea (spacebar issue)
- Remove separate 'Save Descriptions' button
- Add ESLint fixes for useCallback dependencies

Backend changes:
- Fix route order: batch-description route must come before :imageId route
- Ensure batch description update API works correctly

Build optimizations:
- Add .dockerignore to exclude development data (182MB reduction)
- Fix Dockerfile: Remove non-existent frontend/conf directory
- Reduce backend image size from 437MB to 247MB

Fixes:
- Fix route matching issue with batch-description endpoint
- Prevent keyboard events from triggering drag-and-drop
- Clean up unused functions and ESLint warnings
This commit is contained in:
Matthias Lotz 2025-11-07 23:20:50 +01:00
parent d2f2fe158d
commit 07b436cc4d
7 changed files with 124 additions and 110 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,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

@ -214,52 +214,7 @@ router.delete('/groups/:groupId/images/:imageId', async (req, res) => {
}
});
// 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
});
}
});
// Batch-Update für mehrere Bildbeschreibungen
// 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;
@ -308,6 +263,51 @@ router.patch('/groups/:groupId/images/batch-description', async (req, res) => {
}
});
// 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

@ -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

@ -148,6 +148,9 @@ const ImageGalleryCard = ({
<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}

View File

@ -36,7 +36,7 @@ const ModerationGroupImagesPage = () => {
// 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}`);
@ -76,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,
@ -98,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 });
@ -186,61 +207,21 @@ const ModerationGroupImagesPage = () => {
// Handle edit mode toggle
const handleEditMode = (enabled) => {
console.log('🔄 Edit mode toggled:', enabled ? 'ENABLED' : 'DISABLED');
setIsEditMode(enabled);
};
// Handle description changes
const handleDescriptionChange = (imageId, description) => {
setImageDescriptions(prev => ({
...prev,
[imageId]: description.slice(0, 200) // Enforce max length
}));
};
// Save descriptions to backend
const handleSaveDescriptions = async () => {
if (Object.keys(imageDescriptions).length === 0) {
Swal.fire({
icon: 'info',
title: 'Keine Änderungen',
text: 'Es wurden keine Beschreibungen geändert',
timer: 1500
});
return;
}
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) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message || 'Speichern fehlgeschlagen');
}
Swal.fire({
icon: 'success',
title: 'Beschreibungen gespeichert',
timer: 1500,
showConfirmButton: false
});
setIsEditMode(false);
} catch (e) {
console.error(e);
Swal.fire({
icon: 'error',
title: 'Fehler beim Speichern',
text: e.message
});
}
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
@ -273,13 +254,17 @@ const ModerationGroupImagesPage = () => {
<DescriptionInput metadata={metadata} onMetadataChange={setMetadata} />
<div className="action-buttons">
<Button className="btn btn-secondary" onClick={() => navigate('/moderation')}> Zurück</Button>
{isEditMode && (
<Button className="btn btn-success" onClick={handleSaveDescriptions}>
💾 Beschreibungen speichern
</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

@ -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}"