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:
@@ -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
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
|
||||
export default new MemberService();
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user