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

@@ -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();