feat(frontend): Add consent management UI components

- Add ConsentCheckboxes component with workshop and social media consents
- Add UploadSuccessDialog with group ID display and copy functionality
- Integrate consent validation into MultiUploadPage
- Extend batchUpload utility to send consent data
- Add GDPR compliance notices and contact information
- Block uploads without required workshop consent
This commit is contained in:
Matthias Lotz 2025-11-09 21:11:01 +01:00
parent 6ba7f7bd33
commit 39f133eadf
4 changed files with 446 additions and 55 deletions

View File

@ -0,0 +1,206 @@
import React, { useState, useEffect } from 'react';
import {
Box,
FormControlLabel,
Checkbox,
Typography,
Paper,
Divider,
Alert
} from '@mui/material';
import InfoIcon from '@mui/icons-material/Info';
import FacebookIcon from '@mui/icons-material/Facebook';
import InstagramIcon from '@mui/icons-material/Instagram';
import MusicNoteIcon from '@mui/icons-material/MusicNote';
const ICON_MAP = {
'Facebook': FacebookIcon,
'Instagram': InstagramIcon,
'MusicNote': MusicNoteIcon,
};
/**
* ConsentCheckboxes Component
*
* GDPR-konforme Einwilligungsabfrage für Bildveröffentlichung
* - Pflicht: Werkstatt-Anzeige Zustimmung
* - Optional: Social Media Plattform-Zustimmungen
*/
function ConsentCheckboxes({ onConsentChange, consents, disabled = false }) {
const [platforms, setPlatforms] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Lade verfügbare Plattformen vom Backend
fetchPlatforms();
}, []);
const fetchPlatforms = async () => {
try {
const response = await fetch('/api/social-media/platforms');
if (!response.ok) {
throw new Error('Failed to load platforms');
}
const data = await response.json();
setPlatforms(data);
setError(null);
} catch (error) {
console.error('Error loading platforms:', error);
setError('Plattformen konnten nicht geladen werden');
} finally {
setLoading(false);
}
};
const handleWorkshopChange = (event) => {
onConsentChange({
...consents,
workshopConsent: event.target.checked
});
};
const handleSocialMediaChange = (platformId) => (event) => {
const updatedConsents = { ...consents };
const platformConsents = updatedConsents.socialMediaConsents || [];
if (event.target.checked) {
// Füge Consent hinzu
platformConsents.push({ platformId, consented: true });
} else {
// Entferne Consent
const index = platformConsents.findIndex(c => c.platformId === platformId);
if (index > -1) {
platformConsents.splice(index, 1);
}
}
updatedConsents.socialMediaConsents = platformConsents;
onConsentChange(updatedConsents);
};
const isPlatformChecked = (platformId) => {
return consents.socialMediaConsents?.some(c => c.platformId === platformId) || false;
};
return (
<Paper
sx={{
p: 3,
mb: 3,
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
border: '2px solid #e0e0e0'
}}
>
{/* Aufklärungshinweis */}
<Alert severity="info" icon={<InfoIcon />} sx={{ mb: 3 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
Wichtiger Hinweis
</Typography>
<Typography variant="body2">
Alle hochgeladenen Inhalte werden vom Hobbyhimmel-Team geprüft, bevor sie
angezeigt oder veröffentlicht werden. Wir behalten uns vor, Inhalte nicht
zu zeigen oder rechtswidrige Inhalte zu entfernen.
</Typography>
<Typography variant="body2" sx={{ mt: 1 }}>
Nach dem Upload erhalten Sie eine Gruppen-ID als Referenz für die Kontaktaufnahme.
</Typography>
</Alert>
{/* Pflicht-Zustimmung: Werkstatt-Anzeige */}
<Box sx={{ mb: 3 }}>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600, color: '#333' }}>
Anzeige in der Werkstatt *
</Typography>
<FormControlLabel
control={
<Checkbox
checked={consents.workshopConsent || false}
onChange={handleWorkshopChange}
disabled={disabled}
required
sx={{
color: '#4CAF50',
'&.Mui-checked': { color: '#4CAF50' }
}}
/>
}
label={
<Typography variant="body2" sx={{ color: '#555' }}>
Ich willige ein, dass meine hochgeladenen Bilder auf dem Monitor in
der offenen Werkstatt des Hobbyhimmels angezeigt werden dürfen.
Die Bilder sind nur lokal im Hobbyhimmel sichtbar und werden nicht
über das Internet zugänglich gemacht. <strong>(Pflichtfeld)</strong>
</Typography>
}
/>
</Box>
<Divider sx={{ my: 3 }} />
{/* Optional: Social Media Veröffentlichung */}
<Box>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600, color: '#333' }}>
Social Media Veröffentlichung (optional)
</Typography>
<Typography variant="body2" sx={{ mb: 2, color: '#666' }}>
Ich willige ein, dass meine Bilder und Texte auf folgenden Social Media
Plattformen veröffentlicht werden dürfen (inklusive aller angegebenen
Informationen wie Name und Beschreibung):
</Typography>
{loading ? (
<Typography sx={{ color: '#666', fontStyle: 'italic' }}>
Lade Plattformen...
</Typography>
) : error ? (
<Alert severity="warning" sx={{ mb: 2 }}>
{error}
</Alert>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{platforms.map(platform => {
const IconComponent = ICON_MAP[platform.icon_name] || InfoIcon;
return (
<FormControlLabel
key={platform.id}
control={
<Checkbox
checked={isPlatformChecked(platform.id)}
onChange={handleSocialMediaChange(platform.id)}
disabled={disabled}
sx={{
color: '#2196F3',
'&.Mui-checked': { color: '#2196F3' }
}}
/>
}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconComponent fontSize="small" sx={{ color: '#2196F3' }} />
<Typography variant="body2">
{platform.display_name}
</Typography>
</Box>
}
/>
);
})}
</Box>
)}
</Box>
{/* Widerrufs-Hinweis */}
<Alert severity="info" sx={{ mt: 3 }}>
<Typography variant="caption" sx={{ display: 'block' }}>
<strong>Widerruf Ihrer Einwilligung:</strong> Sie können Ihre Einwilligung
jederzeit widerrufen. Kontaktieren Sie uns hierzu mit Ihrer Gruppen-ID unter:{' '}
<strong>it@hobbyhimmel.de</strong>
</Typography>
</Alert>
</Paper>
);
}
export default ConsentCheckboxes;

View File

@ -0,0 +1,188 @@
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Box,
IconButton,
Alert,
Tooltip,
Divider
} from '@mui/material';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import CloseIcon from '@mui/icons-material/Close';
/**
* UploadSuccessDialog Component
*
* Zeigt Erfolgsmeldung nach Upload mit:
* - Gruppen-ID (kopierbar)
* - Anzahl hochgeladener Bilder
* - GDPR Kontaktinformationen
* - Hinweis auf Moderation
*/
function UploadSuccessDialog({ open, onClose, groupId, uploadCount }) {
const [copied, setCopied] = useState(false);
const handleCopyGroupId = () => {
navigator.clipboard.writeText(groupId).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}).catch(err => {
console.error('Failed to copy:', err);
});
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
borderRadius: '16px',
boxShadow: '0 8px 32px rgba(0,0,0,0.2)'
}
}}
>
{/* Header mit Schließen-Button */}
<DialogTitle sx={{ pb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircleIcon sx={{ color: '#4CAF50', fontSize: 32 }} />
<Typography variant="h5" sx={{ fontWeight: 600, color: '#333' }}>
Upload erfolgreich!
</Typography>
</Box>
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<DialogContent sx={{ pb: 3 }}>
{/* Success Message */}
<Alert severity="success" sx={{ mb: 3 }}>
<Typography variant="body2">
<strong>{uploadCount}</strong> {uploadCount === 1 ? 'Bild wurde' : 'Bilder wurden'} erfolgreich hochgeladen
und werden nach der Prüfung durch das Hobbyhimmel-Team angezeigt.
</Typography>
</Alert>
{/* Gruppen-ID Anzeige */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 1, color: '#666', fontWeight: 600 }}>
Ihre Referenz-Nummer:
</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
p: 2,
bgcolor: '#f5f5f5',
borderRadius: '8px',
border: '2px solid #e0e0e0'
}}
>
<Typography
variant="h6"
sx={{
flex: 1,
fontFamily: 'monospace',
color: '#1976d2',
fontWeight: 600
}}
>
{groupId}
</Typography>
<Tooltip title={copied ? 'Kopiert!' : 'Kopieren'}>
<IconButton
onClick={handleCopyGroupId}
size="small"
sx={{
bgcolor: copied ? '#4CAF50' : '#e0e0e0',
color: copied ? '#fff' : '#666',
transition: 'all 0.3s',
'&:hover': {
bgcolor: copied ? '#45a049' : '#d0d0d0'
}
}}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Typography variant="caption" sx={{ display: 'block', mt: 1, color: '#666' }}>
Notieren Sie sich diese Nummer für spätere Anfragen an das Hobbyhimmel-Team.
</Typography>
</Box>
<Divider sx={{ my: 2 }} />
{/* Nächste Schritte */}
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ mb: 1.5, fontWeight: 600, color: '#333' }}>
Was passiert jetzt?
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', gap: 1 }}>
<Typography variant="body2" sx={{ color: '#666', minWidth: '20px' }}></Typography>
<Typography variant="body2" sx={{ color: '#666' }}>
Ihre Bilder werden vom Team geprüft
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Typography variant="body2" sx={{ color: '#666', minWidth: '20px' }}></Typography>
<Typography variant="body2" sx={{ color: '#666' }}>
Nach Freigabe erscheinen sie auf dem Werkstatt-Monitor
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Typography variant="body2" sx={{ color: '#666', minWidth: '20px' }}></Typography>
<Typography variant="body2" sx={{ color: '#666' }}>
Bei gewählter Social Media Einwilligung werden sie entsprechend veröffentlicht
</Typography>
</Box>
</Box>
</Box>
{/* GDPR Kontakt-Info */}
<Alert severity="info" sx={{ mt: 2 }}>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5 }}>
<strong>Fragen oder Widerruf Ihrer Einwilligung?</strong>
</Typography>
<Typography variant="caption">
Kontaktieren Sie uns mit Ihrer Referenz-Nummer unter:{' '}
<strong>it@hobbyhimmel.de</strong>
</Typography>
</Alert>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 3 }}>
<Button
onClick={onClose}
variant="contained"
size="large"
fullWidth
sx={{
bgcolor: '#1976d2',
'&:hover': { bgcolor: '#1565c0' },
textTransform: 'none',
fontWeight: 600,
py: 1.5
}}
>
Schließen
</Button>
</DialogActions>
</Dialog>
);
}
export default UploadSuccessDialog;

View File

@ -11,6 +11,8 @@ import ImageGallery from '../ComponentUtils/ImageGallery';
import DescriptionInput from '../ComponentUtils/MultiUpload/DescriptionInput';
import UploadProgress from '../ComponentUtils/MultiUpload/UploadProgress';
import Loading from '../ComponentUtils/LoadingAnimation/Loading';
import ConsentCheckboxes from '../ComponentUtils/MultiUpload/ConsentCheckboxes';
import UploadSuccessDialog from '../ComponentUtils/MultiUpload/UploadSuccessDialog';
// Utils
import { uploadImageBatch } from '../../Utils/batchUpload';
@ -30,12 +32,17 @@ function MultiUploadPage() {
description: '',
name: ''
});
const [consents, setConsents] = useState({
workshopConsent: false,
socialMediaConsents: []
});
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadComplete, setUploadComplete] = useState(false);
const [uploadResult, setUploadResult] = useState(null);
const [isEditMode, setIsEditMode] = useState(false);
const [imageDescriptions, setImageDescriptions] = useState({});
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
// Cleanup object URLs when component unmounts
useEffect(() => {
@ -94,6 +101,10 @@ function MultiUploadPage() {
description: '',
name: ''
});
setConsents({
workshopConsent: false,
socialMediaConsents: []
});
setImageDescriptions({});
setIsEditMode(false);
};
@ -138,6 +149,17 @@ function MultiUploadPage() {
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;
}
setUploading(true);
setUploadProgress(0);
@ -162,15 +184,16 @@ function MultiUploadPage() {
description: imageDescriptions[img.id] || ''
}));
const result = await uploadImageBatch(filesToUpload, metadata, descriptionsArray);
const result = await uploadImageBatch(filesToUpload, metadata, descriptionsArray, consents);
clearInterval(progressInterval);
setUploadProgress(100);
// Kurze Verzögerung für UX, dann Erfolgsmeldung anzeigen
// Show success dialog
setTimeout(() => {
setUploadComplete(true);
setUploadResult(result);
setShowSuccessDialog(true);
}, 500);
} catch (error) {
@ -224,6 +247,12 @@ function MultiUploadPage() {
{selectedImages.length > 0 && (
<>
<ConsentCheckboxes
consents={consents}
onConsentChange={setConsents}
disabled={uploading}
/>
<DescriptionInput
metadata={metadata}
onMetadataChange={setMetadata}
@ -251,7 +280,7 @@ function MultiUploadPage() {
}
}}
onClick={handleUpload}
disabled={uploading || selectedImages.length === 0}
disabled={uploading || selectedImages.length === 0 || !consents.workshopConsent}
size="large"
>
🚀 {selectedImages.length} Bild{selectedImages.length !== 1 ? 'er' : ''} hochladen
@ -294,62 +323,25 @@ function MultiUploadPage() {
totalFiles={selectedImages.length}
isUploading={uploading}
/>
{uploadComplete && uploadResult && (
<Box sx={{
mt: 4,
p: 3,
borderRadius: '12px',
background: 'linear-gradient(135deg, #4CAF50 0%, #45a049 100%)',
color: 'white',
boxShadow: '0 4px 20px rgba(76, 175, 80, 0.4)',
animation: 'slideIn 0.5s ease-out',
'@keyframes slideIn': {
from: {
opacity: 0,
transform: 'translateY(-20px)'
},
to: {
opacity: 1,
transform: 'translateY(0)'
}
}
}}>
<Typography sx={{ fontSize: '28px', fontWeight: 'bold', mb: 1 }}>
Upload erfolgreich!
</Typography>
<Typography sx={{ fontSize: '18px', mb: 3 }}>
{uploadResult.imageCount} Bild{uploadResult.imageCount !== 1 ? 'er' : ''} wurden hochgeladen.
</Typography>
<Button
sx={{
background: 'white',
color: '#4CAF50',
fontWeight: 'bold',
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'
}}
onClick={() => window.location.reload()}
>
👍 Alles klar!
</Button>
</Box>
)}
</div>
)}
</CardContent>
</Card>
</Container>
{/* Success Dialog */}
{showSuccessDialog && uploadResult && (
<UploadSuccessDialog
open={showSuccessDialog}
onClose={() => {
setShowSuccessDialog(false);
window.location.reload();
}}
groupId={uploadResult.groupId}
uploadCount={uploadResult.imageCount}
/>
)}
<div className="footerContainer">
<Footer />
</div>

View File

@ -1,5 +1,5 @@
// Batch-Upload Funktion für mehrere Bilder
export const uploadImageBatch = async (images, metadata, descriptions = [], onProgress) => {
export const uploadImageBatch = async (images, metadata, descriptions = [], consents = null, onProgress) => {
if (!images || images.length === 0) {
throw new Error('Keine Bilder zum Upload ausgewählt');
}
@ -14,11 +14,16 @@ export const uploadImageBatch = async (images, metadata, descriptions = [], onPr
// Füge Metadaten hinzu
formData.append('metadata', JSON.stringify(metadata || {}));
// Füge Beschreibungen hinzu (NEU)
// Füge Beschreibungen hinzu
if (descriptions && descriptions.length > 0) {
formData.append('descriptions', JSON.stringify(descriptions));
}
// Füge Einwilligungen hinzu (GDPR)
if (consents) {
formData.append('consents', JSON.stringify(consents));
}
try {
const response = await fetch('/api/upload/batch', {
method: 'POST',