From c7f75a4bd8065699a90b371b5915b7d17e34567f Mon Sep 17 00:00:00 2001 From: "matthias.lotz" Date: Mon, 20 Oct 2025 19:47:06 +0200 Subject: [PATCH] Renaming GroupImagePage -> ModerationGroupImagesPage.js --- frontend/src/App.js | 10 +- .../MultiUpload/ImagePreviewGallery.js | 85 ++--- .../src/Components/Pages/GroupImagesPage.js | 68 +++- .../Pages/ModerationGroupImagesPage.js | 194 +++++++++++ .../Components/Pages/ModerationGroupsPage.js | 301 ++++++++++++++++++ .../Components/Pages/PublicGroupImagesPage.js | 62 ++++ 6 files changed, 642 insertions(+), 78 deletions(-) create mode 100644 frontend/src/Components/Pages/ModerationGroupImagesPage.js create mode 100644 frontend/src/Components/Pages/ModerationGroupsPage.js create mode 100644 frontend/src/Components/Pages/PublicGroupImagesPage.js diff --git a/frontend/src/App.js b/frontend/src/App.js index 788e5ed..87c225c 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -6,8 +6,9 @@ import UploadedImage from './Components/Pages/UploadedImagePage'; import MultiUploadPage from './Components/Pages/MultiUploadPage'; import SlideshowPage from './Components/Pages/SlideshowPage'; import GroupsOverviewPage from './Components/Pages/GroupsOverviewPage'; -import GroupImagesPage from './Components/Pages/GroupImagesPage'; -import ModerationPage from './Components/Pages/ModerationPage'; +import ModerationGroupsPage from './Components/Pages/ModerationGroupsPage'; +import ModerationGroupImagesPage from './Components/Pages/ModerationGroupImagesPage'; +import PublicGroupImagesPage from './Components/Pages/PublicGroupImagesPage'; import FZF from './Components/Pages/404Page.js' function App() { @@ -17,9 +18,10 @@ function App() { - + - + + diff --git a/frontend/src/Components/ComponentUtils/MultiUpload/ImagePreviewGallery.js b/frontend/src/Components/ComponentUtils/MultiUpload/ImagePreviewGallery.js index 13aab70..c3ea0a9 100644 --- a/frontend/src/Components/ComponentUtils/MultiUpload/ImagePreviewGallery.js +++ b/frontend/src/Components/ComponentUtils/MultiUpload/ImagePreviewGallery.js @@ -1,7 +1,6 @@ import React from 'react'; import { makeStyles } from '@material-ui/core/styles'; -import { Grid, Card, CardMedia, IconButton, Typography, Box } from '@material-ui/core'; -import { Close as CloseIcon, DragIndicator as DragIcon } from '@material-ui/icons'; +import { Grid, Card, Typography } from '@material-ui/core'; const useStyles = makeStyles({ galleryContainer: { @@ -21,48 +20,24 @@ const useStyles = makeStyles({ height: 150, objectFit: 'cover' }, - removeButton: { - position: 'absolute', - top: '5px', - right: '5px', - backgroundColor: 'rgba(255, 255, 255, 0.9)', - color: '#f44336', - '&:hover': { - backgroundColor: '#ffffff', - color: '#d32f2f' - } - }, - dragHandle: { - position: 'absolute', - top: '5px', - left: '5px', - backgroundColor: 'rgba(255, 255, 255, 0.9)', - color: '#666666', - cursor: 'grab' - }, imageOrder: { position: 'absolute', - bottom: '5px', - left: '5px', + top: '10px', + left: '10px', backgroundColor: 'rgba(0, 0, 0, 0.7)', color: 'white', borderRadius: '12px', - padding: '2px 8px', + padding: '4px 8px', fontSize: '12px', - fontWeight: 'bold' + fontWeight: 'bold', + zIndex: 2 }, - fileName: { - position: 'absolute', - bottom: '0', - left: '0', - right: '0', - backgroundColor: 'rgba(0, 0, 0, 0.7)', - color: 'white', - padding: '5px', - fontSize: '11px', - textOverflow: 'ellipsis', + fileMeta: { + fontSize: '12px', + color: '#6c757d', + marginTop: '6px', overflow: 'hidden', - whiteSpace: 'nowrap' + textOverflow: 'ellipsis' }, galleryHeader: { marginBottom: '15px', @@ -103,34 +78,30 @@ function ImagePreviewGallery({ images, onRemoveImage, onReorderImages }) {
{`Vorschau
- handleRemoveImage(index)} - title="Bild entfernen" - > - - - - - - - -
+
{index + 1}
-
- {image.name || image.originalName || 'Bild'}{image.size ? ' • ' + formatFileSize(image.size) : ''} +
+

{image.originalName || image.name || 'Bild'}

+
+ {image.remoteUrl && image.remoteUrl.includes('/download/') ? ( +
Server-Datei: {image.remoteUrl.split('/').pop()}
+ ) : image.filePath ? ( +
Server-Datei: {image.filePath.split('/').pop()}
+ ) : null} + {image.captureDate ?
Aufnahmedatum: {new Date(image.captureDate).toLocaleDateString('de-DE')}
: null} +
+
+ +
+ +
diff --git a/frontend/src/Components/Pages/GroupImagesPage.js b/frontend/src/Components/Pages/GroupImagesPage.js index 2465f46..cf56c47 100644 --- a/frontend/src/Components/Pages/GroupImagesPage.js +++ b/frontend/src/Components/Pages/GroupImagesPage.js @@ -9,6 +9,7 @@ import Navbar from '../ComponentUtils/Headers/Navbar'; import Footer from '../ComponentUtils/Footer'; import ImagePreviewGallery from '../ComponentUtils/MultiUpload/ImagePreviewGallery'; import DescriptionInput from '../ComponentUtils/MultiUpload/DescriptionInput'; +import GroupCard from '../ComponentUtils/GroupCard'; import '../Pages/Css/GroupImagesPage.css'; @@ -127,6 +128,36 @@ const GroupImagesPage = () => { setSelectedImages(prev => prev.filter((_, index) => index !== indexToRemove)); }; + const approveGroup = async (approved) => { + try { + const response = await fetch(`/groups/${groupId}/approve`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ approved }) + }); + if (!response.ok) throw new Error('Freigabe fehlgeschlagen'); + const updated = { ...group, approved }; + setGroup(updated); + Swal.fire({ icon: 'success', title: approved ? 'Gruppe freigegeben' : 'Gruppe gesperrt', timer: 1200, showConfirmButton: false }); + } catch (e) { + console.error(e); + Swal.fire({ icon: 'error', title: 'Fehler beim Aktualisieren des Freigabestatus' }); + } + }; + + const deleteGroup = async () => { + if (!window.confirm('Gruppe wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.')) return; + try { + const res = await fetch(`/groups/${groupId}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Löschen fehlgeschlagen'); + Swal.fire({ icon: 'success', title: 'Gruppe gelöscht', timer: 1200, showConfirmButton: false }); + history.push('/moderation'); + } catch (e) { + console.error(e); + Swal.fire({ icon: 'error', title: 'Fehler beim Löschen der Gruppe' }); + } + }; + if (loading) return
Lade Gruppe...
; if (error) return
{error}
; if (!group) return
Gruppe nicht gefunden
; @@ -135,32 +166,35 @@ const GroupImagesPage = () => {
- - - - Gruppe bearbeiten - {group.title || ''} + + {/* Use shared GroupCard for top summary/actions */} + approveGroup(approved)} + onViewImages={() => { /* already on this page */ }} + onDelete={() => deleteGroup()} + isPending={!group.approved} + /> - + - {selectedImages.length > 0 && ( - <> - + {selectedImages.length > 0 && ( + <> + -
- - -
- - )} +
+ + +
+ + )} -
-
); + }; export default GroupImagesPage; diff --git a/frontend/src/Components/Pages/ModerationGroupImagesPage.js b/frontend/src/Components/Pages/ModerationGroupImagesPage.js new file mode 100644 index 0000000..32a4ad0 --- /dev/null +++ b/frontend/src/Components/Pages/ModerationGroupImagesPage.js @@ -0,0 +1,194 @@ +import React, { useState, useEffect } from 'react'; +import { useParams, useHistory } from 'react-router-dom'; +import { Button, Container } from '@material-ui/core'; +import Swal from 'sweetalert2/dist/sweetalert2.js'; +import 'sweetalert2/src/sweetalert2.scss'; + +// Components +import Navbar from '../ComponentUtils/Headers/Navbar'; +import Footer from '../ComponentUtils/Footer'; +import ImagePreviewGallery from '../ComponentUtils/MultiUpload/ImagePreviewGallery'; +import DescriptionInput from '../ComponentUtils/MultiUpload/DescriptionInput'; +import GroupCard from '../ComponentUtils/GroupCard'; + + +import '../Pages/Css/GroupImagesPage.css'; + +const ModerationGroupImagesPage = () => { + const { groupId } = useParams(); + const history = useHistory(); + 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: '' }); + + useEffect(() => { + loadGroup(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [groupId]); + + const loadGroup = 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 => ({ + remoteUrl: `/download/${img.fileName}`, + originalName: img.originalName || img.fileName, + id: img.id + })); + setSelectedImages(mapped); + } + + // populate metadata from group + setMetadata({ + year: data.year || new Date().getFullYear(), + title: data.title || '', + description: data.description || '', + name: data.name || '' + }); + } catch (e) { + setError('Fehler beim Laden der Gruppe'); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + if (!group) return; + setSaving(true); + try { + // Use metadata state (controlled by DescriptionInput) as source of truth + const payload = { + title: metadata.title, + description: metadata.description, + year: metadata.year, + name: metadata.name + }; + + 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 fehlgeschlagen'); + } + + Swal.fire({ icon: 'success', title: 'Gruppe erfolgreich aktualisiert', timer: 1500, showConfirmButton: false }); + history.push('/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)); + }; + + const approveGroup = async (approved) => { + try { + const response = await fetch(`/groups/${groupId}/approve`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ approved }) + }); + if (!response.ok) throw new Error('Freigabe fehlgeschlagen'); + const updated = { ...group, approved }; + setGroup(updated); + Swal.fire({ icon: 'success', title: approved ? 'Gruppe freigegeben' : 'Gruppe gesperrt', timer: 1200, showConfirmButton: false }); + } catch (e) { + console.error(e); + Swal.fire({ icon: 'error', title: 'Fehler beim Aktualisieren des Freigabestatus' }); + } + }; + + const deleteGroup = async () => { + if (!window.confirm('Gruppe wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.')) return; + try { + const res = await fetch(`/groups/${groupId}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Löschen fehlgeschlagen'); + Swal.fire({ icon: 'success', title: 'Gruppe gelöscht', timer: 1200, showConfirmButton: false }); + history.push('/moderation'); + } catch (e) { + console.error(e); + Swal.fire({ icon: 'error', title: 'Fehler beim Löschen der Gruppe' }); + } + }; + + if (loading) return
Lade Gruppe...
; + if (error) return
{error}
; + if (!group) return
Gruppe nicht gefunden
; + + return ( +
+ + + + {/* Use shared GroupCard for top summary/actions */} + approveGroup(approved)} + onViewImages={() => { /* already on this page */ }} + onDelete={() => deleteGroup()} + isPending={!group.approved} + /> + + + + {selectedImages.length > 0 && ( + <> + + +
+ + +
+ + )} + +
+ +
+
+ ); + +}; + +export default ModerationGroupImagesPage; diff --git a/frontend/src/Components/Pages/ModerationGroupsPage.js b/frontend/src/Components/Pages/ModerationGroupsPage.js new file mode 100644 index 0000000..9b623c6 --- /dev/null +++ b/frontend/src/Components/Pages/ModerationGroupsPage.js @@ -0,0 +1,301 @@ +import React, { useState, useEffect } from 'react'; +import { Helmet } from 'react-helmet'; +import { useHistory } from 'react-router-dom'; +import { Container } from '@material-ui/core'; +import './Css/ModerationPage.css'; +import Navbar from '../ComponentUtils/Headers/Navbar'; +import Footer from '../ComponentUtils/Footer'; +import GroupCard from '../ComponentUtils/GroupCard'; + +const ModerationGroupsPage = () => { + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedGroup, setSelectedGroup] = useState(null); + const [showImages, setShowImages] = useState(false); + const history = useHistory(); + + useEffect(() => { + loadModerationGroups(); + }, []); + + const loadModerationGroups = async () => { + try { + setLoading(true); + const response = await fetch('/moderation/groups'); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + setGroups(data.groups); + } catch (error) { + console.error('Fehler beim Laden der Moderations-Gruppen:', error); + setError('Fehler beim Laden der Gruppen'); + } finally { + setLoading(false); + } + }; + + const approveGroup = async (groupId, approved) => { + try { + const response = await fetch(`/groups/${groupId}/approve`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ approved: approved }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + // Update local state + setGroups(groups.map(group => + group.groupId === groupId + ? { ...group, approved: approved } + : group + )); + } catch (error) { + console.error('Fehler beim Freigeben der Gruppe:', error); + alert('Fehler beim Freigeben der Gruppe'); + } + }; + + const deleteImage = async (groupId, imageId) => { + console.log('deleteImage called with:', { groupId, imageId }); + console.log('API_URL:', window._env_.API_URL); + + try { + // Use relative URL to go through Nginx proxy + const url = `/groups/${groupId}/images/${imageId}`; + console.log('DELETE request to:', url); + + const response = await fetch(url, { + method: 'DELETE' + }); + + console.log('Response status:', response.status); + console.log('Response ok:', response.ok); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + // Remove image from selectedGroup + if (selectedGroup && selectedGroup.groupId === groupId) { + const updatedImages = selectedGroup.images.filter(img => img.id !== imageId); + setSelectedGroup({ + ...selectedGroup, + images: updatedImages, + imageCount: updatedImages.length + }); + } + + // Update group image count + setGroups(groups.map(group => + group.groupId === groupId + ? { ...group, imageCount: group.imageCount - 1 } + : group + )); + + } catch (error) { + console.error('Fehler beim Löschen des Bildes:', error); + console.error('Error details:', error.message, error.stack); + alert('Fehler beim Löschen des Bildes: ' + error.message); + } + }; + + const deleteGroup = async (groupId) => { + if (!window.confirm('Gruppe wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.')) { + return; + } + + try { + const response = await fetch(`/groups/${groupId}`, { + method: 'DELETE' + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + setGroups(groups.filter(group => group.groupId !== groupId)); + if (selectedGroup && selectedGroup.groupId === groupId) { + setSelectedGroup(null); + setShowImages(false); + } + } catch (error) { + console.error('Fehler beim Löschen der Gruppe:', error); + alert('Fehler beim Löschen der Gruppe'); + } + }; + + // Navigate to the dedicated group images page + const viewGroupImages = (group) => { + history.push(`/moderation/groups/${group.groupId}`); + }; + + if (loading) { + return
Lade Gruppen...
; + } + + if (error) { + return
{error}
; + } + + const pendingGroups = groups.filter(g => !g.approved); + const approvedGroups = groups.filter(g => g.approved); + + return ( +
+ + + Moderation - Interne Verwaltung + + + + + +

Moderation

+ +
+
+ {pendingGroups.length} + Wartend +
+
+ {approvedGroups.length} + Freigegeben +
+
+ {groups.length} + Gesamt +
+
+ +
+
+ {pendingGroups.length} + Wartend +
+
+ {approvedGroups.length} + Freigegeben +
+
+ {groups.length} + Gesamt +
+
+ + {/* Wartende Gruppen */} +
+

🔍 Wartende Freigabe ({pendingGroups.length})

+ {pendingGroups.length === 0 ? ( +

Keine wartenden Gruppen

+ ) : ( +
+ {pendingGroups.map(group => ( + + ))} +
+ )} +
+ + {/* Freigegebene Gruppen */} +
+

✅ Freigegebene Gruppen ({approvedGroups.length})

+ {approvedGroups.length === 0 ? ( +

Keine freigegebenen Gruppen

+ ) : ( +
+ {approvedGroups.map(group => ( + + ))} +
+ )} +
+ + {/* Bilder-Modal */} + {showImages && selectedGroup && ( + { + setShowImages(false); + setSelectedGroup(null); + }} + onDeleteImage={deleteImage} + /> + )} +
+
+
+ ); +}; + +// `GroupCard` has been extracted to `../ComponentUtils/GroupCard` + +const ImageModal = ({ group, onClose, onDeleteImage }) => { + return ( +
+
e.stopPropagation()}> +
+

{group.title}

+ +
+ +
+
+

Jahr: {group.year}

+

Ersteller: {group.name}

+ {group.description && ( +

Beschreibung: {group.description}

+ )} +

Bilder: {group.images.length}

+
+ +
+ {group.images.map(image => ( +
+ {image.originalName} +
+ {image.originalName} + +
+
+ ))} +
+
+
+
+ ); +}; + +export default ModerationGroupsPage; diff --git a/frontend/src/Components/Pages/PublicGroupImagesPage.js b/frontend/src/Components/Pages/PublicGroupImagesPage.js new file mode 100644 index 0000000..41d2e0f --- /dev/null +++ b/frontend/src/Components/Pages/PublicGroupImagesPage.js @@ -0,0 +1,62 @@ +import React, { useState, useEffect } from 'react'; +import { useParams, useHistory } from 'react-router-dom'; +import { Button, Container } from '@material-ui/core'; +import Navbar from '../ComponentUtils/Headers/Navbar'; +import Footer from '../ComponentUtils/Footer'; +import GroupCard from '../ComponentUtils/GroupCard'; +import ImagePreviewGallery from '../ComponentUtils/MultiUpload/ImagePreviewGallery'; +import '../Pages/Css/GroupImagesPage.css'; + +const PublicGroupImagesPage = () => { + const { groupId } = useParams(); + const history = useHistory(); + const [group, setGroup] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + loadGroup(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [groupId]); + + const loadGroup = async () => { + try { + setLoading(true); + // Public endpoint (no moderation controls) + const res = await fetch(`/groups/${groupId}`); + if (!res.ok) throw new Error('Nicht gefunden'); + const data = await res.json(); + setGroup(data); + } catch (e) { + setError('Fehler beim Laden der Gruppe'); + } finally { + setLoading(false); + } + }; + + if (loading) return
Lade Gruppe...
; + if (error) return
{error}
; + if (!group) return
Gruppe nicht gefunden
; + + return ( +
+ + + + + +
+ {group.images && group.images.length > 0 ? ( + ({ remoteUrl: `/download/${img.fileName}`, originalName: img.originalName || img.fileName, id: img.id }))} showRemove={false} /> + ) : ( +

Keine Bilder in dieser Gruppe.

+ )} +
+
+ +
+
+ ); +}; + +export default PublicGroupImagesPage;