diff --git a/backend/controllers/memberController.js b/backend/controllers/memberController.js index c236bfc..cdf56c9 100644 --- a/backend/controllers/memberController.js +++ b/backend/controllers/memberController.js @@ -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 }; \ No newline at end of file diff --git a/backend/routes/memberRoutes.js b/backend/routes/memberRoutes.js index 0c25a41..eb2a2cd 100644 --- a/backend/routes/memberRoutes.js +++ b/backend/routes/memberRoutes.js @@ -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); diff --git a/backend/services/memberService.js b/backend/services/memberService.js index 7be41bc..8ca1c9e 100644 --- a/backend/services/memberService.js +++ b/backend/services/memberService.js @@ -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 ` + + + + ${safeName} + +`; + } + + _escapeSvgText(text) { + return String(text || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\"/g, '"') + .replace(/'/g, '''); + } } export default new MemberService(); \ No newline at end of file diff --git a/frontend/src/views/DiaryView.vue b/frontend/src/views/DiaryView.vue index 6ce604b..68feca8 100644 --- a/frontend/src/views/DiaryView.vue +++ b/frontend/src/views/DiaryView.vue @@ -1,7 +1,7 @@