This commit is contained in:
Matthias Lotz 2025-10-20 19:19:21 +02:00
parent bf4ff75ce5
commit 0c0547b4f5
8 changed files with 175 additions and 156 deletions

View File

@ -109,6 +109,8 @@ class GroupRepository {
// Hole alle Gruppen mit Bildern für Slideshow (nur freigegebene)
async getAllGroupsWithImages() {
const groupFormatter = require('../utils/groupFormatter');
const groups = await dbManager.all(`
SELECT * FROM groups
WHERE approved = TRUE
@ -123,21 +125,7 @@ class GroupRepository {
ORDER BY upload_order ASC
`, [group.group_id]);
result.push({
groupId: group.group_id,
year: group.year,
title: group.title,
description: group.description,
name: group.name,
uploadDate: group.upload_date,
images: images.map(img => ({
fileName: img.file_name,
originalName: img.original_name,
filePath: img.file_path,
uploadOrder: img.upload_order
})),
imageCount: images.length
});
result.push(groupFormatter.formatGroupDetail(group, images));
}
return result;
@ -273,11 +261,8 @@ class GroupRepository {
ORDER BY g.approved ASC, g.upload_date DESC
`);
return groups.map(group => ({
...group,
approved: Boolean(group.approved),
image_count: group.image_count || 0
}));
const groupFormatter = require('../utils/groupFormatter');
return groups.map(group => groupFormatter.formatGroupListRow(group));
}
// Hole Gruppe für Moderation (inkl. nicht-freigegebene)
@ -296,25 +281,8 @@ class GroupRepository {
ORDER BY upload_order ASC
`, [groupId]);
return {
group_id: group.group_id,
year: group.year,
title: group.title,
description: group.description,
name: group.name,
uploadDate: group.upload_date,
approved: group.approved,
images: images.map(img => ({
id: img.id,
fileName: img.file_name,
originalName: img.original_name,
filePath: img.file_path,
uploadOrder: img.upload_order,
fileSize: img.file_size,
mimeType: img.mime_type
})),
imageCount: images.length
};
const groupFormatter = require('../utils/groupFormatter');
return groupFormatter.formatGroupDetail(group, images);
}
// Statistiken (erweitert um Freigabe-Status)

View File

@ -0,0 +1,43 @@
/**
* Utility to format DB rows / repository results into a consistent API DTO (camelCase).
*/
function formatGroupListRow(row) {
return {
groupId: row.group_id,
year: row.year,
title: row.title,
description: row.description,
name: row.name,
uploadDate: row.upload_date,
approved: Boolean(row.approved),
imageCount: row.image_count ? Number(row.image_count) : 0,
previewImage: row.preview_image || null
};
}
function formatGroupDetail(groupRow, images) {
return {
groupId: groupRow.group_id,
year: groupRow.year,
title: groupRow.title,
description: groupRow.description,
name: groupRow.name,
uploadDate: groupRow.upload_date,
approved: Boolean(groupRow.approved),
images: images.map(img => ({
id: img.id,
fileName: img.file_name,
originalName: img.original_name,
filePath: img.file_path,
uploadOrder: img.upload_order,
fileSize: img.file_size || null,
mimeType: img.mime_type || null
})),
imageCount: images.length
};
}
module.exports = {
formatGroupListRow,
formatGroupDetail
};

View File

@ -1,6 +1,4 @@
/* Styles extracted from GroupImagesPage makeStyles */
/* Page-specific styles for GroupImagesPage */
.group-images-container { padding-top: 20px; padding-bottom: 40px; min-height: 80vh; }
.group-card { border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); padding: 20px; margin-bottom: 20px; }
.header-text { font-family: roboto; font-weight: 400; font-size: 28px; text-align: center; margin-bottom: 10px; color: #333333; }
.subheader-text { font-family: roboto; font-weight: 300; font-size: 16px; color: #666666; text-align: center; margin-bottom: 30px; }
.action-buttons { display: flex; gap: 15px; justify-content: center; margin-top: 20px; flex-wrap: wrap; }

View File

@ -1,20 +1,6 @@
/* Extracted styles from GroupsOverviewPage makeStyles */
/* Page-specific styles for GroupsOverviewPage */
.groups-overview-container { padding-top: 20px; padding-bottom: 40px; min-height: 80vh; }
.header-card { border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); margin-bottom: 30px; text-align: center; padding: 20px; }
.header-title { font-family: roboto; font-weight: 500; font-size: 28px; color: #333333; margin-bottom: 10px; }
.header-subtitle { font-family: roboto; font-size: 16px; color: #666666; margin-bottom: 20px; }
.group-card { border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); transition: all 0.3s ease; height: 100%; display: flex; flex-direction: column; }
.group-card:hover { transform: translateY(-4px); box-shadow: 0 8px 20px rgba(0,0,0,0.15); }
.group-image { height: 180px; object-fit: cover; }
.group-content { flex-grow: 1; display: flex; flex-direction: column; }
.group-title { font-family: roboto; font-weight: 500; font-size: 16px; color: #333333; margin-bottom: 8px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.group-meta { font-size: 12px; color: #999999; margin-bottom: 15px; }
.group-actions { margin-top: auto; display: flex; gap: 8px; justify-content: space-between; }
.view-button { border-radius: 20px; text-transform: none; font-size: 12px; padding: 6px 16px; background: linear-gradient(45deg, #4CAF50 30%, #45a049 90%); color: white; }
.view-button:hover { background: linear-gradient(45deg, #45a049 30%, #4CAF50 90%); }
.action-buttons { display:flex; gap:15px; justify-content:center; flex-wrap: wrap; margin-top:20px; }
.primary-button { border-radius: 25px; padding: 12px 30px; font-size:16px; font-weight:500; text-transform:none; background: linear-gradient(45deg, #2196F3 30%, #1976D2 90%); color:white; }
.home-button { border-radius:25px; padding:12px 30px; font-size:16px; font-weight:500; text-transform:none; border:2px solid #4CAF50; color:#4CAF50; background-color: transparent; }
.empty-state { text-align:center; padding:60px 20px; }
.loading-container { text-align:center; padding:60px 20px; }
@media (max-width:800px) { .nav__links, .cta { display:none; } }

View File

@ -74,3 +74,12 @@
/* Standard groups grid used by moderation and overview pages */
.groups-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 20px; }
/* Common CTA / page-level utilities (moved from page CSS) */
.view-button { border-radius: 20px; text-transform: none; font-size: 12px; padding: 6px 16px; background: linear-gradient(45deg, #4CAF50 30%, #45a049 90%); color: white; border: none; cursor: pointer; }
.view-button:hover { background: linear-gradient(45deg, #45a049 30%, #4CAF50 90%); }
.action-buttons { display:flex; gap:15px; justify-content:center; flex-wrap: wrap; margin-top:20px; }
.primary-button { border-radius: 25px; padding: 12px 30px; font-size:16px; font-weight:500; text-transform:none; background: linear-gradient(45deg, #2196F3 30%, #1976D2 90%); color:white; border:none; cursor:pointer; }
.home-button { border-radius:25px; padding:12px 30px; font-size:16px; font-weight:500; text-transform:none; border:2px solid #4CAF50; color:#4CAF50; background-color: transparent; cursor:pointer; }
.empty-state { text-align:center; padding:60px 20px; }
.loading-container { text-align:center; padding:60px 20px; }

View File

@ -0,0 +1,91 @@
import React from 'react';
import PropTypes from 'prop-types';
import './Css/main.css';
const GroupCard = ({ group, onApprove, onViewImages, onDelete, isPending, showActions = true }) => {
let previewUrl = null;
if (group.previewImage) {
previewUrl = `/download/${group.previewImage.split('/').pop()}`;
} else if (group.images && group.images.length > 0 && group.images[0].filePath) {
// images may provide filePath already
previewUrl = group.images[0].filePath;
}
return (
<div className={`group-card ${isPending ? 'pending' : 'approved'}`}>
<div className="group-preview">
{previewUrl ? (
<img src={previewUrl} alt="Preview" className="preview-image" />
) : (
<div className="no-preview">Kein Vorschaubild</div>
)}
<div className="image-count">{group.imageCount} Bilder</div>
</div>
<div className="group-info">
<h3>{group.title}</h3>
<p className="group-meta">{group.year} {group.name}</p>
{group.description && (
<p className="group-description">{group.description}</p>
)}
<p className="upload-date">
Hochgeladen: {new Date(group.uploadDate).toLocaleDateString('de-DE')}
</p>
</div>
<div className="group-actions">
{showActions ? (
<>
<button
className="btn btn-secondary"
onClick={() => onViewImages(group)}
>
Gruppe editieren
</button>
{isPending ? (
<button
className="btn btn-success"
onClick={() => onApprove(group.groupId, true)}
>
Freigeben
</button>
) : (
<button
className="btn btn-warning"
onClick={() => onApprove(group.groupId, false)}
>
Sperren
</button>
)}
<button
className="btn btn-danger"
onClick={() => onDelete(group.groupId)}
>
🗑 Löschen
</button>
</>
) : (
<button
className="view-button"
onClick={() => onViewImages(group)}
title="Anzeigen"
>
Anzeigen
</button>
)}
</div>
</div>
);
};
GroupCard.propTypes = {
group: PropTypes.object.isRequired,
onApprove: PropTypes.func.isRequired,
onViewImages: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
isPending: PropTypes.bool
};
export default GroupCard;

View File

@ -24,6 +24,7 @@ import Swal from 'sweetalert2/dist/sweetalert2.js';
// Components
import Navbar from '../ComponentUtils/Headers/Navbar';
import Footer from '../ComponentUtils/Footer';
import GroupCard from '../ComponentUtils/GroupCard';
// Utils
import { fetchAllGroups, deleteGroup } from '../../Utils/batchUpload';
@ -174,36 +175,16 @@ function GroupsOverviewPage() {
<div className="groups-grid">
{groups.map((group) => (
<Grid item xs={12} sm={6} md={4} key={group.groupId} className="grid-item-stretch">
<Card className="group-card card-stretch">
{group.images && group.images.length > 0 && (
<div className="group-preview">
<img className="preview-image" src={group.images[0].filePath} alt={group.description || 'Slideshow Vorschau'} />
</div>
)}
<CardContent className="group-content">
<Typography className="group-title">
{group.description || 'Unbenannte Slideshow'}
</Typography>
<Typography className="group-meta">
📅 {formatDate(group.uploadDate)} 📸 {group.images?.length || 0} Bilder
</Typography>
<div className="group-actions">
<Button
className="view-button"
onClick={() => handleViewSlideshow(group.groupId)}
startIcon={<SlideshowIcon />}
fullWidth
>
Anzeigen
</Button>
</div>
</CardContent>
</Card>
</Grid>
<div key={group.groupId} className="grid-item-stretch">
<GroupCard
group={group}
onApprove={() => { /* no-op on public page */ }}
onViewImages={() => handleViewSlideshow(group.groupId)}
onDelete={() => { /* no-op on public page */ }}
isPending={false}
showActions={false}
/>
</div>
))}
</div>
</>

View File

@ -5,6 +5,7 @@ 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 ModerationPage = () => {
const [groups, setGroups] = useState([]);
@ -53,7 +54,7 @@ const ModerationPage = () => {
// Update local state
setGroups(groups.map(group =>
group.group_id === groupId
group.groupId === groupId
? { ...group, approved: approved }
: group
));
@ -84,7 +85,7 @@ const ModerationPage = () => {
}
// Remove image from selectedGroup
if (selectedGroup && selectedGroup.group_id === groupId) {
if (selectedGroup && selectedGroup.groupId === groupId) {
const updatedImages = selectedGroup.images.filter(img => img.id !== imageId);
setSelectedGroup({
...selectedGroup,
@ -95,8 +96,8 @@ const ModerationPage = () => {
// Update group image count
setGroups(groups.map(group =>
group.group_id === groupId
? { ...group, image_count: group.image_count - 1 }
group.groupId === groupId
? { ...group, imageCount: group.imageCount - 1 }
: group
));
@ -121,8 +122,8 @@ const ModerationPage = () => {
throw new Error(`HTTP error! status: ${response.status}`);
}
setGroups(groups.filter(group => group.group_id !== groupId));
if (selectedGroup && selectedGroup.group_id === groupId) {
setGroups(groups.filter(group => group.groupId !== groupId));
if (selectedGroup && selectedGroup.groupId === groupId) {
setSelectedGroup(null);
setShowImages(false);
}
@ -134,7 +135,7 @@ const ModerationPage = () => {
// Navigate to the dedicated group images page
const viewGroupImages = (group) => {
history.push(`/groups/${group.group_id}`);
history.push(`/groups/${group.groupId}`);
};
if (loading) {
@ -199,7 +200,7 @@ const ModerationPage = () => {
<div className="groups-grid">
{pendingGroups.map(group => (
<GroupCard
key={group.group_id}
key={group.groupId}
group={group}
onApprove={approveGroup}
onViewImages={viewGroupImages}
@ -220,7 +221,7 @@ const ModerationPage = () => {
<div className="groups-grid">
{approvedGroups.map(group => (
<GroupCard
key={group.group_id}
key={group.groupId}
group={group}
onApprove={approveGroup}
onViewImages={viewGroupImages}
@ -249,65 +250,7 @@ const ModerationPage = () => {
);
};
const GroupCard = ({ group, onApprove, onViewImages, onDelete, isPending }) => {
const previewUrl = group.preview_image ? `/download/${group.preview_image.split('/').pop()}` : null;
return (
<div className={`group-card ${isPending ? 'pending' : 'approved'}`}>
<div className="group-preview">
{previewUrl ? (
<img src={previewUrl} alt="Preview" className="preview-image" />
) : (
<div className="no-preview">Kein Vorschaubild</div>
)}
<div className="image-count">{group.image_count} Bilder</div>
</div>
<div className="group-info">
<h3>{group.title}</h3>
<p className="group-meta">{group.year} {group.name}</p>
{group.description && (
<p className="group-description">{group.description}</p>
)}
<p className="upload-date">
Hochgeladen: {new Date(group.upload_date).toLocaleDateString('de-DE')}
</p>
</div>
<div className="group-actions">
<button
className="btn btn-secondary"
onClick={() => onViewImages(group)}
>
Gruppe editieren
</button>
{isPending ? (
<button
className="btn btn-success"
onClick={() => onApprove(group.group_id, true)}
>
Freigeben
</button>
) : (
<button
className="btn btn-warning"
onClick={() => onApprove(group.group_id, false)}
>
Sperren
</button>
)}
<button
className="btn btn-danger"
onClick={() => onDelete(group.group_id)}
>
🗑 Löschen
</button>
</div>
</div>
);
};
// `GroupCard` has been extracted to `../ComponentUtils/GroupCard`
const ImageModal = ({ group, onClose, onDeleteImage }) => {
return (
@ -340,7 +283,7 @@ const ImageModal = ({ group, onClose, onDeleteImage }) => {
<span className="image-name">{image.originalName}</span>
<button
className="btn btn-danger btn-sm"
onClick={() => onDeleteImage(group.group_id, image.id)}
onClick={() => onDeleteImage(group.groupId, image.id)}
title="Bild löschen"
>
🗑