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:
@@ -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();
|
||||
Reference in New Issue
Block a user