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:
parent
4b9feec887
commit
bd7bdac000
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user