Refactor: Create modular component architecture for ManagementPortalPage

- Created new modular components:
  * ConsentManager: Manages workshop + social media consents with individual save
  * GroupMetadataEditor: Manages group metadata (title, description, name, year) with save
  * ImageDescriptionManager: Manages image descriptions with batch save
  * DeleteGroupButton: Standalone group deletion component

- Refactored ManagementPortalPage to use modular components:
  * Each component in Paper box with heading inside (not outside)
  * HTML buttons with CSS classes (btn btn-success, btn btn-secondary)
  * Inline feedback with Material-UI Alert instead of SweetAlert2 popups
  * Icons: 💾 save, ↩ discard, 🗑️ delete
  * Individual save/discard functionality per component

- Enhanced ConsentCheckboxes component:
  * Added children prop for flexible composition
  * Conditional heading for manage mode inside Paper box

- Fixed DescriptionInput:
  * Removed duplicate heading (now only in parent component)

- React state management improvements:
  * Deep copy pattern for nested objects/arrays
  * Sorted array comparison for order-insensitive change detection
  * Set-based comparison for detecting removed items
  * Initialization guard to prevent useEffect overwrites

- Bug fixes:
  * Fixed image reordering using existing /api/groups/:groupId/reorder route
  * Fixed edit mode toggle with unsaved changes warning
  * Fixed consent state updates with proper object references
  * Fixed uploadImageBatch signature to use object destructuring
  * Removed unnecessary /api/manage/:token/reorder route from backend

Next: Apply same modular pattern to MultiUploadPage and ModerationGroupImagesPage
This commit is contained in:
Matthias Lotz 2025-11-15 17:25:51 +01:00
parent 324c46d735
commit 4b9feec887
9 changed files with 1092 additions and 711 deletions

View File

@ -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 * PUT /api/manage/:token/metadata
* Update group metadata (title, description, name) * Update group metadata (title, description, name)

View File

@ -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 (
<ConsentCheckboxes
consents={consents}
onConsentChange={handleConsentChange}
disabled={saving}
mode="manage"
groupId={groupId}
token={token}
onSave={null}
>
{/* Success Message */}
{successMessage && (
<Alert severity="success" sx={{ mt: 3 }}>
{successMessage}
</Alert>
)}
{/* Email Hint - show IMMEDIATELY when social media revoked (before save) */}
{hasChanges() && hasSocialMediaRevocations() && !successMessage && (
<Alert severity="warning" sx={{ mt: 3 }}>
<strong>Hinweis:</strong> Bei Widerruf einer Social Media Einwilligung müssen Sie nach dem Speichern
eine E-Mail an{' '}
<a href="mailto:info@hobbyhimmel.de" style={{ color: '#2196F3', fontWeight: 'bold' }}>
info@hobbyhimmel.de
</a>{' '}
senden, um die Löschung Ihrer Bilder anzufordern.
</Alert>
)}
{/* Email Hint after successful save */}
{showEmailHint && successMessage && (
<Alert severity="info" sx={{ mt: 2 }}>
<strong>Wichtig:</strong> Bitte senden Sie jetzt eine E-Mail an{' '}
<a href="mailto:info@hobbyhimmel.de" style={{ color: '#2196F3', fontWeight: 'bold' }}>
info@hobbyhimmel.de
</a>{' '}
mit Ihrer Gruppen-ID, um die Löschung Ihrer Bilder auf den Social Media Plattformen anzufordern.
</Alert>
)}
{/* Error Message */}
{errorMessage && (
<Alert severity="error" sx={{ mt: 2 }}>
{errorMessage}
</Alert>
)}
{/* Action Buttons */}
{hasChanges() && (
<Box sx={{ mt: 3, display: 'flex', gap: 1, justifyContent: 'center' }}>
<button
className="btn btn-success"
onClick={handleSave}
disabled={saving}
>
{saving ? '⏳ Speichern...' : '💾 Einwilligungen speichern'}
</button>
<button
className="btn btn-secondary"
onClick={handleDiscard}
disabled={saving}
>
Verwerfen
</button>
</Box>
)}
</ConsentCheckboxes>
);
}
export default ConsentManager;

View File

@ -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: `<strong>Achtung:</strong> Diese Aktion kann nicht rückgängig gemacht werden!<br><br>
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 (
<Button
sx={{
borderRadius: '25px',
px: '30px',
py: '12px',
fontSize: '16px',
fontWeight: 500,
textTransform: 'none',
border: '2px solid #f44336',
color: '#f44336',
backgroundColor: 'transparent',
'&:hover': {
backgroundColor: '#f44336',
color: 'white',
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(244, 67, 54, 0.3)'
}
}}
onClick={handleDelete}
disabled={deleting}
size="large"
>
<DeleteForeverIcon sx={{ mr: 1 }} /> Gruppe löschen
</Button>
);
}
export default DeleteGroupButton;

View File

@ -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 (
<Paper
sx={{
p: 3,
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
border: '2px solid #e0e0e0'
}}
>
{/* Component Header */}
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
📝 Projekt-Informationen
</Typography>
<DescriptionInput
metadata={metadata}
onMetadataChange={setMetadata}
/>
{hasChanges() && (
<Box sx={{ mt: 2, display: 'flex', gap: 1, justifyContent: 'center' }}>
<button
className="btn btn-success"
onClick={handleSave}
disabled={saving}
>
{saving ? '⏳ Speichern...' : '💾 Informationen speichern'}
</button>
<button
className="btn btn-secondary"
onClick={handleDiscard}
disabled={saving}
>
Verwerfen
</button>
</Box>
)}
</Paper>
);
}
export default GroupMetadataEditor;

View File

@ -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 (
<Paper
sx={{
p: 3,
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
border: '2px solid #e0e0e0'
}}
>
{/* Component Header */}
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
Bildbeschreibungen
</Typography>
<ImageGallery
items={images}
mode="preview"
isEditMode={isEditMode}
onEditMode={handleEditToggle}
enableReordering={enableReordering}
onReorder={onReorder}
imageDescriptions={imageDescriptions}
onDescriptionChange={handleDescriptionChange}
/>
{hasChanges() && (
<Box sx={{ mt: 2, display: 'flex', gap: 1, justifyContent: 'center' }}>
<button
className="btn btn-success"
onClick={handleSave}
disabled={saving}
>
{saving ? '⏳ Speichern...' : '💾 Beschreibungen speichern'}
</button>
<button
className="btn btn-secondary"
onClick={handleDiscard}
disabled={saving}
>
Verwerfen
</button>
</Box>
)}
</Paper>
);
}
export default ImageDescriptionManager;

View File

@ -38,7 +38,8 @@ function ConsentCheckboxes({
consents, consents,
disabled = false, disabled = false,
mode = 'upload', mode = 'upload',
groupId = null groupId = null,
children
}) { }) {
const [platforms, setPlatforms] = useState([]); const [platforms, setPlatforms] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -109,6 +110,13 @@ function ConsentCheckboxes({
border: '2px solid #e0e0e0' border: '2px solid #e0e0e0'
}} }}
> >
{/* Component Header for manage mode */}
{isManageMode && (
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
Einwilligungen
</Typography>
)}
{/* Aufklärungshinweis */} {/* Aufklärungshinweis */}
<Alert severity="info" icon={<InfoIcon />} sx={{ mb: 3 }}> <Alert severity="info" icon={<InfoIcon />} sx={{ mb: 3 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}> <Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
@ -227,6 +235,9 @@ function ConsentCheckboxes({
</Typography> </Typography>
</Alert> </Alert>
)} )}
{/* Additional content from parent (e.g., save buttons) */}
{children}
</Paper> </Paper>
); );
} }

View File

@ -72,8 +72,6 @@ function DescriptionInput({
return ( return (
<Box sx={{ marginTop: '20px', marginBottom: '20px' }}> <Box sx={{ marginTop: '20px', marginBottom: '20px' }}>
<Typography sx={sectionTitleSx}>📝 Projekt-Informationen</Typography>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12} sm={6}> <Grid item xs={12} sm={6}>
<Typography sx={fieldLabelSx}> <Typography sx={fieldLabelSx}>

View File

@ -1,109 +1,50 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { Button, Container, Box, Typography, Paper } from '@mui/material'; import { Container, Card, CardContent, Typography, Box, Button } from '@mui/material';
import Swal from 'sweetalert2/dist/sweetalert2.js'; import Swal from 'sweetalert2';
import 'sweetalert2/src/sweetalert2.scss';
// Components
import Navbar from '../ComponentUtils/Headers/Navbar'; import Navbar from '../ComponentUtils/Headers/Navbar';
import Footer from '../ComponentUtils/Footer'; import Footer from '../ComponentUtils/Footer';
import ImageGallery from '../ComponentUtils/ImageGallery'; import Loading from '../ComponentUtils/LoadingAnimation/Loading';
import ImageGalleryCard from '../ComponentUtils/ImageGalleryCard'; import ImageGalleryCard from '../ComponentUtils/ImageGalleryCard';
import DescriptionInput from '../ComponentUtils/MultiUpload/DescriptionInput';
import ConsentBadges from '../ComponentUtils/ConsentBadges'; 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'; * ManagementPortalPage - Self-service management for uploaded groups
import CancelIcon from '@mui/icons-material/Cancel'; *
* Modulare Struktur mit individuellen Komponenten:
const ManagementPortalPage = () => { * - ImageDescriptionManager: Bildbeschreibungen bearbeiten
* - GroupMetadataEditor: Gruppenmetadaten bearbeiten
* - ConsentManager: Einwilligungen verwalten
* - DeleteGroupButton: Gruppe löschen
*/
function ManagementPortalPage() {
const { token } = useParams(); const { token } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [group, setGroup] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [group, setGroup] = useState(null);
// State from ModerationGroupImagesPage // Load group data
const [selectedImages, setSelectedImages] = useState([]); const loadGroup = async () => {
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([]);
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);
}
};
const loadGroup = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
// Token validation + group data loading
const res = await fetch(`/api/manage/${token}`); const res = await fetch(`/api/manage/${token}`);
if (res.status === 404) { if (res.status === 404) {
setError('Ungültiger oder abgelaufener Verwaltungslink'); setError('Ungültiger oder abgelaufener Verwaltungslink');
setLoading(false);
return; return;
} }
if (res.status === 429) { if (res.status === 429) {
setError('Zu viele Anfragen. Bitte versuchen Sie es später erneut.'); setError('Zu viele Anfragen. Bitte versuchen Sie es später erneut.');
setLoading(false);
return; return;
} }
@ -112,56 +53,35 @@ const ManagementPortalPage = () => {
} }
const response = await res.json(); const response = await res.json();
const data = response.data || response; // Handle both {data: ...} and direct response const data = response.data || response;
// Transform data to match expected structure for ConsentBadges and internal use // Transform data
const transformedData = { const transformedData = {
...data, ...data,
// Keep snake_case for ConsentBadges component compatibility displayInWorkshop: data.displayInWorkshop || data.display_in_workshop,
display_in_workshop: data.displayInWorkshop, consentTimestamp: data.consentTimestamp || data.consent_timestamp,
consent_timestamp: data.consentTimestamp,
// Add transformed consents for our UI
consents: { consents: {
workshopConsent: data.displayInWorkshop === 1, workshopConsent: (data.displayInWorkshop === 1 || data.display_in_workshop === 1),
socialMediaConsents: (data.socialMediaConsents || []).map(c => ({ socialMediaConsents: (data.socialMediaConsents || [])
platformId: c.platform_id, .filter(c => c.consented === 1 && c.revoked === 0)
platformName: c.platform_name, .map(c => ({ platformId: c.platform_id, consented: true }))
platformDisplayName: c.display_name, },
consented: c.consented === 1, metadata: {
revoked: c.revoked === 1
}))
}
};
setGroup(transformedData);
// 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);
}
// Populate metadata from group
setMetadata({
year: data.year || new Date().getFullYear(), year: data.year || new Date().getFullYear(),
title: data.title || '', title: data.title || '',
description: data.description || '', description: data.description || '',
name: data.name || '' 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) { } catch (e) {
console.error('Error loading group:', e); console.error('Error loading group:', e);
@ -169,331 +89,99 @@ const ManagementPortalPage = () => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [token]);
// 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
}; };
const res = await fetch(`/api/manage/${token}/metadata`, { useEffect(() => {
method: 'PUT', if (token) {
headers: { 'Content-Type': 'application/json' }, loadGroup();
body: JSON.stringify(payload) }
}, [token]); // eslint-disable-line react-hooks/exhaustive-deps
// Handle adding new images
const handleImagesSelected = async (newImages) => {
try {
const formData = new FormData();
newImages.forEach(file => {
formData.append('images', file);
});
const res = await fetch(`/api/manage/${token}/images`, {
method: 'POST',
body: formData
}); });
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));
throw new Error(body.error || 'Fehler beim Speichern'); throw new Error(body.error || 'Fehler beim Hochladen');
} }
await Swal.fire({ await Swal.fire({
icon: 'success', icon: 'success',
title: 'Metadaten gespeichert', title: 'Bilder hinzugefügt',
text: 'Ihre Änderungen wurden gespeichert und müssen erneut moderiert werden.', text: `${newImages.length} Bild(er) wurden erfolgreich hinzugefügt.`,
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, timer: 2000,
showConfirmButton: false showConfirmButton: false
}); });
// Reload group to get updated consent status // Reload group data
await loadGroup(); await loadGroup();
} catch (error) { } catch (error) {
console.error('Error saving consent changes:', error); console.error('Error adding images:', error);
Swal.fire({ Swal.fire({
icon: 'error', icon: 'error',
title: 'Fehler', title: 'Fehler',
text: error.message || 'Änderungen konnten nicht gespeichert werden' text: error.message || 'Bilder konnten nicht hinzugefügt werden'
}); });
} finally {
setSaving(false);
} }
}; };
// Helper: Get effective consent status considering pending changes const handleReorder = async (newOrder) => {
const getEffectiveConsentStatus = (consentType, platformId = null) => { if (!group || !group.groupId) {
// Check if there's a pending change for this consent console.error('No groupId available for reordering');
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: `<strong>Achtung:</strong> Diese Aktion kann nicht rückgängig gemacht werden!<br><br>
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; return;
} }
try { try {
const res = await fetch(`/api/manage/${token}`, { const imageIds = newOrder.map(img => img.id);
method: 'DELETE'
const response = await fetch(`/api/groups/${group.groupId}/reorder`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageIds: imageIds })
}); });
if (!res.ok) { if (!response.ok) {
const body = await res.json().catch(() => ({})); throw new Error('Reihenfolge konnte nicht gespeichert werden');
throw new Error(body.error || 'Fehler beim Löschen');
} }
await Swal.fire({ await Swal.fire({
icon: 'success', icon: 'success',
title: 'Gruppe gelöscht', title: 'Gespeichert',
text: 'Ihre Gruppe wurde erfolgreich gelöscht.', text: 'Die neue Reihenfolge wurde gespeichert.',
timer: 2000, timer: 1500,
showConfirmButton: false showConfirmButton: false
}); });
// Redirect to home page await loadGroup();
navigate('/');
} catch (error) { } catch (error) {
console.error('Error deleting group:', error); console.error('Error reordering images:', error);
Swal.fire({ Swal.fire({
icon: 'error', icon: 'error',
title: 'Fehler', title: 'Fehler',
text: error.message || 'Gruppe konnte nicht gelöscht werden' text: error.message || 'Reihenfolge konnte nicht gespeichert 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) { if (loading) {
return ( return (
<div className="allContainer"> <div className="allContainer">
<Navbar /> <Navbar />
<Container maxWidth="lg" style={{ marginTop: '40px', textAlign: 'center' }}> <Container maxWidth="lg" sx={{ pt: '20px', pb: '40px', minHeight: '80vh', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Typography variant="h5">Lade Ihre Gruppe...</Typography> <Loading />
</Container> </Container>
<div className="footerContainer"><Footer /></div> <Footer />
</div> </div>
); );
} }
@ -502,37 +190,20 @@ const ManagementPortalPage = () => {
return ( return (
<div className="allContainer"> <div className="allContainer">
<Navbar /> <Navbar />
<Container maxWidth="lg" style={{ marginTop: '40px' }}> <Container maxWidth="lg" sx={{ pt: '20px', pb: '40px', minHeight: '80vh' }}>
<Paper sx={{ p: 4, textAlign: 'center' }}> <Card sx={{ borderRadius: '12px', boxShadow: '0 4px 12px rgba(0,0,0,0.1)', p: '20px', textAlign: 'center' }}>
<CancelIcon sx={{ fontSize: 60, color: '#d32f2f', mb: 2 }} /> <Typography variant="h5" color="error" gutterBottom>
<Typography variant="h5" gutterBottom color="error">
Zugriff nicht möglich
</Typography>
<Typography variant="body1" color="text.secondary">
{error} {error}
</Typography> </Typography>
<Button <Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
variant="contained" {error}
onClick={() => navigate('/')} </Typography>
sx={{ mt: 3 }} <Button variant="contained" onClick={() => navigate('/')}>
>
Zur Startseite Zur Startseite
</Button> </Button>
</Paper> </Card>
</Container> </Container>
<div className="footerContainer"><Footer /></div> <Footer />
</div>
);
}
if (!group) {
return (
<div className="allContainer">
<Navbar />
<Container maxWidth="lg" style={{ marginTop: '40px' }}>
<Typography variant="h5" color="error">Gruppe nicht gefunden</Typography>
</Container>
<div className="footerContainer"><Footer /></div>
</div> </div>
); );
} }
@ -541,15 +212,19 @@ const ManagementPortalPage = () => {
<div className="allContainer"> <div className="allContainer">
<Navbar /> <Navbar />
<Container maxWidth="lg" className="page-container" style={{ marginTop: '40px' }}> <Container maxWidth="lg" sx={{ pt: '20px', pb: '40px', minHeight: '80vh' }}>
<Card sx={{ borderRadius: '12px', boxShadow: '0 4px 12px rgba(0,0,0,0.1)', p: '20px', mb: '20px' }}>
{/* Header */} <CardContent>
<Typography variant="h4" gutterBottom sx={{ fontWeight: 600, mb: 3 }}> <Typography sx={{ fontFamily: 'roboto', fontWeight: 400, fontSize: '28px', textAlign: 'center', mb: '10px', color: '#333333' }}>
Mein Upload verwalten Mein Upload verwalten
</Typography> </Typography>
<Typography sx={{ fontFamily: 'roboto', fontWeight: 300, fontSize: '16px', color: '#666666', textAlign: 'center', mb: '30px' }}>
Hier können Sie Ihre hochgeladenen Bilder verwalten, Metadaten bearbeiten und Einwilligungen ändern.
</Typography>
{/* Group Overview Card */} {/* Group Overview */}
<Paper sx={{ p: 3, mb: 3 }}> {group && (
<Box sx={{ mb: 3 }}>
<ImageGalleryCard <ImageGalleryCard
item={group} item={group}
showActions={false} showActions={false}
@ -558,170 +233,81 @@ const ManagementPortalPage = () => {
hidePreview={true} hidePreview={true}
/> />
{/* Consent Badges */}
<Box sx={{ mt: 2 }}> <Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 600 }}> <Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 600 }}>
Erteilte Einwilligungen: Erteilte Einwilligungen:
</Typography> </Typography>
<ConsentBadges group={group} /> <ConsentBadges group={group} />
</Box> </Box>
</Paper> </Box>
)}
{/* Consent Management Section */} {/* Add Images Dropzone */}
{group.consents && ( <Box sx={{ mb: 3 }}>
<> <Typography variant="h6" gutterBottom sx={{ fontWeight: 600 }}>
<ConsentCheckboxes Weitere Bilder hinzufügen
onConsentChange={handleConsentChange} </Typography>
consents={currentConsents} <MultiImageDropzone
disabled={saving} onImagesSelected={handleImagesSelected}
mode="manage" selectedImages={[]}
/>
</Box>
{/* Image Descriptions Manager */}
{group && group.images && group.images.length > 0 && (
<Box sx={{ mb: 3 }}>
<ImageDescriptionManager
images={group.images}
token={token}
enableReordering={true}
onReorder={handleReorder}
onRefresh={loadGroup}
/>
</Box>
)}
{/* Group Metadata Editor */}
{group && (
<Box sx={{ mb: 3 }}>
<GroupMetadataEditor
initialMetadata={group.metadata}
token={token}
onRefresh={loadGroup}
/>
</Box>
)}
{/* Consent Manager */}
{group && (
<Box sx={{ mb: 3 }}>
<ConsentManager
initialConsents={group.consents}
token={token}
groupId={group.groupId} groupId={group.groupId}
onRefresh={loadGroup}
/> />
{/* Save Changes Section (only if there are pending changes) */}
{pendingConsentChanges.length > 0 && (
<Paper sx={{ p: 3, mb: 3, bgcolor: '#fff3cd' }}>
<Typography variant="body2" sx={{ mb: 2, fontWeight: 600 }}>
Sie haben {pendingConsentChanges.length} ungespeicherte Änderung{pendingConsentChanges.length !== 1 ? 'en' : ''}
</Typography>
{/* Show mailto link if social media consents are being revoked */}
{getMailtoLink() && (
<Box sx={{ mb: 2, p: 2, bgcolor: '#f5f5f5', borderRadius: 1 }}>
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>Bereits veröffentlichte Social Media Beiträge löschen?</strong>
</Typography>
<Typography variant="body2" sx={{ mb: 1, fontSize: '13px' }}>
Kontaktieren Sie uns für die Löschung bereits veröffentlichter Beiträge:
</Typography>
<a
href={getMailtoLink()}
style={{
display: 'inline-block',
marginTop: '8px',
padding: '6px 16px',
color: '#1976d2',
textDecoration: 'none',
border: '1px solid #1976d2',
borderRadius: '4px',
fontSize: '14px',
fontWeight: 500,
transition: 'all 0.2s'
}}
onMouseOver={(e) => {
e.currentTarget.style.backgroundColor = '#e3f2fd';
}}
onMouseOut={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
>
📧 E-Mail an it@hobbyhimmel.de
</a>
</Box> </Box>
)} )}
<Button {/* Delete Group Button */}
variant="contained" {group && (
color="primary" <Box sx={{ mt: 4, display: 'flex', justifyContent: 'center' }}>
onClick={handleSaveConsentChanges} <DeleteGroupButton
disabled={saving} token={token}
sx={{ mr: 2 }} groupName={group.title || group.name || 'diese Gruppe'}
>
{saving ? '⏳ Speichern...' : '💾 Änderungen speichern'}
</Button>
<Button
variant="outlined"
color="secondary"
onClick={() => {
setPendingConsentChanges([]);
// Reset currentConsents to original 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
});
}}
disabled={saving}
>
Verwerfen
</Button>
</Paper>
)}
</>
)}
{/* Image Gallery */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600 }}>
Ihre Bilder
</Typography>
<ImageGallery
items={selectedImages}
onDelete={handleRemoveImage}
enableReordering={false}
mode="preview"
showActions={true}
isEditMode={isEditMode}
onEditMode={handleEditMode}
imageDescriptions={imageDescriptions}
onDescriptionChange={handleDescriptionChange}
/> />
</Paper>
{/* Metadata Editor */}
{selectedImages.length > 0 && (
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600 }}>
Metadaten bearbeiten
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Änderungen an Metadaten setzen die Freigabe zurück und müssen erneut moderiert werden.
</Typography>
<DescriptionInput metadata={metadata} onMetadataChange={setMetadata} />
<Box sx={{ display: 'flex', gap: 2, mt: 3 }}>
<Button
variant="contained"
color="primary"
onClick={handleSaveMetadata}
disabled={saving}
sx={{ minWidth: '160px' }}
>
{saving ? '⏳ Speichern...' : '💾 Metadaten speichern'}
</Button>
</Box> </Box>
</Paper>
)} )}
</CardContent>
{/* Delete Group Section */} </Card>
<Paper sx={{ p: 3, mb: 3, borderColor: '#d32f2f', borderWidth: 1, borderStyle: 'solid' }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, color: '#d32f2f' }}>
Gefährliche Aktionen
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Diese Aktion kann nicht rückgängig gemacht werden. Alle Bilder und Daten werden unwiderruflich gelöscht.
</Typography>
<Button
variant="outlined"
color="error"
startIcon={<DeleteForeverIcon />}
onClick={handleDeleteGroup}
>
Gruppe komplett löschen
</Button>
</Paper>
</Container> </Container>
<div className="footerContainer"><Footer /></div> <div className="footerContainer">
<Footer />
</div>
</div> </div>
); );
}; }
export default ManagementPortalPage; export default ManagementPortalPage;

View File

@ -1,5 +1,5 @@
// Batch-Upload Funktion für mehrere Bilder // 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) { if (!images || images.length === 0) {
throw new Error('Keine Bilder zum Upload ausgewählt'); 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 // Füge Metadaten hinzu
formData.append('metadata', JSON.stringify(metadata || {})); formData.append('metadata', JSON.stringify(metadata || {}));
// Füge Beschreibungen hinzu // Füge Beschreibungen hinzu (convert object to array format)
if (descriptions && descriptions.length > 0) { const descriptionsArray = Object.entries(imageDescriptions).map(([id, description]) => ({
formData.append('descriptions', JSON.stringify(descriptions)); imageId: id,
description
}));
if (descriptionsArray.length > 0) {
formData.append('descriptions', JSON.stringify(descriptionsArray));
} }
// Füge Einwilligungen hinzu (GDPR) // Füge Einwilligungen hinzu (GDPR)