diff --git a/backend/src/routes/management.js b/backend/src/routes/management.js index f36baa1..2e591ab 100644 --- a/backend/src/routes/management.js +++ b/backend/src/routes/management.js @@ -216,6 +216,102 @@ router.put('/:token/consents', async (req, res) => { } }); +/** + * PUT /api/manage/:token/images/descriptions + * Batch update image descriptions for a group + * + * Body: + * - descriptions: [{ imageId: number, description: string }, ...] + * + * @returns {Object} Update result with count of updated images + * @throws {400} Invalid request or validation error + * @throws {404} Token invalid or not found + * @throws {500} Server error + */ +router.put('/:token/images/descriptions', async (req, res) => { + try { + const { token } = req.params; + const { descriptions } = req.body; + + // Validate token format + if (!validateToken(token)) { + return res.status(404).json({ + success: false, + error: 'Invalid management token format' + }); + } + + // Validate descriptions array + if (!Array.isArray(descriptions) || descriptions.length === 0) { + return res.status(400).json({ + success: false, + error: 'descriptions must be a non-empty array' + }); + } + + // Validate each description + for (const desc of descriptions) { + if (!desc.imageId || typeof desc.imageId !== 'number') { + return res.status(400).json({ + success: false, + error: 'Each description must contain a valid imageId' + }); + } + if (desc.description && desc.description.length > 200) { + return res.status(400).json({ + success: false, + error: `Description for image ${desc.imageId} exceeds 200 characters` + }); + } + } + + // Load group by management token + const groupData = await groupRepository.getGroupByManagementToken(token); + + if (!groupData) { + return res.status(404).json({ + success: false, + error: 'Management token not found or group has been deleted' + }); + } + + // Update descriptions + let updatedCount = 0; + for (const desc of descriptions) { + const updated = await groupRepository.updateImageDescription( + desc.imageId, + groupData.groupId, + desc.description || null + ); + if (updated) { + updatedCount++; + } + } + + await res.auditLog('update_image_descriptions', true, groupData.groupId, + `Updated ${updatedCount} image descriptions`); + + res.json({ + success: true, + message: `${updatedCount} image description(s) updated successfully`, + data: { + groupId: groupData.groupId, + updatedImages: updatedCount, + totalRequested: descriptions.length + } + }); + + } catch (error) { + console.error('Error updating image descriptions:', error); + await res.auditLog('update_image_descriptions', false, null, error.message); + + res.status(500).json({ + success: false, + error: 'Failed to update image descriptions' + }); + } +}); + /** * PUT /api/manage/:token/metadata * Update group metadata (title, description, name) diff --git a/frontend/src/Components/ComponentUtils/ConsentManager.js b/frontend/src/Components/ComponentUtils/ConsentManager.js new file mode 100644 index 0000000..e780746 --- /dev/null +++ b/frontend/src/Components/ComponentUtils/ConsentManager.js @@ -0,0 +1,263 @@ +import React, { useState } from 'react'; +import { Box, Alert, Typography } from '@mui/material'; +import ConsentCheckboxes from './MultiUpload/ConsentCheckboxes'; + +/** + * Manages consents with save functionality + * Wraps ConsentCheckboxes and provides save for workshop + social media consents + */ +function ConsentManager({ + initialConsents, + token, + groupId, + onRefresh +}) { + // Initialize with proper defaults + const defaultConsents = { + workshopConsent: false, + socialMediaConsents: [] + }; + + const [consents, setConsents] = useState(defaultConsents); + const [originalConsents, setOriginalConsents] = useState(defaultConsents); + const [saving, setSaving] = useState(false); + const [initialized, setInitialized] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [showEmailHint, setShowEmailHint] = useState(false); + + // Update ONLY ONCE when initialConsents first arrives + React.useEffect(() => { + if (initialConsents && !initialized) { + // Deep copy to avoid shared references + const consentsCopy = { + workshopConsent: initialConsents.workshopConsent, + socialMediaConsents: [...(initialConsents.socialMediaConsents || [])] + }; + setConsents(consentsCopy); + + // Separate deep copy for original + const originalCopy = { + workshopConsent: initialConsents.workshopConsent, + socialMediaConsents: [...(initialConsents.socialMediaConsents || [])] + }; + setOriginalConsents(originalCopy); + + setInitialized(true); + } + }, [initialConsents, initialized]); + + const hasChanges = () => { + // Check workshop consent + if (consents.workshopConsent !== originalConsents.workshopConsent) { + return true; + } + + // Check social media consents - sort before comparing (order doesn't matter) + const currentIds = (consents.socialMediaConsents || []).map(c => c.platformId).sort((a, b) => a - b); + const originalIds = (originalConsents.socialMediaConsents || []).map(c => c.platformId).sort((a, b) => a - b); + + // Different lengths = definitely changed + if (currentIds.length !== originalIds.length) { + return true; + } + + // Compare sorted arrays element by element + for (let i = 0; i < currentIds.length; i++) { + if (currentIds[i] !== originalIds[i]) { + return true; + } + } + + return false; + }; + + // Check if social media consent was revoked (for email hint) + const hasSocialMediaRevocations = () => { + const currentIds = new Set((consents.socialMediaConsents || []).map(c => c.platformId)); + const originalIds = new Set((originalConsents.socialMediaConsents || []).map(c => c.platformId)); + + // Check if any original platform is missing in current + for (let platformId of originalIds) { + if (!currentIds.has(platformId)) { + return true; + } + } + return false; + }; + + const handleSave = async () => { + if (!hasChanges()) { + return; + } + + try { + setSaving(true); + setSuccessMessage(''); + setErrorMessage(''); + + // Detect changes + const changes = []; + + // Workshop consent change + if (consents.workshopConsent !== originalConsents.workshopConsent) { + changes.push({ + consentType: 'workshop', + action: consents.workshopConsent ? 'restore' : 'revoke' + }); + } + + // Social media consent changes + const originalSocialIds = new Set(originalConsents.socialMediaConsents.map(c => c.platformId)); + const currentSocialIds = new Set(consents.socialMediaConsents.map(c => c.platformId)); + + // Revoked social media consents + const revoked = []; + originalSocialIds.forEach(platformId => { + if (!currentSocialIds.has(platformId)) { + revoked.push(platformId); + changes.push({ + consentType: 'social_media', + action: 'revoke', + platformId + }); + } + }); + + // Restored social media consents + currentSocialIds.forEach(platformId => { + if (!originalSocialIds.has(platformId)) { + changes.push({ + consentType: 'social_media', + action: 'restore', + platformId + }); + } + }); + + // Save each change + for (const change of changes) { + const res = await fetch(`/api/manage/${token}/consents`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(change) + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || 'Fehler beim Speichern der Einwilligung'); + } + } + + // Show success message + setSuccessMessage('Einwilligungen wurden erfolgreich gespeichert.'); + + // Show email hint after saving if social media was revoked + setShowEmailHint(revoked.length > 0); + + // Update original consents with deep copy + setOriginalConsents({ + workshopConsent: consents.workshopConsent, + socialMediaConsents: [...(consents.socialMediaConsents || [])] + }); + + // Don't refresh - just show success message + + } catch (error) { + console.error('Error saving consents:', error); + setErrorMessage(error.message || 'Einwilligungen konnten nicht gespeichert werden'); + } finally { + setSaving(false); + } + }; + + const handleDiscard = () => { + setConsents({ + ...originalConsents, + socialMediaConsents: [...(originalConsents.socialMediaConsents || [])] + }); + setSuccessMessage(''); + setErrorMessage(''); + setShowEmailHint(false); + }; + + const handleConsentChange = (newConsents) => { + // Force new object reference so React detects the change + setConsents({ + workshopConsent: newConsents.workshopConsent, + socialMediaConsents: [...(newConsents.socialMediaConsents || [])] + }); + }; + + return ( + + {/* Success Message */} + {successMessage && ( + + {successMessage} + + )} + + {/* Email Hint - show IMMEDIATELY when social media revoked (before save) */} + {hasChanges() && hasSocialMediaRevocations() && !successMessage && ( + + Hinweis: Bei Widerruf einer Social Media Einwilligung müssen Sie nach dem Speichern + eine E-Mail an{' '} + + info@hobbyhimmel.de + {' '} + senden, um die Löschung Ihrer Bilder anzufordern. + + )} + + {/* Email Hint after successful save */} + {showEmailHint && successMessage && ( + + Wichtig: Bitte senden Sie jetzt eine E-Mail an{' '} + + info@hobbyhimmel.de + {' '} + mit Ihrer Gruppen-ID, um die Löschung Ihrer Bilder auf den Social Media Plattformen anzufordern. + + )} + + {/* Error Message */} + {errorMessage && ( + + {errorMessage} + + )} + + {/* Action Buttons */} + {hasChanges() && ( + + + + + + )} + + ); +} + +export default ConsentManager; diff --git a/frontend/src/Components/ComponentUtils/DeleteGroupButton.js b/frontend/src/Components/ComponentUtils/DeleteGroupButton.js new file mode 100644 index 0000000..64096cf --- /dev/null +++ b/frontend/src/Components/ComponentUtils/DeleteGroupButton.js @@ -0,0 +1,102 @@ +import React, { useState } from 'react'; +import { Button } from '@mui/material'; +import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; +import Swal from 'sweetalert2'; +import { useNavigate } from 'react-router-dom'; + +/** + * Delete group button with confirmation dialog + * Standalone component for group deletion + */ +function DeleteGroupButton({ token, groupName = 'diese Gruppe' }) { + const [deleting, setDeleting] = useState(false); + const navigate = useNavigate(); + + const handleDelete = async () => { + const result = await Swal.fire({ + title: 'Gruppe komplett löschen?', + html: `Achtung: Diese Aktion kann nicht rückgängig gemacht werden!

+ Alle Bilder und Daten von "${groupName}" werden unwiderruflich gelöscht.`, + icon: 'warning', + showCancelButton: true, + confirmButtonColor: '#d33', + cancelButtonColor: '#3085d6', + confirmButtonText: 'Ja, alles löschen', + cancelButtonText: 'Abbrechen', + input: 'checkbox', + inputPlaceholder: 'Ich verstehe, dass diese Aktion unwiderruflich ist' + }); + + if (!result.isConfirmed || !result.value) { + if (result.isConfirmed && !result.value) { + Swal.fire({ + icon: 'info', + title: 'Bestätigung erforderlich', + text: 'Bitte bestätigen Sie das Kontrollkästchen, um fortzufahren.' + }); + } + return; + } + + try { + setDeleting(true); + + const res = await fetch(`/api/manage/${token}`, { + method: 'DELETE' + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || 'Fehler beim Löschen'); + } + + await Swal.fire({ + icon: 'success', + title: 'Gruppe gelöscht', + text: 'Die Gruppe und alle Bilder wurden erfolgreich gelöscht.', + timer: 2000, + showConfirmButton: false + }); + + navigate('/'); + + } catch (error) { + console.error('Error deleting group:', error); + Swal.fire({ + icon: 'error', + title: 'Fehler', + text: error.message || 'Gruppe konnte nicht gelöscht werden' + }); + setDeleting(false); + } + }; + + return ( + + ); +} + +export default DeleteGroupButton; diff --git a/frontend/src/Components/ComponentUtils/GroupMetadataEditor.js b/frontend/src/Components/ComponentUtils/GroupMetadataEditor.js new file mode 100644 index 0000000..06ae97a --- /dev/null +++ b/frontend/src/Components/ComponentUtils/GroupMetadataEditor.js @@ -0,0 +1,146 @@ +import React, { useState } from 'react'; +import { Box, Typography, Paper } from '@mui/material'; +import Swal from 'sweetalert2'; +import DescriptionInput from './MultiUpload/DescriptionInput'; + +/** + * Manages group metadata with save functionality + * Wraps DescriptionInput and provides save for title, description, name, year + */ +function GroupMetadataEditor({ + initialMetadata, + token, + onRefresh +}) { + const [metadata, setMetadata] = useState(initialMetadata || { + year: new Date().getFullYear(), + title: '', + description: '', + name: '' + }); + const [originalMetadata, setOriginalMetadata] = useState(initialMetadata || { + year: new Date().getFullYear(), + title: '', + description: '', + name: '' + }); + const [saving, setSaving] = useState(false); + + // Update when initialMetadata changes + React.useEffect(() => { + if (initialMetadata) { + setMetadata(initialMetadata); + setOriginalMetadata(initialMetadata); + } + }, [initialMetadata]); + + const hasChanges = () => { + return JSON.stringify(metadata) !== JSON.stringify(originalMetadata); + }; + + const handleSave = async () => { + if (!hasChanges()) { + Swal.fire({ + icon: 'info', + title: 'Keine Änderungen', + text: 'Es wurden keine Änderungen an den Metadaten vorgenommen.' + }); + return; + } + + try { + setSaving(true); + + const res = await fetch(`/api/manage/${token}/metadata`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(metadata) + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || 'Fehler beim Speichern der Metadaten'); + } + + await Swal.fire({ + icon: 'success', + title: 'Gespeichert', + text: 'Metadaten wurden erfolgreich aktualisiert. Gruppe wird erneut moderiert.', + timer: 2000, + showConfirmButton: false + }); + + // Update original metadata + setOriginalMetadata(metadata); + + // Refresh data if callback provided + if (onRefresh) { + await onRefresh(); + } + + } catch (error) { + console.error('Error saving metadata:', error); + Swal.fire({ + icon: 'error', + title: 'Fehler', + text: error.message || 'Metadaten konnten nicht gespeichert werden' + }); + } finally { + setSaving(false); + } + }; + + const handleDiscard = () => { + setMetadata(originalMetadata); + Swal.fire({ + icon: 'info', + title: 'Verworfen', + text: 'Änderungen wurden zurückgesetzt.', + timer: 1500, + showConfirmButton: false + }); + }; + + return ( + + {/* Component Header */} + + 📝 Projekt-Informationen + + + + + {hasChanges() && ( + + + + + + )} + + ); +} + +export default GroupMetadataEditor; diff --git a/frontend/src/Components/ComponentUtils/ImageDescriptionManager.js b/frontend/src/Components/ComponentUtils/ImageDescriptionManager.js new file mode 100644 index 0000000..4448771 --- /dev/null +++ b/frontend/src/Components/ComponentUtils/ImageDescriptionManager.js @@ -0,0 +1,175 @@ +import React, { useState } from 'react'; +import { Box, Typography, Paper } from '@mui/material'; +import Swal from 'sweetalert2'; +import ImageGallery from './ImageGallery'; + +/** + * Manages image descriptions with save functionality + * Wraps ImageGallery and provides batch save for all descriptions + */ +function ImageDescriptionManager({ + images, + token, + enableReordering = false, + onReorder, + onRefresh +}) { + const [imageDescriptions, setImageDescriptions] = useState({}); + const [originalDescriptions, setOriginalDescriptions] = useState({}); + const [isEditMode, setIsEditMode] = useState(false); + const [saving, setSaving] = useState(false); + + // Initialize descriptions from images + React.useEffect(() => { + if (images && images.length > 0) { + const descriptions = {}; + images.forEach(img => { + descriptions[img.id] = img.imageDescription || ''; + }); + setImageDescriptions(descriptions); + setOriginalDescriptions(descriptions); + } + }, [images]); + + const handleDescriptionChange = (imageId, description) => { + setImageDescriptions(prev => ({ + ...prev, + [imageId]: description + })); + }; + + const hasChanges = () => { + return JSON.stringify(imageDescriptions) !== JSON.stringify(originalDescriptions); + }; + + const handleSave = async () => { + if (!hasChanges()) { + Swal.fire({ + icon: 'info', + title: 'Keine Änderungen', + text: 'Es wurden keine Änderungen an den Beschreibungen vorgenommen.' + }); + return; + } + + try { + setSaving(true); + + // Build descriptions array for API + const descriptions = Object.entries(imageDescriptions).map(([imageId, description]) => ({ + imageId: parseInt(imageId), + description: description || null + })); + + const res = await fetch(`/api/manage/${token}/images/descriptions`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ descriptions }) + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || 'Fehler beim Speichern der Beschreibungen'); + } + + await Swal.fire({ + icon: 'success', + title: 'Gespeichert', + text: 'Bildbeschreibungen wurden erfolgreich aktualisiert.', + timer: 2000, + showConfirmButton: false + }); + + // Update original descriptions + setOriginalDescriptions(imageDescriptions); + + // Refresh data if callback provided + if (onRefresh) { + await onRefresh(); + } + + } catch (error) { + console.error('Error saving descriptions:', error); + Swal.fire({ + icon: 'error', + title: 'Fehler', + text: error.message || 'Beschreibungen konnten nicht gespeichert werden' + }); + } finally { + setSaving(false); + } + }; + + const handleDiscard = () => { + setImageDescriptions(originalDescriptions); + setIsEditMode(false); + }; + + const handleEditToggle = () => { + if (isEditMode && hasChanges()) { + // Warn user if trying to leave edit mode with unsaved changes + Swal.fire({ + icon: 'warning', + title: 'Ungespeicherte Änderungen', + text: 'Du hast ungespeicherte Änderungen. Bitte speichere oder verwerfe sie zuerst.', + confirmButtonText: 'OK' + }); + return; // Don't toggle edit mode + } + + if (isEditMode) { + // Discard changes when leaving edit mode without saving + setImageDescriptions({ ...originalDescriptions }); + } + setIsEditMode(!isEditMode); + }; + + return ( + + {/* Component Header */} + + Bildbeschreibungen + + + + + {hasChanges() && ( + + + + + + )} + + ); +} + +export default ImageDescriptionManager; diff --git a/frontend/src/Components/ComponentUtils/MultiUpload/ConsentCheckboxes.js b/frontend/src/Components/ComponentUtils/MultiUpload/ConsentCheckboxes.js index a4b489e..255525f 100644 --- a/frontend/src/Components/ComponentUtils/MultiUpload/ConsentCheckboxes.js +++ b/frontend/src/Components/ComponentUtils/MultiUpload/ConsentCheckboxes.js @@ -38,7 +38,8 @@ function ConsentCheckboxes({ consents, disabled = false, mode = 'upload', - groupId = null + groupId = null, + children }) { const [platforms, setPlatforms] = useState([]); const [loading, setLoading] = useState(true); @@ -109,6 +110,13 @@ function ConsentCheckboxes({ border: '2px solid #e0e0e0' }} > + {/* Component Header for manage mode */} + {isManageMode && ( + + Einwilligungen + + )} + {/* Aufklärungshinweis */} } sx={{ mb: 3 }}> @@ -227,6 +235,9 @@ function ConsentCheckboxes({ )} + + {/* Additional content from parent (e.g., save buttons) */} + {children} ); } diff --git a/frontend/src/Components/ComponentUtils/MultiUpload/DescriptionInput.js b/frontend/src/Components/ComponentUtils/MultiUpload/DescriptionInput.js index dcd52d7..fe0470d 100644 --- a/frontend/src/Components/ComponentUtils/MultiUpload/DescriptionInput.js +++ b/frontend/src/Components/ComponentUtils/MultiUpload/DescriptionInput.js @@ -72,8 +72,6 @@ function DescriptionInput({ return ( - 📝 Projekt-Informationen - diff --git a/frontend/src/Components/Pages/ManagementPortalPage.js b/frontend/src/Components/Pages/ManagementPortalPage.js index be03046..706a9e4 100644 --- a/frontend/src/Components/Pages/ManagementPortalPage.js +++ b/frontend/src/Components/Pages/ManagementPortalPage.js @@ -1,727 +1,313 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { Button, Container, Box, Typography, Paper } from '@mui/material'; -import Swal from 'sweetalert2/dist/sweetalert2.js'; -import 'sweetalert2/src/sweetalert2.scss'; - -// Components +import { Container, Card, CardContent, Typography, Box, Button } from '@mui/material'; +import Swal from 'sweetalert2'; import Navbar from '../ComponentUtils/Headers/Navbar'; import Footer from '../ComponentUtils/Footer'; -import ImageGallery from '../ComponentUtils/ImageGallery'; +import Loading from '../ComponentUtils/LoadingAnimation/Loading'; import ImageGalleryCard from '../ComponentUtils/ImageGalleryCard'; -import DescriptionInput from '../ComponentUtils/MultiUpload/DescriptionInput'; import ConsentBadges from '../ComponentUtils/ConsentBadges'; -import ConsentCheckboxes from '../ComponentUtils/MultiUpload/ConsentCheckboxes'; +import MultiImageDropzone from '../ComponentUtils/MultiUpload/MultiImageDropzone'; +import ImageDescriptionManager from '../ComponentUtils/ImageDescriptionManager'; +import GroupMetadataEditor from '../ComponentUtils/GroupMetadataEditor'; +import ConsentManager from '../ComponentUtils/ConsentManager'; +import DeleteGroupButton from '../ComponentUtils/DeleteGroupButton'; -// Icons -import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; -import CancelIcon from '@mui/icons-material/Cancel'; +/** + * ManagementPortalPage - Self-service management for uploaded groups + * + * Modulare Struktur mit individuellen Komponenten: + * - ImageDescriptionManager: Bildbeschreibungen bearbeiten + * - GroupMetadataEditor: Gruppenmetadaten bearbeiten + * - ConsentManager: Einwilligungen verwalten + * - DeleteGroupButton: Gruppe löschen + */ +function ManagementPortalPage() { + const { token } = useParams(); + const navigate = useNavigate(); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [group, setGroup] = useState(null); -const ManagementPortalPage = () => { - const { token } = useParams(); - const navigate = useNavigate(); - - const [group, setGroup] = useState(null); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - // State from ModerationGroupImagesPage - const [selectedImages, setSelectedImages] = useState([]); - const [metadata, setMetadata] = useState({ - year: new Date().getFullYear(), - title: '', - description: '', - name: '' - }); - const [imageDescriptions, setImageDescriptions] = useState({}); - const [isEditMode, setIsEditMode] = useState(false); - - // Pending consent changes (collected locally before saving) - const [pendingConsentChanges, setPendingConsentChanges] = useState([]); - - // Current consents (for ConsentCheckboxes component - includes pending changes) - const [currentConsents, setCurrentConsents] = useState({ - workshopConsent: false, - socialMediaConsents: [] - }); - - // All available social media platforms - const [allPlatforms, setAllPlatforms] = useState([]); + // Load group data + const loadGroup = async () => { + try { + setLoading(true); + setError(null); + + const res = await fetch(`/api/manage/${token}`); + + if (res.status === 404) { + setError('Ungültiger oder abgelaufener Verwaltungslink'); + return; + } + + if (res.status === 429) { + setError('Zu viele Anfragen. Bitte versuchen Sie es später erneut.'); + return; + } + + if (!res.ok) { + throw new Error('Fehler beim Laden der Gruppe'); + } + + const response = await res.json(); + const data = response.data || response; + + // Transform data + const transformedData = { + ...data, + displayInWorkshop: data.displayInWorkshop || data.display_in_workshop, + consentTimestamp: data.consentTimestamp || data.consent_timestamp, + consents: { + workshopConsent: (data.displayInWorkshop === 1 || data.display_in_workshop === 1), + socialMediaConsents: (data.socialMediaConsents || []) + .filter(c => c.consented === 1 && c.revoked === 0) + .map(c => ({ platformId: c.platform_id, consented: true })) + }, + metadata: { + year: data.year || new Date().getFullYear(), + title: data.title || '', + description: data.description || '', + name: data.name || '' + }, + images: (data.images || []).map(img => ({ + ...img, + remoteUrl: `/download/${img.fileName}`, + originalName: img.originalName || img.fileName, + id: img.id, + imageDescription: img.imageDescription || '' + })) + }; + + setGroup(transformedData); + + } catch (e) { + console.error('Error loading group:', e); + setError('Fehler beim Laden der Gruppe'); + } finally { + setLoading(false); + } + }; - useEffect(() => { - loadGroup(); - loadAllPlatforms(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [token]); - - // Reset pending changes when group is reloaded - useEffect(() => { - if (group) { - setPendingConsentChanges([]); - // Initialize currentConsents from group data - const workshopStatus = group.consents?.workshopConsent || false; - const socialMediaStatus = allPlatforms.map(platform => { - const consent = group.consents?.socialMediaConsents?.find(c => c.platformId === platform.id); - const isActive = consent ? (consent.consented && !consent.revoked) : false; - return isActive ? { platformId: platform.id, consented: true } : null; - }).filter(Boolean); - - setCurrentConsents({ - workshopConsent: workshopStatus, - socialMediaConsents: socialMediaStatus - }); - } - }, [group, allPlatforms]); - - const loadAllPlatforms = async () => { - try { - const res = await fetch('/api/social-media/platforms'); - if (res.ok) { - const data = await res.json(); - // Backend returns array directly, not wrapped in {platforms: [...]} - setAllPlatforms(Array.isArray(data) ? data : []); - } - } catch (e) { - console.error('Error loading platforms:', e); - } - }; + useEffect(() => { + if (token) { + loadGroup(); + } + }, [token]); // eslint-disable-line react-hooks/exhaustive-deps - const loadGroup = useCallback(async () => { - try { - setLoading(true); - setError(null); - - // Token validation + group data loading - const res = await fetch(`/api/manage/${token}`); - - if (res.status === 404) { - setError('Ungültiger oder abgelaufener Verwaltungslink'); - setLoading(false); - return; - } - - if (res.status === 429) { - setError('Zu viele Anfragen. Bitte versuchen Sie es später erneut.'); - setLoading(false); - return; - } - - if (!res.ok) { - throw new Error('Fehler beim Laden der Gruppe'); - } - - const response = await res.json(); - const data = response.data || response; // Handle both {data: ...} and direct response - - // Transform data to match expected structure for ConsentBadges and internal use - const transformedData = { - ...data, - // Keep snake_case for ConsentBadges component compatibility - display_in_workshop: data.displayInWorkshop, - consent_timestamp: data.consentTimestamp, - // Add transformed consents for our UI - consents: { - workshopConsent: data.displayInWorkshop === 1, - socialMediaConsents: (data.socialMediaConsents || []).map(c => ({ - platformId: c.platform_id, - platformName: c.platform_name, - platformDisplayName: c.display_name, - consented: c.consented === 1, - revoked: c.revoked === 1 - })) - } - }; - - setGroup(transformedData); + // Handle adding new images + const handleImagesSelected = async (newImages) => { + try { + const formData = new FormData(); + newImages.forEach(file => { + formData.append('images', file); + }); - // Map images to preview-friendly objects (same as ModerationGroupImagesPage) - if (data.images && data.images.length > 0) { - const mapped = data.images.map(img => ({ - ...img, - remoteUrl: `/download/${img.fileName}`, - 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); - } + const res = await fetch(`/api/manage/${token}/images`, { + method: 'POST', + body: formData + }); - // Populate metadata from group - setMetadata({ - year: data.year || new Date().getFullYear(), - title: data.title || '', - description: data.description || '', - name: data.name || '' - }); - - } catch (e) { - console.error('Error loading group:', e); - setError('Fehler beim Laden der Gruppe'); - } finally { - setLoading(false); - } - }, [token]); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || 'Fehler beim Hochladen'); + } - // Handle metadata save - const handleSaveMetadata = async () => { - if (!group) return; - setSaving(true); - - try { - const payload = { - title: metadata.title, - description: metadata.description, - year: metadata.year, - name: metadata.name - }; + await Swal.fire({ + icon: 'success', + title: 'Bilder hinzugefügt', + text: `${newImages.length} Bild(er) wurden erfolgreich hinzugefügt.`, + timer: 2000, + showConfirmButton: false + }); - const res = await fetch(`/api/manage/${token}/metadata`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); + // Reload group data + await loadGroup(); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || 'Fehler beim Speichern'); - } + } catch (error) { + console.error('Error adding images:', error); + Swal.fire({ + icon: 'error', + title: 'Fehler', + text: error.message || 'Bilder konnten nicht hinzugefügt werden' + }); + } + }; - await Swal.fire({ - icon: 'success', - title: 'Metadaten gespeichert', - text: 'Ihre Änderungen wurden gespeichert und müssen erneut moderiert werden.', - timer: 3000, - showConfirmButton: true - }); - - // Reload group to get updated approval status - await loadGroup(); - - } catch (error) { - console.error('Error saving metadata:', error); - Swal.fire({ - icon: 'error', - title: 'Fehler', - text: error.message || 'Metadaten konnten nicht gespeichert werden' - }); - } finally { - setSaving(false); - } - }; - - // Handle image deletion - const handleRemoveImage = async (imageId) => { - const result = await Swal.fire({ - title: 'Bild löschen?', - text: 'Möchten Sie dieses Bild wirklich löschen?', - icon: 'warning', - showCancelButton: true, - confirmButtonColor: '#d33', - cancelButtonColor: '#3085d6', - confirmButtonText: 'Ja, löschen', - cancelButtonText: 'Abbrechen' - }); - - if (!result.isConfirmed) return; - - try { - const res = await fetch(`/api/manage/${token}/images/${imageId}`, { - method: 'DELETE' - }); - - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || 'Fehler beim Löschen'); - } - - // Update local state - setSelectedImages(prev => prev.filter(img => img.id !== imageId)); - - Swal.fire({ - icon: 'success', - title: 'Bild gelöscht', - timer: 1500, - showConfirmButton: false - }); - - // Reload to get updated group state - await loadGroup(); - - } catch (error) { - console.error('Error deleting image:', error); - Swal.fire({ - icon: 'error', - title: 'Fehler', - text: error.message || 'Bild konnte nicht gelöscht werden' - }); - } - }; - - // Handle consent changes from ConsentCheckboxes component - const handleConsentChange = (newConsents) => { - setCurrentConsents(newConsents); - - if (!group) return; - - const changes = []; - - // Check workshop consent change - const originalWorkshop = group.consents?.workshopConsent || false; - if (newConsents.workshopConsent !== originalWorkshop) { - changes.push({ - consentType: 'workshop', - action: newConsents.workshopConsent ? 'restore' : 'revoke', - platformId: null - }); - } - - // Check social media consent changes - allPlatforms.forEach(platform => { - const consent = group.consents?.socialMediaConsents?.find(c => c.platformId === platform.id); - const originalStatus = consent ? (consent.consented && !consent.revoked) : false; - const newStatus = newConsents.socialMediaConsents?.some(c => c.platformId === platform.id) || false; - - if (newStatus !== originalStatus) { - changes.push({ - consentType: 'social_media', - action: newStatus ? 'restore' : 'revoke', - platformId: platform.id - }); - } - }); - - setPendingConsentChanges(changes); - }; - - // Handle consent revocation (collect locally, don't save yet) - const handleRevokeConsent = (consentType, platformId = null) => { - const change = { consentType, action: 'revoke', platformId }; - setPendingConsentChanges(prev => { - // Remove any previous change for the same consent - const filtered = prev.filter(c => - !(c.consentType === consentType && c.platformId === platformId) - ); - return [...filtered, change]; - }); - }; - - // Handle consent restoration (collect locally, don't save yet) - const handleRestoreConsent = (consentType, platformId = null) => { - const change = { consentType, action: 'restore', platformId }; - setPendingConsentChanges(prev => { - // Remove any previous change for the same consent - const filtered = prev.filter(c => - !(c.consentType === consentType && c.platformId === platformId) - ); - return [...filtered, change]; - }); - }; - - // Save all pending consent changes - const handleSaveConsentChanges = async () => { - if (pendingConsentChanges.length === 0) return; - - setSaving(true); - try { - // Send all changes to backend - for (const change of pendingConsentChanges) { - const payload = change.consentType === 'workshop' - ? { consentType: 'workshop', action: change.action } - : { consentType: 'social_media', action: change.action, platformId: change.platformId }; - - const res = await fetch(`/api/manage/${token}/consents`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || 'Fehler beim Speichern'); - } - } - - await Swal.fire({ - icon: 'success', - title: 'Änderungen gespeichert', - text: 'Ihre Einwilligungsänderungen wurden erfolgreich gespeichert.', - timer: 2000, - showConfirmButton: false - }); - - // Reload group to get updated consent status - await loadGroup(); - - } catch (error) { - console.error('Error saving consent changes:', error); - Swal.fire({ - icon: 'error', - title: 'Fehler', - text: error.message || 'Änderungen konnten nicht gespeichert werden' - }); - } finally { - setSaving(false); - } - }; - - // Helper: Get effective consent status considering pending changes - const getEffectiveConsentStatus = (consentType, platformId = null) => { - // Check if there's a pending change for this consent - const pendingChange = pendingConsentChanges.find(c => - c.consentType === consentType && c.platformId === platformId - ); - - if (pendingChange) { - return pendingChange.action === 'restore'; // true if restoring, false if revoking - } - - // No pending change, return current status - if (consentType === 'workshop') { - return group?.consents?.workshopConsent || false; - } else if (consentType === 'social_media') { - const consent = group?.consents?.socialMediaConsents?.find(c => c.platformId === platformId); - return consent ? (consent.consented && !consent.revoked) : false; - } - - return false; - }; - - // Helper: Generate mailto link for revoked social media consents - const getMailtoLink = () => { - if (!group) return ''; - - const revokedPlatforms = pendingConsentChanges - .filter(c => c.consentType === 'social_media' && c.action === 'revoke') - .map(c => { - // Look up platform name in allPlatforms (works even if consent was never granted) - const platform = allPlatforms.find(p => p.id === c.platformId); - return platform?.display_name || 'Unbekannte Plattform'; - }); - - if (revokedPlatforms.length === 0) return ''; - - const subject = `Löschung Social Media Posts - Gruppe ${group.groupId}`; - const body = `Hallo, ich habe die Einwilligung zur Veröffentlichung auf folgenden Plattformen widerrufen: ${revokedPlatforms.join(', ')}. Bitte löschen Sie die bereits veröffentlichten Beiträge meiner Gruppe ${group.groupId}. Vielen Dank`; - - return `mailto:it@hobbyhimmel.de?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; - }; - - // Handle group deletion - const handleDeleteGroup = async () => { - const result = await Swal.fire({ - title: 'Gruppe komplett löschen?', - html: `Achtung: Diese Aktion kann nicht rückgängig gemacht werden!

- Alle Bilder und Daten dieser Gruppe werden unwiderruflich gelöscht.`, - icon: 'warning', - showCancelButton: true, - confirmButtonColor: '#d33', - cancelButtonColor: '#3085d6', - confirmButtonText: 'Ja, alles löschen', - cancelButtonText: 'Abbrechen', - input: 'checkbox', - inputPlaceholder: 'Ich verstehe, dass diese Aktion unwiderruflich ist' - }); - - if (!result.isConfirmed || !result.value) { - if (result.isConfirmed && !result.value) { - Swal.fire({ - icon: 'info', - title: 'Bestätigung erforderlich', - text: 'Bitte bestätigen Sie das Kontrollkästchen, um fortzufahren.' - }); - } - return; - } - - try { - const res = await fetch(`/api/manage/${token}`, { - method: 'DELETE' - }); - - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || 'Fehler beim Löschen'); - } - - await Swal.fire({ - icon: 'success', - title: 'Gruppe gelöscht', - text: 'Ihre Gruppe wurde erfolgreich gelöscht.', - timer: 2000, - showConfirmButton: false - }); - - // Redirect to home page - navigate('/'); - - } catch (error) { - console.error('Error deleting group:', error); - Swal.fire({ - icon: 'error', - title: 'Fehler', - text: error.message || 'Gruppe konnte nicht gelöscht werden' - }); - } - }; - - // Handle edit mode toggle - const handleEditMode = (enabled) => { - setIsEditMode(enabled); - }; - - // Handle description changes - const handleDescriptionChange = (imageId, description) => { - setImageDescriptions(prev => ({ - ...prev, - [imageId]: description.slice(0, 200) - })); - }; - - if (loading) { - return ( -
- - - Lade Ihre Gruppe... - -
-
- ); + const handleReorder = async (newOrder) => { + if (!group || !group.groupId) { + console.error('No groupId available for reordering'); + return; } - if (error) { - return ( -
- - - - - - Zugriff nicht möglich - - - {error} - - - - -
-
- ); - } + try { + const imageIds = newOrder.map(img => img.id); + + const response = await fetch(`/api/groups/${group.groupId}/reorder`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ imageIds: imageIds }) + }); - if (!group) { - return ( -
- - - Gruppe nicht gefunden - -
-
- ); - } + if (!response.ok) { + throw new Error('Reihenfolge konnte nicht gespeichert werden'); + } + await Swal.fire({ + icon: 'success', + title: 'Gespeichert', + text: 'Die neue Reihenfolge wurde gespeichert.', + timer: 1500, + showConfirmButton: false + }); + + await loadGroup(); + } catch (error) { + console.error('Error reordering images:', error); + Swal.fire({ + icon: 'error', + title: 'Fehler', + text: error.message || 'Reihenfolge konnte nicht gespeichert werden' + }); + } + }; + + if (loading) { return ( -
- - - - - {/* Header */} - - Mein Upload verwalten - - - {/* Group Overview Card */} - - - - {/* Consent Badges */} - - - Erteilte Einwilligungen: - - - - - - {/* Consent Management Section */} - {group.consents && ( - <> - - - {/* Save Changes Section (only if there are pending changes) */} - {pendingConsentChanges.length > 0 && ( - - - ⚠️ Sie haben {pendingConsentChanges.length} ungespeicherte Änderung{pendingConsentChanges.length !== 1 ? 'en' : ''} - - - {/* Show mailto link if social media consents are being revoked */} - {getMailtoLink() && ( - - - Bereits veröffentlichte Social Media Beiträge löschen? - - - Kontaktieren Sie uns für die Löschung bereits veröffentlichter Beiträge: - - { - e.currentTarget.style.backgroundColor = '#e3f2fd'; - }} - onMouseOut={(e) => { - e.currentTarget.style.backgroundColor = 'transparent'; - }} - > - 📧 E-Mail an it@hobbyhimmel.de - - - )} - - - - - )} - - )} - - {/* Image Gallery */} - - - Ihre Bilder - - - - - {/* Metadata Editor */} - {selectedImages.length > 0 && ( - - - Metadaten bearbeiten - - - Änderungen an Metadaten setzen die Freigabe zurück und müssen erneut moderiert werden. - - - - - - - - )} - - {/* Delete Group Section */} - - - Gefährliche Aktionen - - - Diese Aktion kann nicht rückgängig gemacht werden. Alle Bilder und Daten werden unwiderruflich gelöscht. - - - - - - -
-
+
+ + + + +
+
); -}; + } + + if (error) { + return ( +
+ + + + + {error} + + + {error} + + + + +
+
+ ); + } + + return ( +
+ + + + + + + Mein Upload verwalten + + + Hier können Sie Ihre hochgeladenen Bilder verwalten, Metadaten bearbeiten und Einwilligungen ändern. + + + {/* Group Overview */} + {group && ( + + + + + + Erteilte Einwilligungen: + + + + + )} + + {/* Add Images Dropzone */} + + + Weitere Bilder hinzufügen + + + + + {/* Image Descriptions Manager */} + {group && group.images && group.images.length > 0 && ( + + + + )} + + {/* Group Metadata Editor */} + {group && ( + + + + )} + + {/* Consent Manager */} + {group && ( + + + + )} + + {/* Delete Group Button */} + {group && ( + + + + )} + + + + +
+
+
+ ); +} export default ManagementPortalPage; + diff --git a/frontend/src/Utils/batchUpload.js b/frontend/src/Utils/batchUpload.js index d7e6bab..7e0ef30 100644 --- a/frontend/src/Utils/batchUpload.js +++ b/frontend/src/Utils/batchUpload.js @@ -1,5 +1,5 @@ // Batch-Upload Funktion für mehrere Bilder -export const uploadImageBatch = async (images, metadata, descriptions = [], consents = null, onProgress) => { +export const uploadImageBatch = async ({ images, metadata, imageDescriptions = {}, consents = null, onProgress }) => { if (!images || images.length === 0) { throw new Error('Keine Bilder zum Upload ausgewählt'); } @@ -14,9 +14,13 @@ export const uploadImageBatch = async (images, metadata, descriptions = [], cons // Füge Metadaten hinzu formData.append('metadata', JSON.stringify(metadata || {})); - // Füge Beschreibungen hinzu - if (descriptions && descriptions.length > 0) { - formData.append('descriptions', JSON.stringify(descriptions)); + // Füge Beschreibungen hinzu (convert object to array format) + const descriptionsArray = Object.entries(imageDescriptions).map(([id, description]) => ({ + imageId: id, + description + })); + if (descriptionsArray.length > 0) { + formData.append('descriptions', JSON.stringify(descriptionsArray)); } // Füge Einwilligungen hinzu (GDPR)