Add member gallery generation feature in backend and frontend

This commit introduces a new API endpoint for generating a member gallery, allowing users to retrieve a composite image of active members' latest images. The backend has been updated with a new method in MemberService to handle gallery creation, while the frontend has been enhanced with a dialog for displaying the generated gallery. This feature improves the user experience by providing a visual representation of club members.
This commit is contained in:
Torsten Schulz (local)
2025-11-11 16:22:47 +01:00
parent 2bf5c0137b
commit ed15137003
4 changed files with 330 additions and 3 deletions

View File

@@ -116,6 +116,23 @@ const deleteMemberImage = async (req, res) => {
}
};
const generateMemberGallery = async (req, res) => {
try {
const { clubId } = req.params;
const { authcode: userToken } = req.headers;
const result = await MemberService.generateMemberGallery(userToken, clubId);
if (result.status === 200) {
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'no-store');
return res.status(200).send(result.buffer);
}
return res.status(result.status).json({ error: result.error || 'Galerie konnte nicht erstellt werden' });
} catch (error) {
console.error('[generateMemberGallery] - Error:', error);
res.status(500).json({ error: 'Failed to generate member gallery' });
}
};
const setPrimaryMemberImage = async (req, res) => {
try {
const { clubId, memberId, imageId } = req.params;
@@ -209,5 +226,6 @@ export {
quickUpdateMemberFormHandedOver,
quickDeactivateMember,
deleteMemberImage,
setPrimaryMemberImage
setPrimaryMemberImage,
generateMemberGallery
};

View File

@@ -11,7 +11,8 @@ import {
quickUpdateMemberFormHandedOver,
quickDeactivateMember,
deleteMemberImage,
setPrimaryMemberImage
setPrimaryMemberImage,
generateMemberGallery
} from '../controllers/memberController.js';
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
@@ -29,6 +30,7 @@ router.get('/image/:clubId/:memberId', authenticate, authorize('members', 'read'
router.delete('/image/:clubId/:memberId/:imageId', authenticate, authorize('members', 'write'), deleteMemberImage);
router.post('/image/:clubId/:memberId/:imageId/primary', authenticate, authorize('members', 'write'), setPrimaryMemberImage);
router.get('/get/:id/:showAll', authenticate, authorize('members', 'read'), getClubMembers);
router.get('/gallery/:clubId', authenticate, authorize('members', 'read'), generateMemberGallery);
router.post('/set/:id', authenticate, authorize('members', 'write'), setClubMembers);
router.get('/notapproved/:id', authenticate, authorize('members', 'read'), getWaitingApprovals);
router.post('/update-ratings/:id', authenticate, authorize('mytischtennis', 'write'), updateRatingsFromMyTischtennis);

View File

@@ -1069,6 +1069,215 @@ class MemberService {
console.warn('[MemberService] - Unable to remove legacy image after migration:', unlinkError.message || unlinkError);
}
}
async generateMemberGallery(userToken, clubId) {
try {
await checkAccess(userToken, clubId);
const members = await Member.findAll({
where: {
clubId,
active: true
},
include: [
{
model: MemberImage,
as: 'images',
required: false,
attributes: ['id', 'fileName', 'sortOrder', 'updatedAt']
}
],
order: [['lastName', 'ASC'], ['firstName', 'ASC']]
});
const galleryEntries = [];
for (const member of members) {
let images = [];
if (Array.isArray(member.images) && member.images.length > 0) {
images = member.images.map(img => img.toJSON ? img.toJSON() : img);
} else {
const migrated = await this._migrateLegacyImage(member.id);
if (migrated) {
images = [migrated.toJSON ? migrated.toJSON() : migrated];
}
}
if (!images || images.length === 0) {
continue;
}
const latestImage = this._selectLatestImage(images);
if (!latestImage) {
continue;
}
const fileName = latestImage.fileName || `${latestImage.id}.jpg`;
const filePath = path.join('images', 'members', String(member.id), fileName);
if (!fs.existsSync(filePath)) {
continue;
}
galleryEntries.push({
filePath,
fullName: `${member.firstName || ''} ${member.lastName || ''}`.trim() || member.lastName || member.firstName || 'Unbekannt'
});
}
if (galleryEntries.length === 0) {
return {
status: 404,
error: 'Keine aktiven Mitglieder mit Bildern gefunden'
};
}
const tileDimension = 200;
const galleryGap = 20;
const maxColumns = Math.max(1, Math.floor((1920 - galleryGap) / (tileDimension + galleryGap)));
const columns = Math.min(maxColumns, galleryEntries.length);
const rows = Math.ceil(galleryEntries.length / columns);
const canvasWidth = galleryGap + columns * (tileDimension + galleryGap) - galleryGap;
const canvasHeight = galleryGap + rows * (tileDimension + galleryGap) - galleryGap;
const gap = galleryGap;
const backgroundColor = '#101010';
const grid = this._computeGalleryGrid(galleryEntries.length, canvasWidth, canvasHeight, gap);
const composites = [];
let index = 0;
for (const entry of galleryEntries) {
const row = Math.floor(index / grid.columns);
const col = index % grid.columns;
const left = gap + col * (grid.tileWidth + gap);
const top = gap + row * (grid.tileHeight + gap);
const resizedBuffer = await sharp(entry.filePath)
.resize(200, 200, { fit: 'cover' })
.toBuffer();
composites.push({
input: resizedBuffer,
top,
left
});
const textHeight = Math.max(36, Math.round(grid.tileHeight * 0.18));
const nameOverlay = this._buildNameOverlay(grid.tileWidth, textHeight, entry.fullName);
composites.push({
input: Buffer.from(nameOverlay),
top: top + grid.tileHeight - textHeight,
left
});
index += 1;
}
const buffer = await sharp({
create: {
width: canvasWidth,
height: canvasHeight,
channels: 3,
background: backgroundColor
}
})
.composite(composites)
.png()
.toBuffer();
return {
status: 200,
buffer
};
} catch (error) {
console.error('[generateMemberGallery] - Error:', error);
return {
status: error.statusCode || 500,
error: 'Failed to generate member gallery'
};
}
}
_selectLatestImage(images) {
if (!Array.isArray(images) || images.length === 0) {
return null;
}
return images
.slice()
.sort((a, b) => {
const updatedA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
const updatedB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
if (updatedA !== updatedB) {
return updatedB - updatedA;
}
const sortA = Number.isFinite(a.sortOrder) ? a.sortOrder : parseInt(a.sortOrder || '0', 10);
const sortB = Number.isFinite(b.sortOrder) ? b.sortOrder : parseInt(b.sortOrder || '0', 10);
if (sortA !== sortB) {
return sortB - sortA;
}
return (b.id || 0) - (a.id || 0);
})[0];
}
_computeGalleryGrid(count, canvasWidth, canvasHeight, gap) {
let best = null;
const tileDimension = 200;
for (let columns = 1; columns <= count; columns += 1) {
const rows = Math.ceil(count / columns);
const availableWidth = canvasWidth - (columns + 1) * gap;
const availableHeight = canvasHeight - (rows + 1) * gap;
if (availableWidth <= 0 || availableHeight <= 0) {
continue;
}
const tileWidth = Math.min(tileDimension, Math.floor(availableWidth / columns));
const tileHeight = Math.min(tileDimension, Math.floor(availableHeight / rows));
if (tileWidth <= 0 || tileHeight <= 0) {
continue;
}
if (!best) {
best = {
columns,
rows,
tileWidth,
tileHeight,
};
}
}
if (!best) {
best = {
columns: 1,
rows: count,
tileWidth: tileDimension,
tileHeight: tileDimension
};
}
return best;
}
_buildNameOverlay(width, height, name) {
const safeName = this._escapeSvgText(name || '');
const fontSize = Math.max(24, Math.round(height * 0.6));
return `
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="${width}" height="${height}" fill="rgba(0,0,0,0.55)" />
<text x="50%" y="60%" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="${fontSize}" fill="#ff3333" font-weight="600">
${safeName}
</text>
</svg>`;
}
_escapeSvgText(text) {
return String(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\"/g, '&quot;')
.replace(/'/g, '&#39;');
}
}
export default new MemberService();

View File

@@ -1,7 +1,7 @@
<template>
<div class="diary">
<h2>Trainingstagebuch</h2>
<div>
<div class="diary-header-row">
<label>Datum:
<select v-model="date" @change="handleDateChange">
<option value="new">Neu anlegen</option>
@@ -11,6 +11,13 @@
<button v-if="date && date !== 'new' && canDeleteCurrentDate" class="btn-secondary"
@click="deleteCurrentDate">Datum löschen</button>
</label>
<button
class="btn-secondary gallery-trigger"
:disabled="!currentClub || galleryLoading"
@click="openGalleryDialog"
>
{{ galleryLoading ? 'Galerie wird erstellt…' : 'Mitglieder-Galerie' }}
</button>
</div>
<div v-if="showForm && date === 'new'">
<h3>Neues Datum anlegen</h3>
@@ -493,6 +500,25 @@
:initial-drawing-data="null"
@ok="handleDrawingDialogOkForDiary"
/>
<!-- Mitglieder-Galerie Dialog -->
<BaseDialog
v-model="showGalleryDialog"
title="Mitglieder-Galerie"
size="large"
:close-on-overlay="true"
@close="closeGalleryDialog"
>
<div class="gallery-dialog-content">
<div v-if="galleryLoading" class="gallery-loading">Galerie wird erstellt</div>
<div v-else-if="galleryImageUrl" class="gallery-image-wrapper">
<img :src="galleryImageUrl" alt="Mitglieder-Galerie" class="gallery-dialog-image">
</div>
<div v-else class="gallery-error">
{{ galleryError || 'Keine Galerie verfügbar.' }}
</div>
</div>
</BaseDialog>
</div>
<!-- Info Dialog -->
@@ -637,6 +663,10 @@ export default {
accidents: [],
editingActivityId: null, // ID der Aktivität, die gerade bearbeitet wird
// Suche für Inline-Edit
showGalleryDialog: false,
galleryLoading: false,
galleryImageUrl: null,
galleryError: '',
editShowDropdown: false,
editSearchResults: [],
editSearchForId: null,
@@ -749,6 +779,35 @@ export default {
}
this.confirmDialog.isOpen = false;
},
revokeGalleryImage() {
if (this.galleryImageUrl) {
URL.revokeObjectURL(this.galleryImageUrl);
this.galleryImageUrl = null;
}
},
async openGalleryDialog() {
if (!this.currentClub || this.galleryLoading) {
return;
}
this.galleryLoading = true;
this.galleryError = '';
try {
const response = await apiClient.get(`/clubmembers/gallery/${this.currentClub}`, { responseType: 'blob' });
this.revokeGalleryImage();
this.galleryImageUrl = URL.createObjectURL(response.data);
this.showGalleryDialog = true;
} catch (error) {
console.error('Fehler beim Erstellen der Mitglieder-Galerie:', error);
this.galleryError = error?.response?.data?.error || 'Galerie konnte nicht erstellt werden.';
this.showInfo('Fehler', 'Galerie konnte nicht erstellt werden.', this.galleryError, 'error');
} finally {
this.galleryLoading = false;
}
},
closeGalleryDialog() {
this.showGalleryDialog = false;
this.revokeGalleryImage();
},
hasActivityVisual(pa) {
if (!pa) return false;
@@ -2355,6 +2414,7 @@ export default {
if (this.timeChecker) {
clearInterval(this.timeChecker);
}
this.revokeGalleryImage();
}
};
</script>
@@ -2400,6 +2460,44 @@ h3 {
height: 100%;
}
.diary-header-row {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.gallery-trigger {
align-self: flex-end;
}
.gallery-dialog-content {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
max-height: 70vh;
}
.gallery-image-wrapper {
max-width: 100%;
max-height: 100%;
}
.gallery-dialog-image {
max-width: 100%;
max-height: 100%;
border-radius: 8px;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
}
.gallery-loading,
.gallery-error {
font-size: 1rem;
color: var(--text-color, #333);
}
.column:first-child {
flex: 1;
overflow: visible;