Implement member image management features in backend and frontend
This commit introduces new functionalities for managing member images, including uploading, deleting, and setting primary images. The memberController and memberService have been updated to handle these operations, while new routes have been added to facilitate image management. The frontend has been enhanced with an ImageViewerDialog component that supports image rotation, deletion, and setting primary images. Additionally, improvements to the member view allow for better image handling and display. These changes enhance the overall user experience and functionality of the member management system.
This commit is contained in:
@@ -43,8 +43,12 @@ const uploadMemberImage = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await MemberService.uploadMemberImage(userToken, clubId, memberId, req.file.buffer);
|
||||
res.status(result.status).json(result.message ? { message: result.message } : { error: result.error });
|
||||
const makePrimary =
|
||||
req.body?.makePrimary === true ||
|
||||
req.body?.makePrimary === 'true' ||
|
||||
req.query?.makePrimary === 'true';
|
||||
const result = await MemberService.uploadMemberImage(userToken, clubId, memberId, req.file.buffer, { makePrimary });
|
||||
res.status(result.status).json(result.response ?? { success: false, error: 'Unknown upload result' });
|
||||
} catch (error) {
|
||||
console.error('[uploadMemberImage] - Error:', error);
|
||||
res.status(500).json({ error: 'Failed to upload image' });
|
||||
@@ -53,9 +57,9 @@ const uploadMemberImage = async (req, res) => {
|
||||
|
||||
const getMemberImage = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId } = req.params;
|
||||
const { clubId, memberId, imageId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await MemberService.getMemberImage(userToken, clubId, memberId);
|
||||
const result = await MemberService.getMemberImage(userToken, clubId, memberId, imageId || null);
|
||||
if (result.status === 200) {
|
||||
res.sendFile(result.imagePath);
|
||||
} else {
|
||||
@@ -81,7 +85,7 @@ const updateRatingsFromMyTischtennis = async (req, res) => {
|
||||
|
||||
const rotateMemberImage = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId } = req.params;
|
||||
const { clubId, memberId, imageId } = req.params;
|
||||
const { direction } = req.body;
|
||||
const { authcode: userToken } = req.headers;
|
||||
|
||||
@@ -92,7 +96,7 @@ const rotateMemberImage = async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const result = await MemberService.rotateMemberImage(userToken, clubId, memberId, direction);
|
||||
const result = await MemberService.rotateMemberImage(userToken, clubId, memberId, imageId, direction);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[rotateMemberImage] - Error:', error);
|
||||
@@ -100,6 +104,30 @@ const rotateMemberImage = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMemberImage = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId, imageId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await MemberService.deleteMemberImage(userToken, clubId, memberId, imageId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[deleteMemberImage] - Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Failed to delete image' });
|
||||
}
|
||||
};
|
||||
|
||||
const setPrimaryMemberImage = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId, imageId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await MemberService.setPrimaryMemberImage(userToken, clubId, memberId, imageId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[setPrimaryMemberImage] - Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Failed to update primary image' });
|
||||
}
|
||||
};
|
||||
|
||||
const quickUpdateTestMembership = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId } = req.params;
|
||||
@@ -168,4 +196,18 @@ const transferMembers = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
export { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage, transferMembers, quickUpdateTestMembership, quickUpdateMemberFormHandedOver, quickDeactivateMember };
|
||||
export {
|
||||
getClubMembers,
|
||||
getWaitingApprovals,
|
||||
setClubMembers,
|
||||
uploadMemberImage,
|
||||
getMemberImage,
|
||||
updateRatingsFromMyTischtennis,
|
||||
rotateMemberImage,
|
||||
transferMembers,
|
||||
quickUpdateTestMembership,
|
||||
quickUpdateMemberFormHandedOver,
|
||||
quickDeactivateMember,
|
||||
deleteMemberImage,
|
||||
setPrimaryMemberImage
|
||||
};
|
||||
@@ -6,6 +6,9 @@ export const authenticate = async (req, res, next) => {
|
||||
if (!token) {
|
||||
token = req.headers['authcode'];
|
||||
}
|
||||
if (!token) {
|
||||
token = req.query?.authcode || req.query?.token || null;
|
||||
}
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Unauthorized: Token fehlt' });
|
||||
}
|
||||
|
||||
17
backend/migrations/20251111_add_member_images.sql
Normal file
17
backend/migrations/20251111_add_member_images.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Create table for storing multiple images per member
|
||||
CREATE TABLE IF NOT EXISTS `member_image` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`member_id` INT NOT NULL,
|
||||
`file_name` VARCHAR(255) NOT NULL,
|
||||
`sort_order` INT NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_member_image_member_id` (`member_id`),
|
||||
CONSTRAINT `fk_member_image_member`
|
||||
FOREIGN KEY (`member_id`)
|
||||
REFERENCES `member` (`id`)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
40
backend/models/MemberImage.js
Normal file
40
backend/models/MemberImage.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const MemberImage = sequelize.define('MemberImage', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
memberId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'member_id',
|
||||
references: {
|
||||
model: 'member',
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE'
|
||||
},
|
||||
fileName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
field: 'file_name'
|
||||
},
|
||||
sortOrder: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'sort_order'
|
||||
}
|
||||
}, {
|
||||
tableName: 'member_image',
|
||||
underscored: true,
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
export default MemberImage;
|
||||
|
||||
@@ -41,6 +41,7 @@ import MyTischtennisFetchLog from './MyTischtennisFetchLog.js';
|
||||
import ApiLog from './ApiLog.js';
|
||||
import MemberTransferConfig from './MemberTransferConfig.js';
|
||||
import MemberContact from './MemberContact.js';
|
||||
import MemberImage from './MemberImage.js';
|
||||
// Official tournaments relations
|
||||
OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' });
|
||||
OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' });
|
||||
@@ -250,6 +251,9 @@ MemberTransferConfig.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
Member.hasMany(MemberContact, { foreignKey: 'memberId', as: 'contacts' });
|
||||
MemberContact.belongsTo(Member, { foreignKey: 'memberId', as: 'member' });
|
||||
|
||||
Member.hasMany(MemberImage, { foreignKey: 'memberId', as: 'images' });
|
||||
MemberImage.belongsTo(Member, { foreignKey: 'memberId', as: 'member' });
|
||||
|
||||
export {
|
||||
User,
|
||||
Log,
|
||||
@@ -293,4 +297,5 @@ export {
|
||||
ApiLog,
|
||||
MemberTransferConfig,
|
||||
MemberContact,
|
||||
MemberImage,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
import { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage, transferMembers, quickUpdateTestMembership, quickUpdateMemberFormHandedOver, quickDeactivateMember } from '../controllers/memberController.js';
|
||||
import {
|
||||
getClubMembers,
|
||||
getWaitingApprovals,
|
||||
setClubMembers,
|
||||
uploadMemberImage,
|
||||
getMemberImage,
|
||||
updateRatingsFromMyTischtennis,
|
||||
rotateMemberImage,
|
||||
transferMembers,
|
||||
quickUpdateTestMembership,
|
||||
quickUpdateMemberFormHandedOver,
|
||||
quickDeactivateMember,
|
||||
deleteMemberImage,
|
||||
setPrimaryMemberImage
|
||||
} from '../controllers/memberController.js';
|
||||
import express from 'express';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { authorize } from '../middleware/authorizationMiddleware.js';
|
||||
@@ -10,12 +24,15 @@ const storage = multer.memoryStorage();
|
||||
const upload = multer({ storage: storage });
|
||||
|
||||
router.post('/image/:clubId/:memberId', authenticate, authorize('members', 'write'), upload.single('image'), uploadMemberImage);
|
||||
router.get('/image/:clubId/:memberId/:imageId', authenticate, authorize('members', 'read'), getMemberImage);
|
||||
router.get('/image/:clubId/:memberId', authenticate, authorize('members', 'read'), getMemberImage);
|
||||
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.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);
|
||||
router.post('/rotate-image/:clubId/:memberId', authenticate, authorize('members', 'write'), rotateMemberImage);
|
||||
router.post('/rotate-image/:clubId/:memberId/:imageId', authenticate, authorize('members', 'write'), rotateMemberImage);
|
||||
router.post('/transfer/:id', authenticate, authorize('members', 'write'), transferMembers);
|
||||
router.post('/quick-update-test-membership/:clubId/:memberId', authenticate, authorize('members', 'write'), quickUpdateTestMembership);
|
||||
router.post('/quick-update-member-form/:clubId/:memberId', authenticate, authorize('members', 'write'), quickUpdateMemberFormHandedOver);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import UserClub from "../models/UserClub.js";
|
||||
import { checkAccess, getUserByToken, hasUserClubAccess } from "../utils/userUtils.js";
|
||||
import Member from "../models/Member.js";
|
||||
import MemberImage from "../models/MemberImage.js";
|
||||
import Participant from "../models/Participant.js";
|
||||
import DiaryDate from "../models/DiaryDates.js";
|
||||
import path from 'path';
|
||||
@@ -31,51 +32,59 @@ class MemberService {
|
||||
where['active'] = true;
|
||||
}
|
||||
const MemberContact = (await import('../models/MemberContact.js')).default;
|
||||
return await Member.findAll({
|
||||
where,
|
||||
include: [{
|
||||
model: MemberContact,
|
||||
as: 'contacts',
|
||||
required: false,
|
||||
attributes: ['id', 'memberId', 'type', 'value', 'isParent', 'parentName', 'isPrimary', 'createdAt', 'updatedAt']
|
||||
}],
|
||||
raw: false // Ensure we get model instances, not plain objects, so getters are called
|
||||
})
|
||||
.then(members => {
|
||||
return members.map(member => {
|
||||
const imagePath = path.join('images', 'members', `${member.id}.jpg`);
|
||||
const hasImage = fs.existsSync(imagePath);
|
||||
const memberJson = member.toJSON();
|
||||
// Ensure contacts are properly serialized - access via model instance to trigger getters
|
||||
if (member.contacts && Array.isArray(member.contacts)) {
|
||||
memberJson.contacts = member.contacts.map(contact => {
|
||||
// Access properties through the model instance to trigger getters
|
||||
return {
|
||||
id: contact.id,
|
||||
memberId: contact.memberId,
|
||||
type: contact.type,
|
||||
value: contact.value, // Getter should decrypt this
|
||||
isParent: contact.isParent,
|
||||
parentName: contact.parentName, // Getter should decrypt this
|
||||
isPrimary: contact.isPrimary,
|
||||
createdAt: contact.createdAt,
|
||||
updatedAt: contact.updatedAt
|
||||
};
|
||||
});
|
||||
try {
|
||||
const members = await Member.findAll({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: MemberContact,
|
||||
as: 'contacts',
|
||||
required: false,
|
||||
attributes: ['id', 'memberId', 'type', 'value', 'isParent', 'parentName', 'isPrimary', 'createdAt', 'updatedAt']
|
||||
},
|
||||
{
|
||||
model: MemberImage,
|
||||
as: 'images',
|
||||
required: false,
|
||||
attributes: ['id', 'memberId', 'fileName', 'sortOrder', 'createdAt', 'updatedAt']
|
||||
}
|
||||
return {
|
||||
...memberJson,
|
||||
hasImage: hasImage
|
||||
};
|
||||
});
|
||||
})
|
||||
.then(membersWithImageStatus => {
|
||||
return membersWithImageStatus;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[getClubMembers] - Error:', error);
|
||||
throw error;
|
||||
],
|
||||
raw: false
|
||||
});
|
||||
|
||||
const results = [];
|
||||
for (const member of members) {
|
||||
const memberJson = member.toJSON();
|
||||
|
||||
if (member.contacts && Array.isArray(member.contacts)) {
|
||||
memberJson.contacts = member.contacts.map(contact => ({
|
||||
id: contact.id,
|
||||
memberId: contact.memberId,
|
||||
type: contact.type,
|
||||
value: contact.value,
|
||||
isParent: contact.isParent,
|
||||
parentName: contact.parentName,
|
||||
isPrimary: contact.isPrimary,
|
||||
createdAt: contact.createdAt,
|
||||
updatedAt: contact.updatedAt
|
||||
}));
|
||||
}
|
||||
|
||||
const imageData = await this._prepareMemberImages(member, { forceReload: true });
|
||||
memberJson.images = imageData.images;
|
||||
memberJson.primaryImageId = imageData.primaryImageId;
|
||||
memberJson.primaryImageUrl = imageData.primaryImageUrl;
|
||||
memberJson.imageUrl = imageData.primaryImageUrl;
|
||||
memberJson.hasImage = imageData.hasImage;
|
||||
|
||||
results.push(memberJson);
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('[getClubMembers] - Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, postalCode, birthdate, phone, email, active = true, testMembership = false,
|
||||
@@ -182,38 +191,118 @@ class MemberService {
|
||||
}
|
||||
}
|
||||
|
||||
async uploadMemberImage(userToken, clubId, memberId, imageBuffer) {
|
||||
async uploadMemberImage(userToken, clubId, memberId, imageBuffer, options = {}) {
|
||||
const { makePrimary = false } = options;
|
||||
let transaction = null;
|
||||
try {
|
||||
devLog('------>', userToken, clubId, memberId, imageBuffer);
|
||||
await checkAccess(userToken, clubId);
|
||||
const member = await Member.findOne({ where: { id: memberId, clubId: clubId } });
|
||||
if (!member) {
|
||||
return { status: 404, error: 'Member not found in this club' };
|
||||
return { status: 404, response: { success: false, error: 'Member not found in this club' } };
|
||||
}
|
||||
const imagePath = path.join('images', 'members', `${memberId}.jpg`);
|
||||
await sharp(imageBuffer)
|
||||
.resize(600, 600)
|
||||
.jpeg({ quality: 80 })
|
||||
.toFile(imagePath);
|
||||
|
||||
return { status: 200, message: 'Image uploaded successfully' };
|
||||
await this._migrateLegacyImage(memberId);
|
||||
|
||||
transaction = await MemberImage.sequelize.transaction();
|
||||
|
||||
const maxSortOrder = await MemberImage.max('sortOrder', {
|
||||
where: { memberId },
|
||||
transaction
|
||||
});
|
||||
const sortOrder = Number.isFinite(maxSortOrder) ? (maxSortOrder + 1) : 1;
|
||||
|
||||
const imageRecord = await MemberImage.create({
|
||||
memberId,
|
||||
fileName: '',
|
||||
sortOrder
|
||||
}, { transaction });
|
||||
|
||||
const directoryPath = await this._ensureImageDirectory(memberId);
|
||||
const fileName = `${imageRecord.id}.jpg`;
|
||||
const targetPath = path.join(directoryPath, fileName);
|
||||
|
||||
await sharp(imageBuffer)
|
||||
.resize(600, 600, { fit: 'inside' })
|
||||
.jpeg({ quality: 80 })
|
||||
.toFile(targetPath);
|
||||
|
||||
imageRecord.fileName = fileName;
|
||||
await imageRecord.save({ transaction });
|
||||
|
||||
if (makePrimary) {
|
||||
await this._setPrimaryImage(memberId, imageRecord.id, { transaction });
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
transaction = null;
|
||||
|
||||
const imageData = await this._prepareMemberImages(member, { forceReload: true });
|
||||
const createdImage = imageData.images.find(img => img.id === imageRecord.id) || null;
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
response: {
|
||||
success: true,
|
||||
image: createdImage,
|
||||
images: imageData.images,
|
||||
primaryImageId: imageData.primaryImageId,
|
||||
primaryImageUrl: imageData.primaryImageUrl
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
if (transaction) {
|
||||
try {
|
||||
await transaction.rollback();
|
||||
} catch (rollbackError) {
|
||||
console.error('[uploadMemberImage] - Rollback failed:', rollbackError);
|
||||
}
|
||||
}
|
||||
console.error('[uploadMemberImage] - Error:', error);
|
||||
return { status: 500, error: 'Failed to upload image' };
|
||||
return { status: 500, response: { success: false, error: 'Failed to upload image' } };
|
||||
}
|
||||
}
|
||||
|
||||
async getMemberImage(userToken, clubId, memberId) {
|
||||
async getMemberImage(userToken, clubId, memberId, imageId = null) {
|
||||
try {
|
||||
await checkAccess(userToken, clubId);
|
||||
const member = await Member.findOne({ where: { id: memberId, clubId: clubId } });
|
||||
if (!member) {
|
||||
return { status: 404, error: 'Member not found in this club' };
|
||||
}
|
||||
const imagePath = path.join('images', 'members', `${memberId}.jpg`);
|
||||
const migratedImage = await this._migrateLegacyImage(memberId);
|
||||
if (migratedImage && (!imageId || imageId === migratedImage.id)) {
|
||||
imageId = migratedImage.id;
|
||||
}
|
||||
|
||||
let imageRecord = null;
|
||||
if (imageId) {
|
||||
imageRecord = await MemberImage.findOne({
|
||||
where: { id: imageId, memberId }
|
||||
});
|
||||
}
|
||||
|
||||
if (!imageRecord) {
|
||||
imageRecord = await MemberImage.findOne({
|
||||
where: { memberId },
|
||||
order: [['sortOrder', 'ASC'], ['id', 'ASC']]
|
||||
});
|
||||
}
|
||||
|
||||
if (!imageRecord) {
|
||||
const legacyPath = this._findLegacyImagePath(memberId);
|
||||
if (legacyPath) {
|
||||
return { status: 200, imagePath: path.resolve(legacyPath) };
|
||||
}
|
||||
return { status: 404, error: 'Image not found' };
|
||||
}
|
||||
|
||||
const directoryPath = path.join('images', 'members', String(memberId));
|
||||
const imagePath = path.join(directoryPath, imageRecord.fileName);
|
||||
|
||||
if (!fs.existsSync(imagePath)) {
|
||||
return { status: 404, error: 'Image not found' };
|
||||
}
|
||||
|
||||
return { status: 200, imagePath: path.resolve(imagePath) };
|
||||
} catch (error) {
|
||||
console.error('[getMemberImage] - Error:', error);
|
||||
@@ -527,7 +616,7 @@ class MemberService {
|
||||
return this._updateRatingsInternal(userId, clubId);
|
||||
}
|
||||
|
||||
async rotateMemberImage(userToken, clubId, memberId, direction) {
|
||||
async rotateMemberImage(userToken, clubId, memberId, imageId, direction) {
|
||||
try {
|
||||
await checkAccess(userToken, clubId);
|
||||
const member = await Member.findOne({ where: { id: memberId, clubId: clubId } });
|
||||
@@ -535,35 +624,49 @@ class MemberService {
|
||||
if (!member) {
|
||||
return { status: 404, response: { success: false, error: 'Member not found in this club' } };
|
||||
}
|
||||
|
||||
const imagePath = path.join('images', 'members', `${memberId}.jpg`);
|
||||
if (!fs.existsSync(imagePath)) {
|
||||
|
||||
await this._migrateLegacyImage(memberId);
|
||||
|
||||
const imageRecord = await MemberImage.findOne({
|
||||
where: { id: imageId, memberId }
|
||||
});
|
||||
|
||||
if (!imageRecord) {
|
||||
return { status: 404, response: { success: false, error: 'Image not found' } };
|
||||
}
|
||||
|
||||
// Read the image
|
||||
|
||||
const imagePath = path.join('images', 'members', String(memberId), imageRecord.fileName);
|
||||
if (!fs.existsSync(imagePath)) {
|
||||
return { status: 404, response: { success: false, error: 'Image file not found on disk' } };
|
||||
}
|
||||
|
||||
const imageBuffer = await fs.promises.readFile(imagePath);
|
||||
|
||||
// Calculate rotation angle (-90 for left, +90 for right)
|
||||
const rotationAngle = direction === 'left' ? -90 : 90;
|
||||
|
||||
// Rotate the image
|
||||
|
||||
const rotatedBuffer = await sharp(imageBuffer)
|
||||
.rotate(rotationAngle)
|
||||
.jpeg({ quality: 80 })
|
||||
.toBuffer();
|
||||
|
||||
// Save the rotated image
|
||||
|
||||
await fs.promises.writeFile(imagePath, rotatedBuffer);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
response: {
|
||||
success: true,
|
||||
|
||||
await imageRecord.update({ updatedAt: new Date() });
|
||||
|
||||
const imageData = await this._prepareMemberImages(member, { forceReload: true });
|
||||
const updatedImage = imageData.images.find(img => img.id === imageRecord.id) || null;
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
response: {
|
||||
success: true,
|
||||
message: `Bild um ${rotationAngle}° gedreht`,
|
||||
direction: direction,
|
||||
rotation: rotationAngle
|
||||
}
|
||||
direction,
|
||||
rotation: rotationAngle,
|
||||
image: updatedImage,
|
||||
images: imageData.images,
|
||||
primaryImageId: imageData.primaryImageId,
|
||||
primaryImageUrl: imageData.primaryImageUrl
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[rotateMemberImage] - Error:', error);
|
||||
@@ -577,6 +680,121 @@ class MemberService {
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMemberImage(userToken, clubId, memberId, imageId) {
|
||||
let transaction = null;
|
||||
try {
|
||||
await checkAccess(userToken, clubId);
|
||||
const member = await Member.findOne({ where: { id: memberId, clubId } });
|
||||
if (!member) {
|
||||
return { status: 404, response: { success: false, error: 'Member not found in this club' } };
|
||||
}
|
||||
|
||||
await this._migrateLegacyImage(memberId);
|
||||
|
||||
transaction = await MemberImage.sequelize.transaction();
|
||||
const imageRecord = await MemberImage.findOne({
|
||||
where: { id: imageId, memberId },
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE
|
||||
});
|
||||
|
||||
if (!imageRecord) {
|
||||
await transaction.rollback();
|
||||
transaction = null;
|
||||
return { status: 404, response: { success: false, error: 'Image not found' } };
|
||||
}
|
||||
|
||||
const imagePath = path.join('images', 'members', String(memberId), imageRecord.fileName);
|
||||
|
||||
await imageRecord.destroy({ transaction });
|
||||
await this._resequenceMemberImages(memberId, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
transaction = null;
|
||||
|
||||
if (fs.existsSync(imagePath)) {
|
||||
await fs.promises.unlink(imagePath).catch(err => {
|
||||
console.warn('[deleteMemberImage] - Failed to remove image file:', err);
|
||||
});
|
||||
}
|
||||
|
||||
const imageData = await this._prepareMemberImages(member, { forceReload: true });
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
response: {
|
||||
success: true,
|
||||
images: imageData.images,
|
||||
primaryImageId: imageData.primaryImageId,
|
||||
primaryImageUrl: imageData.primaryImageUrl
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
if (transaction) {
|
||||
try {
|
||||
await transaction.rollback();
|
||||
} catch (rollbackError) {
|
||||
console.error('[deleteMemberImage] - Rollback failed:', rollbackError);
|
||||
}
|
||||
}
|
||||
console.error('[deleteMemberImage] - Error:', error);
|
||||
return { status: 500, response: { success: false, error: 'Failed to delete image' } };
|
||||
}
|
||||
}
|
||||
|
||||
async setPrimaryMemberImage(userToken, clubId, memberId, imageId) {
|
||||
let transaction = null;
|
||||
try {
|
||||
await checkAccess(userToken, clubId);
|
||||
const member = await Member.findOne({ where: { id: memberId, clubId } });
|
||||
if (!member) {
|
||||
return { status: 404, response: { success: false, error: 'Member not found in this club' } };
|
||||
}
|
||||
|
||||
await this._migrateLegacyImage(memberId);
|
||||
|
||||
transaction = await MemberImage.sequelize.transaction();
|
||||
const imageRecord = await MemberImage.findOne({
|
||||
where: { id: imageId, memberId },
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE
|
||||
});
|
||||
|
||||
if (!imageRecord) {
|
||||
await transaction.rollback();
|
||||
transaction = null;
|
||||
return { status: 404, response: { success: false, error: 'Image not found' } };
|
||||
}
|
||||
|
||||
await this._setPrimaryImage(memberId, imageId, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
transaction = null;
|
||||
|
||||
const imageData = await this._prepareMemberImages(member, { forceReload: true });
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
response: {
|
||||
success: true,
|
||||
images: imageData.images,
|
||||
primaryImageId: imageData.primaryImageId,
|
||||
primaryImageUrl: imageData.primaryImageUrl
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
if (transaction) {
|
||||
try {
|
||||
await transaction.rollback();
|
||||
} catch (rollbackError) {
|
||||
console.error('[setPrimaryMemberImage] - Rollback failed:', rollbackError);
|
||||
}
|
||||
}
|
||||
console.error('[setPrimaryMemberImage] - Error:', error);
|
||||
return { status: 500, response: { success: false, error: 'Failed to set primary image' } };
|
||||
}
|
||||
}
|
||||
|
||||
async quickUpdateTestMembership(userToken, clubId, memberId) {
|
||||
try {
|
||||
await checkAccess(userToken, clubId);
|
||||
@@ -651,6 +869,206 @@ class MemberService {
|
||||
return { status: 500, response: { error: 'Failed to deactivate member' } };
|
||||
}
|
||||
}
|
||||
|
||||
async _ensureImageDirectory(memberId) {
|
||||
const directoryPath = path.join('images', 'members', String(memberId));
|
||||
await fs.promises.mkdir(directoryPath, { recursive: true });
|
||||
return directoryPath;
|
||||
}
|
||||
|
||||
async _fetchMemberImages(memberId, transaction = null) {
|
||||
return MemberImage.findAll({
|
||||
where: { memberId },
|
||||
order: [['sortOrder', 'ASC'], ['id', 'ASC']],
|
||||
transaction
|
||||
});
|
||||
}
|
||||
|
||||
async _prepareMemberImages(member, options = {}) {
|
||||
const { forceReload = false } = options;
|
||||
let images = [];
|
||||
|
||||
if (!forceReload && Array.isArray(member.images) && member.images.length > 0) {
|
||||
images = member.images;
|
||||
} else {
|
||||
images = await this._fetchMemberImages(member.id);
|
||||
}
|
||||
|
||||
if (!images || images.length === 0) {
|
||||
const migrated = await this._migrateLegacyImage(member.id);
|
||||
if (migrated) {
|
||||
images = await this._fetchMemberImages(member.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!images || images.length === 0) {
|
||||
return {
|
||||
images: [],
|
||||
primaryImageId: null,
|
||||
primaryImageUrl: null,
|
||||
hasImage: false
|
||||
};
|
||||
}
|
||||
|
||||
const mappedImages = images
|
||||
.map(image => this._mapMemberImageRecord(member, image))
|
||||
.sort((a, b) => {
|
||||
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 sortA - sortB;
|
||||
}
|
||||
return (a.id || 0) - (b.id || 0);
|
||||
});
|
||||
|
||||
const primary = mappedImages[0] || null;
|
||||
|
||||
mappedImages.forEach((image, index) => {
|
||||
image.isPrimary = index === 0;
|
||||
});
|
||||
|
||||
return {
|
||||
images: mappedImages,
|
||||
primaryImageId: primary ? primary.id : null,
|
||||
primaryImageUrl: primary ? primary.url : null,
|
||||
hasImage: mappedImages.length > 0
|
||||
};
|
||||
}
|
||||
|
||||
_mapMemberImageRecord(member, image) {
|
||||
const data = image && typeof image.toJSON === 'function' ? image.toJSON() : image;
|
||||
const updatedAt = data.updatedAt ? new Date(data.updatedAt) : null;
|
||||
const cacheParam = updatedAt && !Number.isNaN(updatedAt.getTime()) ? updatedAt.getTime() : Date.now();
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
memberId: member.id,
|
||||
fileName: data.fileName,
|
||||
sortOrder: data.sortOrder,
|
||||
createdAt: data.createdAt,
|
||||
updatedAt: data.updatedAt,
|
||||
url: `/api/clubmembers/image/${member.clubId}/${member.id}/${data.id}?t=${cacheParam}`
|
||||
};
|
||||
}
|
||||
|
||||
async _setPrimaryImage(memberId, imageId, options = {}) {
|
||||
const { transaction = null } = options;
|
||||
const images = await this._fetchMemberImages(memberId, transaction);
|
||||
if (!images || images.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nextOrder = 2;
|
||||
for (const image of images) {
|
||||
if (image.id === imageId) {
|
||||
image.sortOrder = 1;
|
||||
} else {
|
||||
image.sortOrder = nextOrder;
|
||||
nextOrder += 1;
|
||||
}
|
||||
await image.save({ transaction });
|
||||
}
|
||||
}
|
||||
|
||||
async _resequenceMemberImages(memberId, options = {}) {
|
||||
const { transaction = null } = options;
|
||||
const images = await this._fetchMemberImages(memberId, transaction);
|
||||
let order = 1;
|
||||
for (const image of images) {
|
||||
if (image.sortOrder !== order) {
|
||||
image.sortOrder = order;
|
||||
await image.save({ transaction });
|
||||
}
|
||||
order += 1;
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
async _migrateLegacyImage(memberId) {
|
||||
const legacyPath = this._findLegacyImagePath(memberId);
|
||||
if (!legacyPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = await MemberImage.findOne({ where: { memberId } });
|
||||
const directoryPath = await this._ensureImageDirectory(memberId);
|
||||
|
||||
if (existing) {
|
||||
const fileName = existing.fileName || `${existing.id}.jpg`;
|
||||
const targetPath = path.join(directoryPath, fileName);
|
||||
await this._processLegacyImage(legacyPath, targetPath);
|
||||
if (!existing.fileName || existing.fileName !== fileName) {
|
||||
existing.fileName = fileName;
|
||||
await existing.save();
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
let transaction = null;
|
||||
try {
|
||||
transaction = await MemberImage.sequelize.transaction();
|
||||
const imageRecord = await MemberImage.create({
|
||||
memberId,
|
||||
fileName: '',
|
||||
sortOrder: 1
|
||||
}, { transaction });
|
||||
|
||||
const fileName = `${imageRecord.id}.jpg`;
|
||||
const targetPath = path.join(directoryPath, fileName);
|
||||
await this._processLegacyImage(legacyPath, targetPath);
|
||||
|
||||
imageRecord.fileName = fileName;
|
||||
await imageRecord.save({ transaction });
|
||||
await transaction.commit();
|
||||
transaction = null;
|
||||
return imageRecord;
|
||||
} catch (error) {
|
||||
if (transaction) {
|
||||
try {
|
||||
await transaction.rollback();
|
||||
} catch (rollbackError) {
|
||||
console.error('[MemberService] - Legacy migration rollback failed:', rollbackError);
|
||||
}
|
||||
}
|
||||
console.error('[MemberService] - Legacy image migration failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_findLegacyImagePath(memberId) {
|
||||
const directory = path.join('images', 'members');
|
||||
const extensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
for (const ext of extensions) {
|
||||
const lowerPath = path.join(directory, `${memberId}.${ext}`);
|
||||
if (fs.existsSync(lowerPath)) {
|
||||
return lowerPath;
|
||||
}
|
||||
const upperPath = path.join(directory, `${memberId}.${ext.toUpperCase()}`);
|
||||
if (fs.existsSync(upperPath)) {
|
||||
return upperPath;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async _processLegacyImage(sourcePath, targetPath) {
|
||||
try {
|
||||
await sharp(sourcePath)
|
||||
.resize(600, 600, { fit: 'inside' })
|
||||
.jpeg({ quality: 80 })
|
||||
.toFile(targetPath);
|
||||
} catch (error) {
|
||||
console.error('[MemberService] - Failed to process legacy image, falling back to copy:', error);
|
||||
await fs.promises.copyFile(sourcePath, targetPath);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.unlink(sourcePath);
|
||||
} catch (unlinkError) {
|
||||
// Datei konnte nicht gelöscht werden – loggen, aber nicht blockieren
|
||||
console.warn('[MemberService] - Unable to remove legacy image after migration:', unlinkError.message || unlinkError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new MemberService();
|
||||
@@ -1,8 +1,10 @@
|
||||
import axios from 'axios';
|
||||
import store from './store';
|
||||
|
||||
export const backendBaseUrl = import.meta.env.VITE_BACKEND || 'http://localhost:3005';
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: `${import.meta.env.VITE_BACKEND || 'http://localhost:3005'}/api`,
|
||||
baseURL: `${backendBaseUrl}/api`,
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use(config => {
|
||||
|
||||
@@ -9,56 +9,103 @@
|
||||
:close-on-overlay="true"
|
||||
@close="handleClose"
|
||||
>
|
||||
<!-- Image Content -->
|
||||
<div class="image-viewer-content">
|
||||
<div class="image-container">
|
||||
<img
|
||||
v-if="imageUrl"
|
||||
:src="imageUrl"
|
||||
:alt="title"
|
||||
class="viewer-image"
|
||||
:style="imageStyle"
|
||||
/>
|
||||
<div v-else class="no-image">
|
||||
Kein Bild verfügbar
|
||||
<div class="image-main" :class="{ 'has-images': hasImages }">
|
||||
<button
|
||||
v-if="hasImages && images.length > 1"
|
||||
class="nav-button nav-button--prev"
|
||||
@click="showPreviousImage"
|
||||
title="Vorheriges Bild"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
|
||||
<div class="image-container">
|
||||
<img
|
||||
v-if="currentImageUrl"
|
||||
:src="currentImageUrl"
|
||||
:alt="title"
|
||||
class="viewer-image"
|
||||
:style="imageStyle"
|
||||
/>
|
||||
<div v-else class="no-image">
|
||||
Kein Bild verfügbar
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="hasImages && images.length > 1"
|
||||
class="nav-button nav-button--next"
|
||||
@click="showNextImage"
|
||||
title="Nächstes Bild"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Bild-Aktionen -->
|
||||
<div v-if="showActions && imageUrl" class="image-actions">
|
||||
<button
|
||||
v-if="allowRotate"
|
||||
@click="rotateLeft"
|
||||
|
||||
<div v-if="showActions && hasImages" class="image-actions">
|
||||
<button
|
||||
v-if="allowRotate && currentImageId"
|
||||
@click="rotate('left')"
|
||||
class="action-btn"
|
||||
title="90° links drehen"
|
||||
>
|
||||
↺ Links drehen
|
||||
</button>
|
||||
<button
|
||||
v-if="allowRotate"
|
||||
@click="rotateRight"
|
||||
<button
|
||||
v-if="allowRotate && currentImageId"
|
||||
@click="rotate('right')"
|
||||
class="action-btn"
|
||||
title="90° rechts drehen"
|
||||
>
|
||||
↻ Rechts drehen
|
||||
</button>
|
||||
<button
|
||||
v-if="allowZoom"
|
||||
@click="resetZoom"
|
||||
<button
|
||||
v-if="showSetPrimary && currentImage && !currentImage.isPrimary"
|
||||
@click="setPrimary"
|
||||
class="action-btn"
|
||||
title="Zoom zurücksetzen"
|
||||
title="Als Hauptbild festlegen"
|
||||
>
|
||||
🔍 Zoom zurücksetzen
|
||||
⭐ Als Hauptbild setzen
|
||||
</button>
|
||||
<button
|
||||
v-if="showDelete && currentImageId"
|
||||
@click="deleteImage"
|
||||
class="action-btn action-btn--danger"
|
||||
title="Bild löschen"
|
||||
>
|
||||
🗑️ Löschen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Zusätzliche Inhalte -->
|
||||
|
||||
<div v-if="allowUpload" class="upload-section">
|
||||
<label class="upload-label">
|
||||
📤 Bilder hochladen
|
||||
<input type="file" multiple accept="image/*" @change="handleFileSelect" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="hasImages" class="thumbnail-strip">
|
||||
<div
|
||||
v-for="image in images"
|
||||
:key="image.id"
|
||||
class="thumbnail"
|
||||
:class="{
|
||||
'thumbnail--active': image.id === currentImageId,
|
||||
'thumbnail--primary': image.isPrimary
|
||||
}"
|
||||
@click="selectImage(image.id)"
|
||||
>
|
||||
<img :src="image.objectUrl || image.url" :alt="`Bild #${image.id}`" />
|
||||
<span v-if="image.isPrimary" class="thumbnail-badge">Primär</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.default" class="extra-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<template #footer>
|
||||
<slot name="footer">
|
||||
<button @click="handleClose" class="btn-secondary">
|
||||
@@ -86,9 +133,13 @@ export default {
|
||||
type: String,
|
||||
default: 'Bild'
|
||||
},
|
||||
imageUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
images: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
activeImageId: {
|
||||
type: [Number, String],
|
||||
default: null
|
||||
},
|
||||
memberId: {
|
||||
type: [Number, String],
|
||||
@@ -105,57 +156,149 @@ export default {
|
||||
allowZoom: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showDelete: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showSetPrimary: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
allowUpload: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
imageUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'close', 'rotate'],
|
||||
emits: [
|
||||
'update:modelValue',
|
||||
'update:activeImageId',
|
||||
'close',
|
||||
'rotate',
|
||||
'delete-image',
|
||||
'set-primary',
|
||||
'upload-images'
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
scale: 1
|
||||
scale: 1,
|
||||
currentImageId: this.activeImageId
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasImages() {
|
||||
return Array.isArray(this.images) && this.images.length > 0;
|
||||
},
|
||||
currentImage() {
|
||||
if (!this.hasImages) {
|
||||
return null;
|
||||
}
|
||||
const match = this.images.find(img => img.id === this.currentImageId);
|
||||
return match || this.images[0] || null;
|
||||
},
|
||||
currentImageUrl() {
|
||||
if (this.currentImage) {
|
||||
return this.currentImage.objectUrl || this.currentImage.url || '';
|
||||
}
|
||||
return this.imageUrl || '';
|
||||
},
|
||||
imageStyle() {
|
||||
return {
|
||||
transform: `scale(${this.scale})`
|
||||
};
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
activeImageId(newVal) {
|
||||
this.currentImageId = newVal;
|
||||
},
|
||||
images: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
if (!this.hasImages) {
|
||||
this.currentImageId = null;
|
||||
return;
|
||||
}
|
||||
if (!this.currentImageId || !this.images.some(img => img.id === this.currentImageId)) {
|
||||
this.currentImageId = this.images[0].id;
|
||||
this.emitActiveChange();
|
||||
}
|
||||
}
|
||||
},
|
||||
modelValue(newVal) {
|
||||
if (!newVal) {
|
||||
this.scale = 1;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleClose() {
|
||||
this.scale = 1;
|
||||
this.$emit('update:modelValue', false);
|
||||
this.$emit('close');
|
||||
},
|
||||
|
||||
rotateLeft() {
|
||||
// Emit rotate event - das Bild wird auf dem Server gedreht
|
||||
// und dann neu geladen, daher keine lokale Rotation
|
||||
selectImage(imageId) {
|
||||
if (!imageId || imageId === this.currentImageId) return;
|
||||
this.currentImageId = imageId;
|
||||
this.emitActiveChange();
|
||||
},
|
||||
emitActiveChange() {
|
||||
this.$emit('update:activeImageId', this.currentImageId);
|
||||
},
|
||||
showPreviousImage() {
|
||||
if (!this.hasImages || this.images.length <= 1) return;
|
||||
const currentIndex = this.images.findIndex(img => img.id === this.currentImageId);
|
||||
const prevIndex = currentIndex > 0 ? currentIndex - 1 : this.images.length - 1;
|
||||
this.currentImageId = this.images[prevIndex].id;
|
||||
this.emitActiveChange();
|
||||
},
|
||||
showNextImage() {
|
||||
if (!this.hasImages || this.images.length <= 1) return;
|
||||
const currentIndex = this.images.findIndex(img => img.id === this.currentImageId);
|
||||
const nextIndex = currentIndex === -1 || currentIndex === this.images.length - 1 ? 0 : currentIndex + 1;
|
||||
this.currentImageId = this.images[nextIndex].id;
|
||||
this.emitActiveChange();
|
||||
},
|
||||
rotate(direction) {
|
||||
if (!this.allowRotate || !this.currentImageId) return;
|
||||
this.$emit('rotate', {
|
||||
direction: 'left',
|
||||
memberId: this.memberId
|
||||
direction,
|
||||
memberId: this.memberId,
|
||||
imageId: this.currentImageId
|
||||
});
|
||||
},
|
||||
|
||||
rotateRight() {
|
||||
// Emit rotate event - das Bild wird auf dem Server gedreht
|
||||
// und dann neu geladen, daher keine lokale Rotation
|
||||
this.$emit('rotate', {
|
||||
direction: 'right',
|
||||
memberId: this.memberId
|
||||
deleteImage() {
|
||||
if (!this.showDelete || !this.currentImageId) return;
|
||||
this.$emit('delete-image', {
|
||||
memberId: this.memberId,
|
||||
imageId: this.currentImageId
|
||||
});
|
||||
},
|
||||
|
||||
setPrimary() {
|
||||
if (!this.showSetPrimary || !this.currentImageId) return;
|
||||
this.$emit('set-primary', {
|
||||
memberId: this.memberId,
|
||||
imageId: this.currentImageId
|
||||
});
|
||||
},
|
||||
handleFileSelect(event) {
|
||||
const files = event?.target?.files;
|
||||
if (!this.allowUpload || !files || files.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.$emit('upload-images', {
|
||||
memberId: this.memberId,
|
||||
files
|
||||
});
|
||||
event.target.value = '';
|
||||
},
|
||||
resetZoom() {
|
||||
this.scale = 1;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
modelValue(newVal) {
|
||||
if (!newVal) {
|
||||
// Dialog geschlossen - Reset
|
||||
this.scale = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -164,19 +307,30 @@ export default {
|
||||
.image-viewer-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.image-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.image-main.has-images {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
min-height: 220px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.viewer-image {
|
||||
@@ -193,20 +347,40 @@ export default {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: #fff;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
padding: 0.5rem 1.25rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
color: var(--text-color);
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
@@ -217,11 +391,92 @@ export default {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.action-btn--danger {
|
||||
border-color: #dc3545;
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.action-btn--danger:hover {
|
||||
background: #dc354514;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.upload-label {
|
||||
position: relative;
|
||||
padding: 0.6rem 1.4rem;
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text-color);
|
||||
font-size: 0.95rem;
|
||||
transition: border 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.upload-label:hover {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
.upload-label input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.thumbnail-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
border: 2px solid transparent;
|
||||
transition: transform 0.2s ease, border 0.2s ease;
|
||||
}
|
||||
|
||||
.thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.thumbnail--active {
|
||||
border-color: var(--primary-color);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.thumbnail--primary {
|
||||
box-shadow: 0 0 0 2px rgba(40, 167, 69, 0.5);
|
||||
}
|
||||
|
||||
.thumbnail-badge {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
background: rgba(40, 167, 69, 0.85);
|
||||
color: #fff;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.65rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.extra-content {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@@ -240,19 +495,17 @@ export default {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.viewer-image {
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
|
||||
.image-main {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
|
||||
.nav-button {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -162,10 +162,16 @@
|
||||
<template v-for="member in filteredMembers" :key="member.id">
|
||||
<tr v-if="member.active || showInactiveMembers" class="member-row" :class="{ 'row-inactive': !member.active, 'row-test': member.testMembership && !member.memberFormHandedOver, 'row-test-form': member.testMembership && member.memberFormHandedOver }" @click="editMember(member)">
|
||||
<td>
|
||||
<img v-if="member.imageUrl" :src="member.imageUrl" alt="Mitgliedsbild"
|
||||
style="max-width: 50px; max-height: 50px;"
|
||||
@click.stop="openImageModal(member.imageUrl, member.id)">
|
||||
<span>{{ member.picsInInternetAllowed ? '✓' : '' }}</span>
|
||||
<div @click.stop="openImageModal(member)">
|
||||
<img
|
||||
v-if="member.latestImageUrl"
|
||||
:src="member.latestImageUrl"
|
||||
alt="Mitgliedsbild"
|
||||
class="member-image-thumb-small"
|
||||
>
|
||||
<div v-else class="member-image-thumb__placeholder">+</div>
|
||||
</div>
|
||||
<span class="pics-allowed-indicator">{{ member.picsInInternetAllowed ? '✓' : '' }}</span>
|
||||
</td>
|
||||
<td>{{ member.testMembership ? '*' : '' }}</td>
|
||||
<td>
|
||||
@@ -221,12 +227,21 @@
|
||||
<!-- Image Viewer Dialog -->
|
||||
<ImageViewerDialog
|
||||
v-model="showImageModal"
|
||||
title="Mitgliedsbild"
|
||||
:image-url="selectedImageUrl"
|
||||
title="Mitgliedsbilder"
|
||||
:images="selectedMemberImages"
|
||||
:active-image-id="selectedImageId"
|
||||
:member-id="selectedMemberId"
|
||||
:show-actions="true"
|
||||
:allow-rotate="true"
|
||||
:show-delete="true"
|
||||
:show-set-primary="true"
|
||||
:allow-upload="true"
|
||||
@update:activeImageId="selectedImageId = $event"
|
||||
@rotate="handleRotate"
|
||||
@delete-image="handleDeleteImage"
|
||||
@set-primary="handleSetPrimaryImage"
|
||||
@upload-images="handleUploadImages"
|
||||
@close="onGalleryClosed"
|
||||
/>
|
||||
|
||||
<!-- Notes Modal -->
|
||||
@@ -305,7 +320,7 @@ export default {
|
||||
MemberTransferDialog
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['isAuthenticated', 'currentClub']),
|
||||
...mapGetters(['isAuthenticated', 'currentClub', 'token']),
|
||||
|
||||
activeMembersCount() {
|
||||
return this.members.filter(member => member.active && !member.testMembership).length;
|
||||
@@ -389,9 +404,11 @@ export default {
|
||||
notes: [],
|
||||
newNoteContent: '',
|
||||
showNotesModal: false,
|
||||
showImageModal: false,
|
||||
selectedImageUrl: null,
|
||||
showImageModal: false,
|
||||
selectedMemberId: null,
|
||||
selectedMemberImages: [],
|
||||
selectedImageId: null,
|
||||
selectedMemberForImages: null,
|
||||
testMembership: false,
|
||||
showInactiveMembers: false,
|
||||
newPicsInInternetAllowed: false,
|
||||
@@ -440,7 +457,9 @@ export default {
|
||||
},
|
||||
async loadMembers() {
|
||||
const response = await apiClient.get(`/clubmembers/get/${this.currentClub}/true`);
|
||||
this.members = response.data.sort((a, b) => {
|
||||
this.members = response.data
|
||||
.map(member => this.applyBackendBaseUrlToMember(member))
|
||||
.sort((a, b) => {
|
||||
const lastNameA = a.lastName ? a.lastName.toLowerCase() : '';
|
||||
const lastNameB = b.lastName ? b.lastName.toLowerCase() : '';
|
||||
if (lastNameA && !lastNameB) return -1;
|
||||
@@ -453,12 +472,11 @@ export default {
|
||||
const firstNameB = b.firstName ? b.firstName.toLowerCase() : '';
|
||||
return firstNameA.localeCompare(firstNameB, 'de-DE');
|
||||
});
|
||||
this.members.forEach(member => {
|
||||
this.loadMemberImage(member);
|
||||
});
|
||||
|
||||
// Lade Trainingsteilnahmen für alle Testmitglieder auf einmal über /training-stats
|
||||
await this.loadTrainingParticipations();
|
||||
await Promise.allSettled(this.members.map(member => this.prefetchMemberPrimaryImage(member)));
|
||||
await Promise.allSettled(this.members.map(member => this.prefetchMemberLatestImage(member)));
|
||||
},
|
||||
|
||||
async loadTrainingParticipations() {
|
||||
@@ -957,80 +975,237 @@ export default {
|
||||
this.selectedMemberForActivities = member;
|
||||
this.showActivitiesModal = true;
|
||||
},
|
||||
openImageModal(imageUrl, memberId) {
|
||||
this.selectedImageUrl = imageUrl;
|
||||
this.selectedMemberId = memberId;
|
||||
async openImageModal(member, imageId = null) {
|
||||
if (!member) return;
|
||||
this.selectedMemberForImages = member;
|
||||
this.selectedMemberId = member.id;
|
||||
const images = Array.isArray(member.images) ? [...member.images] : [];
|
||||
await Promise.allSettled(images.map(image => this.ensureImageObjectUrl(image)));
|
||||
this.selectedMemberImages = images;
|
||||
this.selectedImageId = imageId || member.primaryImageId || (this.selectedMemberImages[0]?.id ?? null);
|
||||
this.showImageModal = true;
|
||||
},
|
||||
closeImageModal() {
|
||||
this.showImageModal = false;
|
||||
this.selectedImageUrl = null;
|
||||
this.selectedMemberId = null;
|
||||
this.selectedMemberImages = [];
|
||||
this.selectedImageId = null;
|
||||
this.selectedMemberForImages = null;
|
||||
},
|
||||
async handleRotate(event) {
|
||||
const { direction, memberId } = event;
|
||||
if (!memberId) return;
|
||||
|
||||
const { direction, memberId, imageId } = event || {};
|
||||
if (!memberId || !imageId) return;
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(`/clubmembers/rotate-image/${this.currentClub}/${memberId}`, {
|
||||
direction: direction
|
||||
const response = await apiClient.post(`/clubmembers/rotate-image/${this.currentClub}/${memberId}/${imageId}`, {
|
||||
direction
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
// Reload the member's image to show the rotated version
|
||||
const member = this.members.find(m => m.id === memberId);
|
||||
if (member) {
|
||||
await this.reloadMemberImage(member);
|
||||
// Update the modal image URL
|
||||
if (member.imageUrl) {
|
||||
this.selectedImageUrl = member.imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const member = this.members.find(m => m.id === memberId);
|
||||
|
||||
if (response.data?.success && member) {
|
||||
await this.applyMemberImageUpdate(member, response.data);
|
||||
this.showInfo('Erfolg', getSafeMessage(response.data.message, 'Bild wurde aktualisiert'), '', 'success');
|
||||
} else {
|
||||
this.showInfo('Fehler', 'Fehler beim Drehen des Bildes', getSafeErrorMessage(error), 'error');
|
||||
const msg = getSafeErrorMessage(response.data?.error || null, 'Fehler beim Drehen des Bildes');
|
||||
this.showInfo('Fehler', msg, '', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Drehen des Bildes:', error);
|
||||
this.showInfo('Fehler', 'Fehler beim Drehen des Bildes', getSafeErrorMessage(error), 'error');
|
||||
this.showInfo('Fehler', getSafeErrorMessage(error, 'Fehler beim Drehen des Bildes'), '', 'error');
|
||||
}
|
||||
},
|
||||
async loadMemberImage(member) {
|
||||
async handleDeleteImage(event) {
|
||||
const { memberId, imageId } = event || {};
|
||||
if (!memberId || !imageId) return;
|
||||
const member = this.members.find(m => m.id === memberId);
|
||||
if (!member) return;
|
||||
|
||||
const confirmed = await this.showConfirm(
|
||||
'Bild löschen',
|
||||
'Möchten Sie dieses Bild wirklich löschen?',
|
||||
'',
|
||||
'danger'
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/clubmembers/image/${this.currentClub}/${member.id}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const imageUrl = URL.createObjectURL(response.data);
|
||||
member.imageUrl = imageUrl;
|
||||
const response = await apiClient.delete(`/clubmembers/image/${this.currentClub}/${memberId}/${imageId}`);
|
||||
if (response.data?.success) {
|
||||
this.applyMemberImageUpdate(member, response.data);
|
||||
this.showInfo('Erfolg', 'Bild wurde gelöscht.', '', 'success');
|
||||
} else {
|
||||
const msg = getSafeMessage(response.data?.error, 'Bild konnte nicht gelöscht werden.');
|
||||
this.showInfo('Fehler', msg, '', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
// Kein Alert - es ist normal, dass nicht alle Mitglieder Bilder haben
|
||||
member.imageUrl = null;
|
||||
console.error('Fehler beim Löschen des Bildes:', error);
|
||||
this.showInfo('Fehler', getSafeErrorMessage(error, 'Bild konnte nicht gelöscht werden'), '', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async reloadMemberImage(member) {
|
||||
async handleSetPrimaryImage(event) {
|
||||
const { memberId, imageId } = event || {};
|
||||
if (!memberId || !imageId) return;
|
||||
const member = this.members.find(m => m.id === memberId);
|
||||
if (!member) return;
|
||||
|
||||
try {
|
||||
// Revoke old blob URL to free memory
|
||||
if (member.imageUrl) {
|
||||
URL.revokeObjectURL(member.imageUrl);
|
||||
const response = await apiClient.post(`/clubmembers/image/${this.currentClub}/${memberId}/${imageId}/primary`);
|
||||
if (response.data?.success) {
|
||||
this.applyMemberImageUpdate(member, response.data);
|
||||
this.showInfo('Erfolg', 'Hauptbild wurde aktualisiert.', '', 'success');
|
||||
} else {
|
||||
const msg = getSafeMessage(response.data?.error, 'Hauptbild konnte nicht gesetzt werden.');
|
||||
this.showInfo('Fehler', msg, '', 'error');
|
||||
}
|
||||
|
||||
const response = await apiClient.get(`/clubmembers/image/${this.currentClub}/${member.id}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
// Create new blob URL with timestamp for cache busting
|
||||
const imageUrl = URL.createObjectURL(response.data);
|
||||
member.imageUrl = imageUrl;
|
||||
|
||||
// Also update selectedImageUrl if this is the currently viewed member
|
||||
if (member.id === this.selectedMemberId) {
|
||||
this.selectedImageUrl = imageUrl;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Neuladen des Bildes:', error);
|
||||
console.error('Fehler beim Setzen des Hauptbildes:', error);
|
||||
this.showInfo('Fehler', getSafeErrorMessage(error, 'Hauptbild konnte nicht gesetzt werden'), '', 'error');
|
||||
}
|
||||
},
|
||||
async handleUploadImages(event) {
|
||||
const { memberId, files } = event || {};
|
||||
if (!memberId || !files || files.length === 0) return;
|
||||
const member = this.members.find(m => m.id === memberId);
|
||||
if (!member) return;
|
||||
|
||||
const fileArray = Array.from(files);
|
||||
for (let index = 0; index < fileArray.length; index += 1) {
|
||||
const file = fileArray[index];
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
const shouldBePrimary = (!member.images || member.images.length === 0) && index === 0;
|
||||
if (shouldBePrimary) {
|
||||
formData.append('makePrimary', 'true');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(`/clubmembers/image/${this.currentClub}/${memberId}`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
if (response.data?.success) {
|
||||
await this.applyMemberImageUpdate(member, response.data);
|
||||
} else {
|
||||
const msg = getSafeMessage(response.data?.error, 'Bild konnte nicht hochgeladen werden.');
|
||||
this.showInfo('Fehler', msg, '', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Hochladen des Bildes:', error);
|
||||
this.showInfo('Fehler', getSafeErrorMessage(error, 'Bild konnte nicht hochgeladen werden'), '', 'error');
|
||||
}
|
||||
}
|
||||
},
|
||||
async applyMemberImageUpdate(member, payload) {
|
||||
if (!member || !payload) return;
|
||||
const images = Array.isArray(payload.images) ? payload.images.map(image => this.createImageObject(image)) : [];
|
||||
member.images = images;
|
||||
member.primaryImageId = payload.primaryImageId || (images[0]?.id ?? null);
|
||||
member.primaryImageUrl = null;
|
||||
member.imageUrl = null;
|
||||
member.latestImageId = this.getLatestImageId(images);
|
||||
member.latestImageUrl = null;
|
||||
member.hasImage = images.length > 0;
|
||||
|
||||
await this.prefetchMemberPrimaryImage(member);
|
||||
await this.prefetchMemberLatestImage(member);
|
||||
|
||||
if (this.selectedMemberForImages && this.selectedMemberForImages.id === member.id) {
|
||||
this.selectedMemberForImages.images = images;
|
||||
await Promise.allSettled(images.map(image => this.ensureImageObjectUrl(image)));
|
||||
this.selectedMemberImages = [...images];
|
||||
if (!this.selectedImageId || !images.some(img => img.id === this.selectedImageId)) {
|
||||
this.selectedImageId = member.primaryImageId || (images[0]?.id ?? null);
|
||||
}
|
||||
}
|
||||
},
|
||||
applyBackendBaseUrlToMember(member) {
|
||||
if (!member) return member;
|
||||
const cloned = { ...member };
|
||||
cloned.images = Array.isArray(member.images) ? member.images.map(image => this.createImageObject(image)) : [];
|
||||
cloned.primaryImageId = member.primaryImageId || (cloned.images[0]?.id ?? null);
|
||||
cloned.primaryImageUrl = null;
|
||||
cloned.imageUrl = null;
|
||||
cloned.latestImageId = this.getLatestImageId(cloned.images);
|
||||
cloned.latestImageUrl = null;
|
||||
return cloned;
|
||||
},
|
||||
createImageObject(image) {
|
||||
const apiPath = (image?.url || '').replace(/^\/?api\//i, '').replace(/^\//, '');
|
||||
return {
|
||||
...image,
|
||||
apiPath,
|
||||
objectUrl: image?.objectUrl || null,
|
||||
url: image?.objectUrl || null
|
||||
};
|
||||
},
|
||||
async prefetchMemberPrimaryImage(member) {
|
||||
if (!member || !member.primaryImageId) {
|
||||
member.primaryImageUrl = null;
|
||||
member.imageUrl = null;
|
||||
return;
|
||||
}
|
||||
const primaryImage = Array.isArray(member.images)
|
||||
? member.images.find(img => img.id === member.primaryImageId) || member.images[0]
|
||||
: null;
|
||||
if (!primaryImage) {
|
||||
member.primaryImageUrl = null;
|
||||
member.imageUrl = null;
|
||||
return;
|
||||
}
|
||||
const objectUrl = await this.ensureImageObjectUrl(primaryImage);
|
||||
member.primaryImageUrl = objectUrl;
|
||||
member.imageUrl = objectUrl;
|
||||
},
|
||||
async prefetchMemberLatestImage(member) {
|
||||
if (!member || !member.latestImageId) {
|
||||
member.latestImageUrl = member.primaryImageUrl || null;
|
||||
return;
|
||||
}
|
||||
const latestImage = Array.isArray(member.images)
|
||||
? member.images.find(img => img.id === member.latestImageId) || member.images[member.images.length - 1]
|
||||
: null;
|
||||
if (!latestImage) {
|
||||
member.latestImageUrl = member.primaryImageUrl || null;
|
||||
return;
|
||||
}
|
||||
const objectUrl = await this.ensureImageObjectUrl(latestImage);
|
||||
member.latestImageUrl = objectUrl || member.primaryImageUrl || null;
|
||||
},
|
||||
getLatestImageId(images) {
|
||||
if (!Array.isArray(images) || images.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return images
|
||||
.filter(image => typeof image.id === 'number' || typeof image.id === 'string')
|
||||
.map(image => Number(image.id))
|
||||
.filter(id => !Number.isNaN(id))
|
||||
.sort((a, b) => a - b)
|
||||
.pop() || images[images.length - 1].id;
|
||||
},
|
||||
async ensureImageObjectUrl(image) {
|
||||
if (!image || image.objectUrl) {
|
||||
return image?.objectUrl || null;
|
||||
}
|
||||
if (!image.apiPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const response = await apiClient.get(image.apiPath, { responseType: 'blob' });
|
||||
const objectUrl = URL.createObjectURL(response.data);
|
||||
image.objectUrl = objectUrl;
|
||||
image.url = objectUrl;
|
||||
return objectUrl;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des Bildes:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
onGalleryClosed() {
|
||||
this.closeImageModal();
|
||||
},
|
||||
async createPhoneList() {
|
||||
const activeMembers = this.members.filter(member => member.active && !member.testMembership);
|
||||
@@ -1746,4 +1921,23 @@ table td {
|
||||
margin-right: 0.25rem;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.member-image-gallery {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.member-image-thumb-small {
|
||||
max-width: 48px;
|
||||
max-height: 48px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user