refactor: Complete UI refactoring with modular components

- Refactored ManagementPortalPage, MultiUploadPage, ModerationGroupImagesPage
- Created reusable modular components with mode support:
  * ImageDescriptionManager (manage/moderate modes)
  * GroupMetadataEditor (edit/upload/moderate modes)
  * ConsentManager (edit/upload modes)
- Replaced Material-UI Buttons with HTML buttons + CSS classes
- Fixed image descriptions upload (preview ID to filename mapping)
- Reduced ModerationGroupImagesPage from 281 to 107 lines
- Updated ModerationGroupsPage and GroupsOverviewPage button styles
- All pages now use consistent Paper boxes with headings
- Inline Material-UI Alerts instead of SweetAlert2 popups (except destructive actions)
- Icons: 💾 save, ↩ discard, 🗑️ delete consistently used
This commit is contained in:
Matthias Lotz 2025-11-15 18:17:14 +01:00
parent 4b9feec887
commit bd7bdac000
8 changed files with 244 additions and 471 deletions

View File

@ -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 (
<ConsentCheckboxes
consents={consents}
onConsentChange={handleConsentChange}
consents={currentConsents}
onConsentChange={isUploadMode ? setCurrentConsents : handleConsentChange}
disabled={saving}
mode="manage"
mode={isUploadMode ? "upload" : "manage"}
groupId={groupId}
token={token}
onSave={null}
>
{/* Success Message */}
{successMessage && (
<Alert severity="success" sx={{ mt: 3 }}>
{successMessage}
</Alert>
)}
{/* Alerts and Buttons only in edit mode */}
{!isUploadMode && (
<>
{/* Success Message */}
{successMessage && (
<Alert severity="success" sx={{ mt: 3 }}>
{successMessage}
</Alert>
)}
{/* Email Hint - show IMMEDIATELY when social media revoked (before save) */}
{hasChanges() && hasSocialMediaRevocations() && !successMessage && (
@ -256,7 +270,9 @@ function ConsentManager({
</button>
</Box>
)}
</ConsentCheckboxes>
</>
)}
</ConsentCheckboxes>
);
}

View File

@ -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({
</Typography>
<DescriptionInput
metadata={metadata}
onMetadataChange={setMetadata}
metadata={currentMetadata}
onMetadataChange={setCurrentMetadata}
/>
{hasChanges() && (
{!isUploadMode && hasChanges() && (
<Box sx={{ mt: 2, display: 'flex', gap: 1, justifyContent: 'center' }}>
<button
className="btn btn-success"

View File

@ -6,13 +6,17 @@ import ImageGallery from './ImageGallery';
/**
* Manages image descriptions with save functionality
* Wraps ImageGallery and provides batch save for all descriptions
*
* @param mode - 'manage' (uses token) or 'moderate' (uses groupId)
*/
function ImageDescriptionManager({
images,
token,
token,
groupId,
enableReordering = false,
onReorder,
onRefresh
onRefresh,
mode = 'manage'
}) {
const [imageDescriptions, setImageDescriptions] = useState({});
const [originalDescriptions, setOriginalDescriptions] = useState({});
@ -61,8 +65,15 @@ function ImageDescriptionManager({
description: description || null
}));
const res = await fetch(`/api/manage/${token}/images/descriptions`, {
method: 'PUT',
// Different API endpoints for manage vs moderate
const endpoint = mode === 'moderate'
? `/groups/${groupId}/images/batch-description`
: `/api/manage/${token}/images/descriptions`;
const method = mode === 'moderate' ? 'PATCH' : 'PUT';
const res = await fetch(endpoint, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ descriptions })
});

View File

@ -5,7 +5,6 @@ import {
Container,
Card,
Typography,
Button,
Box,
CircularProgress
} from '@mui/material';
@ -107,9 +106,9 @@ function GroupsOverviewPage() {
<Typography variant="body1" style={{ color: '#666666', marginBottom: '30px' }}>
{error}
</Typography>
<Button onClick={loadGroups} className="primary-button">
<button onClick={loadGroups} className="btn btn-secondary">
🔄 Erneut versuchen
</Button>
</button>
</div>
) : groups.length === 0 ? (
<div className="empty-state">
@ -119,13 +118,13 @@ function GroupsOverviewPage() {
<Typography variant="body1" style={{ color: '#999999', marginBottom: '30px' }}>
Erstellen Sie Ihre erste Slideshow, indem Sie mehrere Bilder hochladen.
</Typography>
<Button
className="primary-button"
<button
className="btn btn-success"
onClick={handleCreateNew}
size="large"
style={{ fontSize: '16px', padding: '12px 24px' }}
>
Erste Slideshow erstellen
</Button>
</button>
</div>
) : (
<>

View File

@ -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 <div className="moderation-loading">Lade Gruppe...</div>;
if (loading) return <Loading />;
if (error) return <div className="moderation-error">{error}</div>;
if (!group) return <div className="moderation-error">Gruppe nicht gefunden</div>;
@ -234,47 +69,37 @@ const ModerationGroupImagesPage = () => {
<div className="allContainer">
<Navbar />
<Container maxWidth="lg" className="page-container">
<ImageGallery
items={selectedImages}
onDelete={handleRemoveImage}
onReorder={handleReorder}
enableReordering={true}
isReordering={isReordering}
mode="preview"
showActions={true}
isEditMode={isEditMode}
onEditMode={handleEditMode}
imageDescriptions={imageDescriptions}
onDescriptionChange={handleDescriptionChange}
/>
<Container maxWidth="lg" sx={{ pt: '20px', pb: '40px', minHeight: '80vh' }}>
{/* Image Descriptions Manager */}
<ImageDescriptionManager
images={group.images}
groupId={groupId}
onRefresh={loadGroup}
mode="moderate"
/>
{selectedImages.length > 0 && (
<>
<DescriptionInput metadata={metadata} onMetadataChange={setMetadata} />
<div className="action-buttons">
<Button className="btn btn-secondary" onClick={() => navigate('/moderation')}>
Zurück
</Button>
<Button
className="btn btn-success"
onClick={handleSave}
disabled={saving}
style={{ minWidth: '160px' }}
>
{saving ? '⏳ Speichern...' : '💾 Speichern'}
</Button>
</div>
</>
)}
{/* Group Metadata Editor */}
<GroupMetadataEditor
initialMetadata={group.metadata}
groupId={groupId}
onRefresh={loadGroup}
mode="moderate"
/>
{/* Back Button */}
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
<button
className="btn btn-secondary"
onClick={() => navigate('/moderation')}
>
Zurück zur Übersicht
</button>
</Box>
</Container>
<div className="footerContainer"><Footer /></div>
</div>
);
};
export default ModerationGroupImagesPage;

View File

@ -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 = () => {
</Select>
</FormControl>
<Button
variant="contained"
startIcon={<FileDownloadIcon />}
<button
className="btn btn-success"
onClick={exportConsentData}
sx={{
bgcolor: '#2196F3',
'&:hover': { bgcolor: '#1976D2' }
style={{
fontSize: '14px',
padding: '10px 20px'
}}
>
Consent-Daten exportieren
</Button>
📥 Consent-Daten exportieren
</button>
</Box>
{/* Wartende Gruppen */}

View File

@ -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 (
<div className="allContainer">
<Navbar />
@ -224,93 +177,70 @@ function MultiUploadPage() {
{!uploading ? (
<>
{/* Image Dropzone - stays inline as it's upload-specific */}
<MultiImageDropzone
onImagesSelected={handleImagesSelected}
selectedImages={selectedImages}
/>
<ImageGallery
items={selectedImages}
onDelete={handleRemoveImage}
mode="preview"
showActions={true}
enableReordering={true}
onReorder={handleReorder}
isEditMode={isEditMode}
onEditMode={handleEditMode}
imageDescriptions={imageDescriptions}
onDescriptionChange={handleDescriptionChange}
/>
{/* Image Gallery with descriptions */}
{selectedImages.length > 0 && (
<ImageGallery
items={selectedImages}
onDelete={handleRemoveImage}
mode="preview"
showActions={true}
enableReordering={true}
onReorder={handleReorder}
isEditMode={isEditMode}
onEditMode={handleEditMode}
imageDescriptions={imageDescriptions}
onDescriptionChange={handleDescriptionChange}
/>
)}
{selectedImages.length > 0 && (
<>
<DescriptionInput
{/* Modular Components like ManagementPortalPage */}
<GroupMetadataEditor
metadata={metadata}
onMetadataChange={setMetadata}
mode="upload"
/>
<ConsentCheckboxes
<ConsentManager
consents={consents}
onConsentChange={setConsents}
disabled={uploading}
onConsentsChange={setConsents}
mode="upload"
/>
<Box sx={{ display: 'flex', gap: '15px', justifyContent: 'center', mt: '20px', flexWrap: 'wrap' }}>
<Button
sx={{
borderRadius: '25px',
px: '30px',
py: '12px',
fontSize: '16px',
fontWeight: 500,
textTransform: 'none',
background: 'linear-gradient(45deg, #4CAF50 30%, #45a049 90%)',
color: 'white',
'&:hover': {
background: 'linear-gradient(45deg, #45a049 30%, #4CAF50 90%)',
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(76, 175, 80, 0.3)'
},
'&.Mui-disabled': {
background: '#cccccc',
color: '#666666'
}
}}
{/* Action Buttons */}
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'center', mt: 3, flexWrap: 'wrap' }}>
<button
className="btn btn-success"
onClick={handleUpload}
disabled={uploading || selectedImages.length === 0 || !consents.workshopConsent}
size="large"
disabled={!canUpload()}
style={{
fontSize: '16px',
padding: '12px 30px'
}}
>
🚀 {selectedImages.length} Bild{selectedImages.length !== 1 ? 'er' : ''} hochladen
</Button>
</button>
<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)'
}
}}
<button
className="btn btn-secondary"
onClick={handleClearAll}
size="large"
style={{
fontSize: '16px',
padding: '12px 30px'
}}
>
🗑 Alle entfernen
</Button>
</button>
</Box>
</>
)}
</>
) : (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
@ -397,34 +327,19 @@ function MultiUploadPage() {
}}>
{window.location.origin}/manage/{uploadResult.managementToken}
</Typography>
<Button
size="small"
sx={{
minWidth: 'auto',
px: 2,
py: 0.5,
<button
className="btn btn-secondary"
style={{
fontSize: '12px',
textTransform: 'none',
bgcolor: '#1976d2',
color: 'white',
'&:hover': {
bgcolor: '#1565c0'
}
padding: '6px 16px'
}}
onClick={() => {
const link = `${window.location.origin}/manage/${uploadResult.managementToken}`;
navigator.clipboard.writeText(link);
Swal.fire({
icon: 'success',
title: 'Link kopiert!',
text: 'Der Verwaltungslink wurde in die Zwischenablage kopiert.',
timer: 2000,
showConfirmButton: false
});
}}
>
📋 Kopieren
</Button>
</button>
</Box>
<Typography sx={{ fontSize: '11px', color: '#666', mb: 0.5 }}>
@ -445,27 +360,16 @@ function MultiUploadPage() {
<strong>Fragen oder Widerruf?</strong> Kontakt: <strong>it@hobbyhimmel.de</strong>
</Typography>
<Button
sx={{
background: 'white',
color: '#4CAF50',
fontWeight: 'bold',
<button
className="btn btn-success"
style={{
fontSize: '16px',
px: 4,
py: 1.5,
borderRadius: '25px',
textTransform: 'none',
'&:hover': {
background: '#f0f0f0',
transform: 'scale(1.05)',
boxShadow: '0 4px 12px rgba(0,0,0,0.2)'
},
transition: 'all 0.3s ease'
padding: '12px 30px'
}}
onClick={() => window.location.reload()}
>
👍 Weitere Bilder hochladen
</Button>
</button>
</Box>
)}
</div>
@ -481,4 +385,4 @@ function MultiUploadPage() {
);
}
export default MultiUploadPage;
export default MultiUploadPage;

View File

@ -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) {