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
|
||||
* 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,
|
||||
disabled = false,
|
||||
mode = 'upload',
|
||||
groupId = null
|
||||
groupId = null,
|
||||
children
|
||||
}) {
|
||||
const [platforms, setPlatforms] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -109,6 +110,13 @@ function ConsentCheckboxes({
|
|||
border: '2px solid #e0e0e0'
|
||||
}}
|
||||
>
|
||||
{/* Component Header for manage mode */}
|
||||
{isManageMode && (
|
||||
<Typography variant="h6" gutterBottom sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Einwilligungen
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Aufklärungshinweis */}
|
||||
<Alert severity="info" icon={<InfoIcon />} sx={{ mb: 3 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
|
|
@ -227,6 +235,9 @@ function ConsentCheckboxes({
|
|||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Additional content from parent (e.g., save buttons) */}
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,8 +72,6 @@ function DescriptionInput({
|
|||
|
||||
return (
|
||||
<Box sx={{ marginTop: '20px', marginBottom: '20px' }}>
|
||||
<Typography sx={sectionTitleSx}>📝 Projekt-Informationen</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography sx={fieldLabelSx}>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,5 +1,5 @@
|
|||
// Batch-Upload Funktion für mehrere Bilder
|
||||
export const uploadImageBatch = async (images, metadata, descriptions = [], consents = null, onProgress) => {
|
||||
export const uploadImageBatch = async ({ images, metadata, imageDescriptions = {}, consents = null, onProgress }) => {
|
||||
if (!images || images.length === 0) {
|
||||
throw new Error('Keine Bilder zum Upload ausgewählt');
|
||||
}
|
||||
|
|
@ -14,9 +14,13 @@ export const uploadImageBatch = async (images, metadata, descriptions = [], cons
|
|||
// Füge Metadaten hinzu
|
||||
formData.append('metadata', JSON.stringify(metadata || {}));
|
||||
|
||||
// Füge Beschreibungen hinzu
|
||||
if (descriptions && descriptions.length > 0) {
|
||||
formData.append('descriptions', JSON.stringify(descriptions));
|
||||
// Füge Beschreibungen hinzu (convert object to array format)
|
||||
const descriptionsArray = Object.entries(imageDescriptions).map(([id, description]) => ({
|
||||
imageId: id,
|
||||
description
|
||||
}));
|
||||
if (descriptionsArray.length > 0) {
|
||||
formData.append('descriptions', JSON.stringify(descriptionsArray));
|
||||
}
|
||||
|
||||
// Füge Einwilligungen hinzu (GDPR)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user