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:
parent
d2f2fe158d
commit
07b436cc4d
21
.dockerignore
Normal file
21
.dockerignore
Normal 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/
|
||||
|
|
@ -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/
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
3
prod.sh
3
prod.sh
|
|
@ -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}"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user