diff --git a/frontend/src/Components/ComponentUtils/ConsentManager.js b/frontend/src/Components/ComponentUtils/ConsentManager.js index e780746..ab9a248 100644 --- a/frontend/src/Components/ComponentUtils/ConsentManager.js +++ b/frontend/src/Components/ComponentUtils/ConsentManager.js @@ -5,12 +5,17 @@ import ConsentCheckboxes from './MultiUpload/ConsentCheckboxes'; /** * Manages consents with save functionality * Wraps ConsentCheckboxes and provides save for workshop + social media consents + * + * @param mode - 'edit' (default) shows save/discard, 'upload' hides them */ function ConsentManager({ - initialConsents, + initialConsents, + consents: externalConsents, + onConsentsChange, token, groupId, - onRefresh + onRefresh, + mode = 'edit' }) { // Initialize with proper defaults const defaultConsents = { @@ -26,9 +31,14 @@ function ConsentManager({ const [errorMessage, setErrorMessage] = useState(''); const [showEmailHint, setShowEmailHint] = useState(false); - // Update ONLY ONCE when initialConsents first arrives + // In upload mode: use external state + const isUploadMode = mode === 'upload'; + const currentConsents = isUploadMode ? externalConsents : consents; + const setCurrentConsents = isUploadMode ? onConsentsChange : setConsents; + + // Update ONLY ONCE when initialConsents first arrives (edit mode only) React.useEffect(() => { - if (initialConsents && !initialized) { + if (initialConsents && !initialized && !isUploadMode) { // Deep copy to avoid shared references const consentsCopy = { workshopConsent: initialConsents.workshopConsent, @@ -45,9 +55,10 @@ function ConsentManager({ setInitialized(true); } - }, [initialConsents, initialized]); + }, [initialConsents, initialized, isUploadMode]); const hasChanges = () => { + if (isUploadMode) return false; // No changes tracking in upload mode // Check workshop consent if (consents.workshopConsent !== originalConsents.workshopConsent) { return true; @@ -191,20 +202,23 @@ function ConsentManager({ return ( - {/* Success Message */} - {successMessage && ( - - {successMessage} - - )} + {/* Alerts and Buttons only in edit mode */} + {!isUploadMode && ( + <> + {/* Success Message */} + {successMessage && ( + + {successMessage} + + )} {/* Email Hint - show IMMEDIATELY when social media revoked (before save) */} {hasChanges() && hasSocialMediaRevocations() && !successMessage && ( @@ -256,7 +270,9 @@ function ConsentManager({ )} - + + )} + ); } diff --git a/frontend/src/Components/ComponentUtils/GroupMetadataEditor.js b/frontend/src/Components/ComponentUtils/GroupMetadataEditor.js index 06ae97a..db171a5 100644 --- a/frontend/src/Components/ComponentUtils/GroupMetadataEditor.js +++ b/frontend/src/Components/ComponentUtils/GroupMetadataEditor.js @@ -6,11 +6,17 @@ import DescriptionInput from './MultiUpload/DescriptionInput'; /** * Manages group metadata with save functionality * Wraps DescriptionInput and provides save for title, description, name, year + * + * @param mode - 'edit' (default) shows save/discard, 'upload' hides them, 'moderate' uses different API */ function GroupMetadataEditor({ - initialMetadata, + initialMetadata, + metadata: externalMetadata, + onMetadataChange, token, - onRefresh + groupId, + onRefresh, + mode = 'edit' }) { const [metadata, setMetadata] = useState(initialMetadata || { year: new Date().getFullYear(), @@ -26,15 +32,22 @@ function GroupMetadataEditor({ }); const [saving, setSaving] = useState(false); - // Update when initialMetadata changes + // In upload mode: use external state + const isUploadMode = mode === 'upload'; + const isModerateMode = mode === 'moderate'; + const currentMetadata = isUploadMode ? externalMetadata : metadata; + const setCurrentMetadata = isUploadMode ? onMetadataChange : setMetadata; + + // Update when initialMetadata changes (edit mode only) React.useEffect(() => { - if (initialMetadata) { + if (initialMetadata && !isUploadMode) { setMetadata(initialMetadata); setOriginalMetadata(initialMetadata); } - }, [initialMetadata]); + }, [initialMetadata, isUploadMode]); const hasChanges = () => { + if (isUploadMode) return false; // No changes tracking in upload mode return JSON.stringify(metadata) !== JSON.stringify(originalMetadata); }; @@ -51,8 +64,15 @@ function GroupMetadataEditor({ try { setSaving(true); - const res = await fetch(`/api/manage/${token}/metadata`, { - method: 'PUT', + // Different API endpoints for manage vs moderate + const endpoint = isModerateMode + ? `/groups/${groupId}` + : `/api/manage/${token}/metadata`; + + const method = isModerateMode ? 'PATCH' : 'PUT'; + + const res = await fetch(endpoint, { + method: method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(metadata) }); @@ -116,11 +136,11 @@ function GroupMetadataEditor({ - {hasChanges() && ( + {!isUploadMode && hasChanges() && ( + ) : groups.length === 0 ? (
@@ -119,13 +118,13 @@ function GroupsOverviewPage() { Erstellen Sie Ihre erste Slideshow, indem Sie mehrere Bilder hochladen. - +
) : ( <> diff --git a/frontend/src/Components/Pages/ModerationGroupImagesPage.js b/frontend/src/Components/Pages/ModerationGroupImagesPage.js index 9b706c4..a5e6416 100644 --- a/frontend/src/Components/Pages/ModerationGroupImagesPage.js +++ b/frontend/src/Components/Pages/ModerationGroupImagesPage.js @@ -1,76 +1,54 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { Button, Container } from '@mui/material'; -import Swal from 'sweetalert2/dist/sweetalert2.js'; -import 'sweetalert2/src/sweetalert2.scss'; +import { Container, Box } from '@mui/material'; // Components import Navbar from '../ComponentUtils/Headers/Navbar'; import Footer from '../ComponentUtils/Footer'; -import ImageGallery from '../ComponentUtils/ImageGallery'; -import DescriptionInput from '../ComponentUtils/MultiUpload/DescriptionInput'; - -// Services -import { updateImageOrder } from '../../services/reorderService'; - - - +import ImageDescriptionManager from '../ComponentUtils/ImageDescriptionManager'; +import GroupMetadataEditor from '../ComponentUtils/GroupMetadataEditor'; +import Loading from '../ComponentUtils/LoadingAnimation/Loading'; +/** + * ModerationGroupImagesPage - Admin page for moderating group images + * + * Uses modular components: + * - ImageDescriptionManager: Edit image descriptions with batch save + * - GroupMetadataEditor: Edit group metadata with save/discard + */ const ModerationGroupImagesPage = () => { const { groupId } = useParams(); const navigate = useNavigate(); const [group, setGroup] = useState(null); const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - // selectedImages will hold objects compatible with ImagePreviewGallery - const [selectedImages, setSelectedImages] = useState([]); - const [metadata, setMetadata] = useState({ year: new Date().getFullYear(), title: '', description: '', name: '' }); - const [isReordering, setIsReordering] = useState(false); - const [isEditMode, setIsEditMode] = useState(false); - const [imageDescriptions, setImageDescriptions] = useState({}); - - useEffect(() => { - loadGroup(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [groupId]); - const loadGroup = useCallback(async () => { try { setLoading(true); const res = await fetch(`/moderation/groups/${groupId}`); if (!res.ok) throw new Error('Nicht gefunden'); const data = await res.json(); - setGroup(data); - - // Map group's images to preview-friendly objects - if (data.images && data.images.length > 0) { - const mapped = data.images.map(img => ({ - ...img, // Pass all image fields including previewPath and imageDescription - remoteUrl: `/download/${img.fileName}`, // Keep for backward compatibility + + // Transform data similar to ManagementPortalPage + const transformedData = { + ...data, + metadata: { + year: data.year || new Date().getFullYear(), + title: data.title || '', + description: data.description || '', + name: data.name || '' + }, + images: (data.images || []).map(img => ({ + ...img, + remoteUrl: `/download/${img.fileName}`, originalName: img.originalName || img.fileName, - id: img.id - })); - 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(), - title: data.title || '', - description: data.description || '', - name: data.name || '' - }); + id: img.id, + imageDescription: img.imageDescription || '' + })) + }; + + setGroup(transformedData); } catch (e) { setError('Fehler beim Laden der Gruppe'); } finally { @@ -78,155 +56,12 @@ const ModerationGroupImagesPage = () => { } }, [groupId]); - const handleSave = async () => { - if (!group) return; - setSaving(true); - try { - // 1. Speichere Gruppen-Metadaten - const payload = { - title: metadata.title, - description: metadata.description, - year: metadata.year, - name: metadata.name - }; + useEffect(() => { + loadGroup(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [groupId]); - const res = await fetch(`/groups/${groupId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.message || 'Speichern der Metadaten fehlgeschlagen'); - } - - // 2. Speichere Bildbeschreibungen (falls vorhanden) - if (Object.keys(imageDescriptions).length > 0) { - const descriptions = Object.entries(imageDescriptions).map(([id, desc]) => ({ - imageId: parseInt(id), - description: desc - })); - - console.log('Speichere Beschreibungen:', descriptions); - - const descRes = await fetch(`/groups/${groupId}/images/batch-description`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ descriptions }) - }); - - if (!descRes.ok) { - const body = await descRes.json().catch(() => ({})); - throw new Error(body.message || 'Speichern der Beschreibungen fehlgeschlagen'); - } - } - - Swal.fire({ icon: 'success', title: 'Gruppe erfolgreich aktualisiert', timer: 1500, showConfirmButton: false }); - navigate('/moderation'); - } catch (e) { - console.error(e); - Swal.fire({ icon: 'error', title: 'Fehler beim Speichern', text: e.message }); - } finally { - setSaving(false); - } - }; - - const handleDeleteImage = async (imageId) => { - if (!window.confirm('Bild wirklich löschen?')) return; - try { - const res = await fetch(`/groups/${groupId}/images/${imageId}`, { method: 'DELETE' }); - if (!res.ok) throw new Error('Löschen fehlgeschlagen'); - // Aktualisiere lokale Ansicht - const newImages = group.images.filter(img => img.id !== imageId); - setGroup({ ...group, images: newImages, imageCount: (group.imageCount || 0) - 1 }); - setSelectedImages(prev => prev.filter(img => img.id !== imageId)); - Swal.fire({ icon: 'success', title: 'Bild gelöscht', timer: 1200, showConfirmButton: false }); - } catch (e) { - console.error(e); - Swal.fire({ icon: 'error', title: 'Fehler beim Löschen des Bildes' }); - } - }; - - const handleRemoveImage = (indexToRemove) => { - // If it's a remote image mapped with id, call delete - const img = selectedImages[indexToRemove]; - if (img && img.id) { - handleDeleteImage(img.id); - return; - } - setSelectedImages(prev => prev.filter((_, index) => index !== indexToRemove)); - }; - - // Handle drag-and-drop reordering - const handleReorder = useCallback(async (reorderedItems) => { - if (isReordering) return; // Prevent concurrent reordering - - try { - setIsReordering(true); - const imageIds = reorderedItems.map(img => img.id); - - // Update local state immediately (optimistic update) - setSelectedImages(reorderedItems); - - // Also update group state to keep consistency - if (group) { - setGroup({ ...group, images: reorderedItems }); - } - - // Send API request - await updateImageOrder(groupId, imageIds); - - // Show success feedback - Swal.fire({ - icon: 'success', - title: 'Reihenfolge gespeichert', - timer: 1500, - showConfirmButton: false, - toast: true, - position: 'top-end' - }); - - } catch (error) { - console.error('❌ Fehler beim Neuordnen:', error); - - // Rollback on error - reload original order - await loadGroup(); - - Swal.fire({ - icon: 'error', - title: 'Fehler beim Speichern', - text: 'Reihenfolge konnte nicht gespeichert werden', - timer: 3000, - showConfirmButton: false - }); - } finally { - setIsReordering(false); - } - }, [groupId, group, isReordering, loadGroup]); - - // Handle edit mode toggle - const handleEditMode = (enabled) => { - console.log('🔄 Edit mode toggled:', enabled ? 'ENABLED' : 'DISABLED'); - setIsEditMode(enabled); - }; - - // Handle description changes - const handleDescriptionChange = (imageId, description) => { - console.log('✏️ Description changed for image', imageId, ':', description); - setImageDescriptions(prev => { - const newDescriptions = { - ...prev, - [imageId]: description.slice(0, 200) // Enforce max length - }; - console.log('📝 Updated imageDescriptions:', newDescriptions); - return newDescriptions; - }); - }; - - // Note: approve/delete group actions are intentionally removed from this page - - if (loading) return
Lade Gruppe...
; + if (loading) return ; if (error) return
{error}
; if (!group) return
Gruppe nicht gefunden
; @@ -234,47 +69,37 @@ const ModerationGroupImagesPage = () => {
- - + + {/* Image Descriptions Manager */} + - {selectedImages.length > 0 && ( - <> - - -
- - -
- - )} + {/* Group Metadata Editor */} + + {/* Back Button */} + + +
); - }; export default ModerationGroupImagesPage; diff --git a/frontend/src/Components/Pages/ModerationGroupsPage.js b/frontend/src/Components/Pages/ModerationGroupsPage.js index a0776a6..df89462 100644 --- a/frontend/src/Components/Pages/ModerationGroupsPage.js +++ b/frontend/src/Components/Pages/ModerationGroupsPage.js @@ -1,8 +1,7 @@ import React, { useState, useEffect } from 'react'; import { Helmet } from 'react-helmet'; import { useNavigate } from 'react-router-dom'; -import { Container, Box, FormControl, InputLabel, Select, MenuItem, Button } from '@mui/material'; -import FileDownloadIcon from '@mui/icons-material/FileDownload'; +import { Container, Box, FormControl, InputLabel, Select, MenuItem } from '@mui/material'; import FilterListIcon from '@mui/icons-material/FilterList'; import Swal from 'sweetalert2/dist/sweetalert2.js'; import Navbar from '../ComponentUtils/Headers/Navbar'; @@ -298,17 +297,16 @@ const ModerationGroupsPage = () => { - + 📥 Consent-Daten exportieren +
{/* Wartende Gruppen */} diff --git a/frontend/src/Components/Pages/MultiUploadPage.js b/frontend/src/Components/Pages/MultiUploadPage.js index ad75937..5a54ceb 100644 --- a/frontend/src/Components/Pages/MultiUploadPage.js +++ b/frontend/src/Components/Pages/MultiUploadPage.js @@ -1,29 +1,23 @@ import React, { useState, useEffect } from 'react'; -import { Button, Card, CardContent, Typography, Container, Box } from '@mui/material'; -import Swal from 'sweetalert2/dist/sweetalert2.js'; -import 'sweetalert2/src/sweetalert2.scss'; +import { Card, CardContent, Typography, Container, Box } from '@mui/material'; // Components import Navbar from '../ComponentUtils/Headers/Navbar'; import Footer from '../ComponentUtils/Footer'; import MultiImageDropzone from '../ComponentUtils/MultiUpload/MultiImageDropzone'; import ImageGallery from '../ComponentUtils/ImageGallery'; -import DescriptionInput from '../ComponentUtils/MultiUpload/DescriptionInput'; +import GroupMetadataEditor from '../ComponentUtils/GroupMetadataEditor'; +import ConsentManager from '../ComponentUtils/ConsentManager'; import UploadProgress from '../ComponentUtils/MultiUpload/UploadProgress'; import Loading from '../ComponentUtils/LoadingAnimation/Loading'; -import ConsentCheckboxes from '../ComponentUtils/MultiUpload/ConsentCheckboxes'; // Utils import { uploadImageBatch } from '../../Utils/batchUpload'; // Styles import '../../App.css'; -// Background.css is now globally imported in src/index.js - -// Styles migrated to MUI sx props in-place below function MultiUploadPage() { - const [selectedImages, setSelectedImages] = useState([]); const [metadata, setMetadata] = useState({ year: new Date().getFullYear(), @@ -54,29 +48,23 @@ function MultiUploadPage() { }, [selectedImages]); const handleImagesSelected = (newImages) => { - console.log('handleImagesSelected called with:', newImages); - // Convert File objects to preview objects with URLs const imageObjects = newImages.map((file, index) => ({ - id: `preview-${Date.now()}-${index}`, // Unique ID für Preview-Modus - file: file, // Original File object for upload - url: URL.createObjectURL(file), // Preview URL + id: `preview-${Date.now()}-${index}`, + file: file, + url: URL.createObjectURL(file), name: file.name, originalName: file.name, size: file.size, type: file.type })); - setSelectedImages(prev => { - const updated = [...prev, ...imageObjects]; - return updated; - }); + setSelectedImages(prev => [...prev, ...imageObjects]); }; const handleRemoveImage = (indexToRemove) => { setSelectedImages(prev => { const imageToRemove = prev[indexToRemove]; - // Clean up the object URL to avoid memory leaks if (imageToRemove && imageToRemove.url && imageToRemove.url.startsWith('blob:')) { URL.revokeObjectURL(imageToRemove.url); } @@ -85,7 +73,6 @@ function MultiUploadPage() { }; const handleClearAll = () => { - // Clean up all object URLs selectedImages.forEach(img => { if (img.url && img.url.startsWith('blob:')) { URL.revokeObjectURL(img.url); @@ -107,105 +94,71 @@ function MultiUploadPage() { setIsEditMode(false); }; - // Handle drag-and-drop reordering (only updates local state, no API call) const handleReorder = (reorderedItems) => { - console.log('Reordering images in preview:', reorderedItems); setSelectedImages(reorderedItems); }; - // Handle edit mode toggle const handleEditMode = (enabled) => { setIsEditMode(enabled); }; - // Handle description changes const handleDescriptionChange = (imageId, description) => { setImageDescriptions(prev => ({ ...prev, - [imageId]: description.slice(0, 200) // Enforce max length + [imageId]: description.slice(0, 200) })); }; const handleUpload = async () => { - if (selectedImages.length === 0) { - Swal.fire({ - icon: 'warning', - title: 'Keine Bilder ausgewählt', - text: 'Bitte wählen Sie mindestens ein Bild zum Upload aus.', - confirmButtonColor: '#4CAF50' - }); - return; - } + if (selectedImages.length === 0) return; - if (!metadata.year || !metadata.title.trim()) { - Swal.fire({ - icon: 'warning', - title: 'Pflichtfelder fehlen', - text: 'Bitte gebe das Jahr und den Titel an.', - confirmButtonColor: '#4CAF50' - }); - return; - } + if (!metadata.year || !metadata.title.trim()) return; - // GDPR: Validate workshop consent (mandatory) - if (!consents.workshopConsent) { - Swal.fire({ - icon: 'error', - title: 'Einwilligung erforderlich', - text: 'Die Zustimmung zur Anzeige in der Werkstatt ist erforderlich.', - confirmButtonColor: '#f44336' - }); - return; - } + if (!consents.workshopConsent) return; setUploading(true); setUploadProgress(0); try { - // Simuliere Progress (da wir noch keinen echten Progress haben) - const progressInterval = setInterval(() => { - setUploadProgress(prev => { - if (prev >= 90) { - clearInterval(progressInterval); - return 90; - } - return prev + 10; - }); - }, 200); + const filesToUpload = selectedImages.map(img => img.file).filter(Boolean); + + if (filesToUpload.length === 0) { + throw new Error('Keine gültigen Bilder zum Upload'); + } - // Extract the actual File objects from our image objects - const filesToUpload = selectedImages.map(img => img.file || img); - - // Prepare descriptions array for backend - const descriptionsArray = selectedImages.map(img => ({ - fileName: img.name, - description: imageDescriptions[img.id] || '' - })); - - const result = await uploadImageBatch(filesToUpload, metadata, descriptionsArray, consents); - - clearInterval(progressInterval); - setUploadProgress(100); + // Map preview IDs to actual file names for backend + const descriptionsForUpload = {}; + selectedImages.forEach(img => { + if (imageDescriptions[img.id]) { + descriptionsForUpload[img.originalName] = imageDescriptions[img.id]; + } + }); - // Show success content - setTimeout(() => { - setUploadComplete(true); - setUploadResult(result); - }, 500); + const result = await uploadImageBatch({ + images: filesToUpload, + metadata, + imageDescriptions: descriptionsForUpload, + consents, + onProgress: setUploadProgress + }); + + setUploadComplete(true); + setUploadResult(result); } catch (error) { - setUploading(false); console.error('Upload error:', error); - - Swal.fire({ - icon: 'error', - title: 'Upload fehlgeschlagen', - text: error.message || 'Ein Fehler ist beim Upload aufgetreten.', - confirmButtonColor: '#f44336' - }); + setUploading(false); + setUploadComplete(false); } }; + const canUpload = () => { + return selectedImages.length > 0 && + metadata.year && + metadata.title.trim() && + consents.workshopConsent; + }; + return (
@@ -224,93 +177,70 @@ function MultiUploadPage() { {!uploading ? ( <> + {/* Image Dropzone - stays inline as it's upload-specific */} - + {/* Image Gallery with descriptions */} + {selectedImages.length > 0 && ( + + )} {selectedImages.length > 0 && ( <> - - - - + - + )} - - ) : (
@@ -397,34 +327,19 @@ function MultiUploadPage() { }}> {window.location.origin}/manage/{uploadResult.managementToken} - + @@ -445,27 +360,16 @@ function MultiUploadPage() { Fragen oder Widerruf? Kontakt: it@hobbyhimmel.de - + )}
@@ -481,4 +385,4 @@ function MultiUploadPage() { ); } -export default MultiUploadPage; \ No newline at end of file +export default MultiUploadPage; diff --git a/frontend/src/Utils/batchUpload.js b/frontend/src/Utils/batchUpload.js index 7e0ef30..8bcf8bb 100644 --- a/frontend/src/Utils/batchUpload.js +++ b/frontend/src/Utils/batchUpload.js @@ -14,9 +14,9 @@ export const uploadImageBatch = async ({ images, metadata, imageDescriptions = { // Füge Metadaten hinzu formData.append('metadata', JSON.stringify(metadata || {})); - // Füge Beschreibungen hinzu (convert object to array format) - const descriptionsArray = Object.entries(imageDescriptions).map(([id, description]) => ({ - imageId: id, + // Füge Beschreibungen hinzu (convert object to array format with fileName) + const descriptionsArray = Object.entries(imageDescriptions).map(([fileName, description]) => ({ + fileName: fileName, description })); if (descriptionsArray.length > 0) {