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 node_modules
npm-debug.log 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 // Batch-Update für mehrere Bildbeschreibungen (MUSS VOR der einzelnen Route stehen!)
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
router.patch('/groups/:groupId/images/batch-description', async (req, res) => { router.patch('/groups/:groupId/images/batch-description', async (req, res) => {
try { try {
const { groupId } = req.params; 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 // Gruppe löschen
router.delete(endpoints.DELETE_GROUP, async (req, res) => { router.delete(endpoints.DELETE_GROUP, async (req, res) => {
try { try {

View File

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

View File

@ -148,6 +148,9 @@ const ImageGalleryCard = ({
<textarea <textarea
value={imageDescription || ''} value={imageDescription || ''}
onChange={(e) => onDescriptionChange?.(itemId, e.target.value)} 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}"...`} placeholder={`Beschreibung für "${item.originalName || item.name}"...`}
maxLength={200} maxLength={200}
rows={2} rows={2}

View File

@ -36,7 +36,7 @@ const ModerationGroupImagesPage = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [groupId]); }, [groupId]);
const loadGroup = async () => { const loadGroup = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
const res = await fetch(`/moderation/groups/${groupId}`); const res = await fetch(`/moderation/groups/${groupId}`);
@ -76,13 +76,13 @@ const ModerationGroupImagesPage = () => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [groupId]);
const handleSave = async () => { const handleSave = async () => {
if (!group) return; if (!group) return;
setSaving(true); setSaving(true);
try { try {
// Use metadata state (controlled by DescriptionInput) as source of truth // 1. Speichere Gruppen-Metadaten
const payload = { const payload = {
title: metadata.title, title: metadata.title,
description: metadata.description, description: metadata.description,
@ -98,7 +98,28 @@ const ModerationGroupImagesPage = () => {
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({})); 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 }); Swal.fire({ icon: 'success', title: 'Gruppe erfolgreich aktualisiert', timer: 1500, showConfirmButton: false });
@ -186,61 +207,21 @@ const ModerationGroupImagesPage = () => {
// Handle edit mode toggle // Handle edit mode toggle
const handleEditMode = (enabled) => { const handleEditMode = (enabled) => {
console.log('🔄 Edit mode toggled:', enabled ? 'ENABLED' : 'DISABLED');
setIsEditMode(enabled); setIsEditMode(enabled);
}; };
// Handle description changes // Handle description changes
const handleDescriptionChange = (imageId, description) => { const handleDescriptionChange = (imageId, description) => {
setImageDescriptions(prev => ({ console.log('✏️ Description changed for image', imageId, ':', description);
...prev, setImageDescriptions(prev => {
[imageId]: description.slice(0, 200) // Enforce max length const newDescriptions = {
})); ...prev,
}; [imageId]: description.slice(0, 200) // Enforce max length
};
// Save descriptions to backend console.log('📝 Updated imageDescriptions:', newDescriptions);
const handleSaveDescriptions = async () => { return newDescriptions;
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
});
}
}; };
// Note: approve/delete group actions are intentionally removed from this page // Note: approve/delete group actions are intentionally removed from this page
@ -273,13 +254,17 @@ const ModerationGroupImagesPage = () => {
<DescriptionInput metadata={metadata} onMetadataChange={setMetadata} /> <DescriptionInput metadata={metadata} onMetadataChange={setMetadata} />
<div className="action-buttons"> <div className="action-buttons">
<Button className="btn btn-secondary" onClick={() => navigate('/moderation')}> Zurück</Button> <Button className="btn btn-secondary" onClick={() => navigate('/moderation')}>
{isEditMode && ( Zurück
<Button className="btn btn-success" onClick={handleSaveDescriptions}> </Button>
💾 Beschreibungen speichern <Button
</Button> className="btn btn-success"
)} onClick={handleSave}
<Button className="primary-button" onClick={handleSave} disabled={saving}>{saving ? 'Speichern...' : 'Speichern'}</Button> disabled={saving}
style={{ minWidth: '160px' }}
>
{saving ? '⏳ Speichern...' : '💾 Speichern'}
</Button>
</div> </div>
</> </>
)} )}

View File

@ -58,7 +58,8 @@ case $choice in
4) 4)
echo -e "${GREEN}Baue Container neu...${NC}" echo -e "${GREEN}Baue Container neu...${NC}"
docker compose -f docker/prod/docker-compose.yml down 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 "${GREEN}Container neu gebaut und gestartet!${NC}"
echo -e "${BLUE}Frontend: http://localhost${NC}" echo -e "${BLUE}Frontend: http://localhost${NC}"
echo -e "${BLUE}Slideshow: http://localhost/slideshow${NC}" echo -e "${BLUE}Slideshow: http://localhost/slideshow${NC}"