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:
parent
324c46d735
commit
4b9feec887
|
|
@ -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)
|
||||||
|
|
|
||||||
263
frontend/src/Components/ComponentUtils/ConsentManager.js
Normal file
263
frontend/src/Components/ComponentUtils/ConsentManager.js
Normal 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;
|
||||||
102
frontend/src/Components/ComponentUtils/DeleteGroupButton.js
Normal file
102
frontend/src/Components/ComponentUtils/DeleteGroupButton.js
Normal 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;
|
||||||
146
frontend/src/Components/ComponentUtils/GroupMetadataEditor.js
Normal file
146
frontend/src/Components/ComponentUtils/GroupMetadataEditor.js
Normal 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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user