feat(MemberGroupPhoto): implement group photo management functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 38s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 38s
- Added MemberGroupPhoto model and established relationships with Club and User models. - Introduced new routes for managing group photos in the backend. - Enhanced frontend components to support group photo cropping and member image updates. - Updated localization files to include new terms related to group photo processing across multiple languages. - Refactored server.js to include MemberGroupPhoto in the synchronization process.
This commit is contained in:
65
backend/controllers/memberGroupPhotoController.js
Normal file
65
backend/controllers/memberGroupPhotoController.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import memberGroupPhotoService from '../services/memberGroupPhotoService.js';
|
||||
|
||||
export const listMemberGroupPhotos = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const photos = await memberGroupPhotoService.list(userToken, clubId);
|
||||
res.status(200).json({ success: true, photos });
|
||||
} catch (error) {
|
||||
console.error('[listMemberGroupPhotos] error:', error);
|
||||
res.status(500).json({ success: false, error: 'Failed to list group photos' });
|
||||
}
|
||||
};
|
||||
|
||||
export const createMemberGroupPhoto = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const result = await memberGroupPhotoService.create(userToken, clubId, req.file, req.body);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[createMemberGroupPhoto] error:', error);
|
||||
res.status(500).json({ success: false, error: 'Failed to save group photo' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateMemberGroupPhoto = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, photoId } = req.params;
|
||||
const result = await memberGroupPhotoService.update(userToken, clubId, photoId, req.body);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[updateMemberGroupPhoto] error:', error);
|
||||
res.status(500).json({ success: false, error: 'Failed to update group photo' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteMemberGroupPhoto = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, photoId } = req.params;
|
||||
const result = await memberGroupPhotoService.remove(userToken, clubId, photoId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[deleteMemberGroupPhoto] error:', error);
|
||||
res.status(500).json({ success: false, error: 'Failed to delete group photo' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getMemberGroupPhotoImage = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, photoId } = req.params;
|
||||
const result = await memberGroupPhotoService.getImage(userToken, clubId, photoId);
|
||||
if (result.status === 200) {
|
||||
res.setHeader('Content-Type', 'image/jpeg');
|
||||
return res.sendFile(result.imagePath);
|
||||
}
|
||||
return res.status(result.status).json({ success: false, error: result.error });
|
||||
} catch (error) {
|
||||
console.error('[getMemberGroupPhotoImage] error:', error);
|
||||
res.status(500).json({ success: false, error: 'Failed to load group photo' });
|
||||
}
|
||||
};
|
||||
28
backend/migrations/20260415_create_member_group_photo.sql
Normal file
28
backend/migrations/20260415_create_member_group_photo.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- Migration: Store group photos for later member photo cropping.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `member_group_photo` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`club_id` INT NOT NULL,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`description` TEXT NULL,
|
||||
`file_name` VARCHAR(255) NOT NULL,
|
||||
`original_file_name` VARCHAR(255) NULL,
|
||||
`mime_type` VARCHAR(100) NULL,
|
||||
`file_size` INT NULL,
|
||||
`width` INT NULL,
|
||||
`height` INT NULL,
|
||||
`taken_at` DATETIME NULL,
|
||||
`created_by_user_id` INT NULL,
|
||||
`created_at` DATETIME NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_member_group_photo_club_id` (`club_id`),
|
||||
CONSTRAINT `fk_member_group_photo_club`
|
||||
FOREIGN KEY (`club_id`) REFERENCES `clubs` (`id`)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
CONSTRAINT `fk_member_group_photo_created_by`
|
||||
FOREIGN KEY (`created_by_user_id`) REFERENCES `user` (`id`)
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE
|
||||
);
|
||||
80
backend/models/MemberGroupPhoto.js
Normal file
80
backend/models/MemberGroupPhoto.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const MemberGroupPhoto = sequelize.define('MemberGroupPhoto', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
references: {
|
||||
model: 'clubs',
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE'
|
||||
},
|
||||
title: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
fileName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
field: 'file_name'
|
||||
},
|
||||
originalFileName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'original_file_name'
|
||||
},
|
||||
mimeType: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
field: 'mime_type'
|
||||
},
|
||||
fileSize: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'file_size'
|
||||
},
|
||||
width: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
height: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
takenAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'taken_at'
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'created_by_user_id',
|
||||
references: {
|
||||
model: 'user',
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'SET NULL',
|
||||
onUpdate: 'CASCADE'
|
||||
}
|
||||
}, {
|
||||
tableName: 'member_group_photo',
|
||||
underscored: true,
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
export default MemberGroupPhoto;
|
||||
@@ -49,6 +49,7 @@ import ApiLog from './ApiLog.js';
|
||||
import MemberTransferConfig from './MemberTransferConfig.js';
|
||||
import MemberContact from './MemberContact.js';
|
||||
import MemberImage from './MemberImage.js';
|
||||
import MemberGroupPhoto from './MemberGroupPhoto.js';
|
||||
import MemberTtrHistory from './MemberTtrHistory.js';
|
||||
import MemberPlayInterest from './MemberPlayInterest.js';
|
||||
import MemberOrder from './MemberOrder.js';
|
||||
@@ -102,6 +103,11 @@ MemberPlayInterest.belongsTo(Member, { as: 'member', foreignKey: 'memberId' });
|
||||
Club.hasMany(MemberPlayInterest, { as: 'memberPlayInterests', foreignKey: 'clubId' });
|
||||
MemberPlayInterest.belongsTo(Club, { as: 'club', foreignKey: 'clubId' });
|
||||
|
||||
Club.hasMany(MemberGroupPhoto, { as: 'memberGroupPhotos', foreignKey: 'clubId' });
|
||||
MemberGroupPhoto.belongsTo(Club, { as: 'club', foreignKey: 'clubId' });
|
||||
User.hasMany(MemberGroupPhoto, { as: 'createdMemberGroupPhotos', foreignKey: 'createdByUserId' });
|
||||
MemberGroupPhoto.belongsTo(User, { as: 'createdByUser', foreignKey: 'createdByUserId' });
|
||||
|
||||
Member.hasMany(MemberOrder, { as: 'orders', foreignKey: 'memberId' });
|
||||
MemberOrder.belongsTo(Member, { as: 'member', foreignKey: 'memberId' });
|
||||
Club.hasMany(MemberOrder, { as: 'memberOrders', foreignKey: 'clubId' });
|
||||
@@ -442,6 +448,7 @@ export {
|
||||
MemberTransferConfig,
|
||||
MemberContact,
|
||||
MemberImage,
|
||||
MemberGroupPhoto,
|
||||
MemberTtrHistory,
|
||||
MemberPlayInterest,
|
||||
MemberOrder,
|
||||
|
||||
25
backend/routes/memberGroupPhotoRoutes.js
Normal file
25
backend/routes/memberGroupPhotoRoutes.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { authorize } from '../middleware/authorizationMiddleware.js';
|
||||
import {
|
||||
listMemberGroupPhotos,
|
||||
createMemberGroupPhoto,
|
||||
updateMemberGroupPhoto,
|
||||
deleteMemberGroupPhoto,
|
||||
getMemberGroupPhotoImage
|
||||
} from '../controllers/memberGroupPhotoController.js';
|
||||
|
||||
const router = express.Router();
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 15 * 1024 * 1024 }
|
||||
});
|
||||
|
||||
router.get('/:clubId', authenticate, authorize('members', 'read'), listMemberGroupPhotos);
|
||||
router.post('/:clubId', authenticate, authorize('members', 'write'), upload.single('image'), createMemberGroupPhoto);
|
||||
router.get('/:clubId/:photoId/image', authenticate, authorize('members', 'read'), getMemberGroupPhotoImage);
|
||||
router.put('/:clubId/:photoId', authenticate, authorize('members', 'write'), updateMemberGroupPhoto);
|
||||
router.delete('/:clubId/:photoId', authenticate, authorize('members', 'write'), deleteMemberGroupPhoto);
|
||||
|
||||
export default router;
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, ClubTeamMember, TeamDocument, Group,
|
||||
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
|
||||
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory, MemberPlayInterest
|
||||
, MemberOrder, MemberOrderHistory
|
||||
, MemberOrder, MemberOrderHistory, MemberGroupPhoto
|
||||
} from './models/index.js';
|
||||
import authRoutes from './routes/authRoutes.js';
|
||||
import clubRoutes from './routes/clubRoutes.js';
|
||||
@@ -56,6 +56,7 @@ import memberTransferConfigRoutes from './routes/memberTransferConfigRoutes.js';
|
||||
import trainingGroupRoutes from './routes/trainingGroupRoutes.js';
|
||||
import trainingTimeRoutes from './routes/trainingTimeRoutes.js';
|
||||
import memberOrderRoutes from './routes/memberOrderRoutes.js';
|
||||
import memberGroupPhotoRoutes from './routes/memberGroupPhotoRoutes.js';
|
||||
import schedulerService from './services/schedulerService.js';
|
||||
import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js';
|
||||
import HttpError from './exceptions/HttpError.js';
|
||||
@@ -300,6 +301,7 @@ app.use('/api/member-transfer-config', memberTransferConfigRoutes);
|
||||
app.use('/api/training-groups', trainingGroupRoutes);
|
||||
app.use('/api/training-times', trainingTimeRoutes);
|
||||
app.use('/api/member-orders', memberOrderRoutes);
|
||||
app.use('/api/member-group-photos', memberGroupPhotoRoutes);
|
||||
|
||||
// Middleware für dynamischen kanonischen Tag (vor express.static)
|
||||
const setCanonicalTag = (req, res, next) => {
|
||||
@@ -542,6 +544,7 @@ app.use((err, req, res, next) => {
|
||||
await safeSync(ApiLog);
|
||||
await safeSync(MemberTransferConfig);
|
||||
await safeSync(MemberContact);
|
||||
await safeSync(MemberGroupPhoto);
|
||||
await safeSync(MemberTtrHistory);
|
||||
await safeSync(MemberPlayInterest);
|
||||
await safeSync(MemberOrder);
|
||||
|
||||
177
backend/services/memberGroupPhotoService.js
Normal file
177
backend/services/memberGroupPhotoService.js
Normal file
@@ -0,0 +1,177 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import sharp from 'sharp';
|
||||
import MemberGroupPhoto from '../models/MemberGroupPhoto.js';
|
||||
import { checkAccess, getUserByToken } from '../utils/userUtils.js';
|
||||
|
||||
class MemberGroupPhotoService {
|
||||
async list(userToken, clubId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const photos = await MemberGroupPhoto.findAll({
|
||||
where: { clubId },
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
return photos.map(photo => this.mapPhoto(photo));
|
||||
}
|
||||
|
||||
async create(userToken, clubId, file, payload = {}) {
|
||||
await checkAccess(userToken, clubId);
|
||||
if (!file?.buffer) {
|
||||
return { status: 400, response: { success: false, error: 'No image provided' } };
|
||||
}
|
||||
if (!String(file.mimetype || '').startsWith('image/')) {
|
||||
return { status: 400, response: { success: false, error: 'File must be an image' } };
|
||||
}
|
||||
|
||||
const user = await getUserByToken(userToken);
|
||||
const title = this.sanitizeText(payload.title, 255) || this.defaultTitle();
|
||||
const description = this.sanitizeText(payload.description, 5000) || null;
|
||||
const takenAt = this.parseDate(payload.takenAt);
|
||||
|
||||
let photo = null;
|
||||
try {
|
||||
photo = await MemberGroupPhoto.create({
|
||||
clubId,
|
||||
title,
|
||||
description,
|
||||
fileName: 'pending.jpg',
|
||||
originalFileName: this.sanitizeText(file.originalname, 255) || null,
|
||||
mimeType: 'image/jpeg',
|
||||
fileSize: file.size || file.buffer.length || null,
|
||||
takenAt,
|
||||
createdByUserId: user?.id || null
|
||||
});
|
||||
|
||||
const directory = await this.ensurePhotoDirectory(clubId);
|
||||
const fileName = `${photo.id}.jpg`;
|
||||
const targetPath = path.join(directory, fileName);
|
||||
const info = await sharp(file.buffer)
|
||||
.rotate()
|
||||
.resize(2400, 2400, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: 88 })
|
||||
.toFile(targetPath);
|
||||
|
||||
await photo.update({
|
||||
fileName,
|
||||
fileSize: info.size || file.size || file.buffer.length || null,
|
||||
width: info.width || null,
|
||||
height: info.height || null
|
||||
});
|
||||
|
||||
return { status: 200, response: { success: true, photo: this.mapPhoto(photo) } };
|
||||
} catch (error) {
|
||||
if (photo?.id) {
|
||||
await photo.destroy().catch(() => {});
|
||||
await fs.promises.unlink(this.getPhotoPath(clubId, `${photo.id}.jpg`)).catch(() => {});
|
||||
}
|
||||
console.error('[MemberGroupPhotoService.create] error:', error);
|
||||
return { status: 500, response: { success: false, error: 'Failed to save group photo' } };
|
||||
}
|
||||
}
|
||||
|
||||
async update(userToken, clubId, photoId, payload = {}) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const photo = await this.findPhoto(clubId, photoId);
|
||||
if (!photo) {
|
||||
return { status: 404, response: { success: false, error: 'Group photo not found' } };
|
||||
}
|
||||
|
||||
const updates = {};
|
||||
if (payload.title !== undefined) {
|
||||
updates.title = this.sanitizeText(payload.title, 255) || photo.title;
|
||||
}
|
||||
if (payload.description !== undefined) {
|
||||
updates.description = this.sanitizeText(payload.description, 5000) || null;
|
||||
}
|
||||
if (payload.takenAt !== undefined) {
|
||||
updates.takenAt = this.parseDate(payload.takenAt);
|
||||
}
|
||||
|
||||
await photo.update(updates);
|
||||
return { status: 200, response: { success: true, photo: this.mapPhoto(photo) } };
|
||||
}
|
||||
|
||||
async remove(userToken, clubId, photoId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const photo = await this.findPhoto(clubId, photoId);
|
||||
if (!photo) {
|
||||
return { status: 404, response: { success: false, error: 'Group photo not found' } };
|
||||
}
|
||||
|
||||
const imagePath = this.getPhotoPath(clubId, photo.fileName);
|
||||
await photo.destroy();
|
||||
await fs.promises.unlink(imagePath).catch((error) => {
|
||||
console.warn('[MemberGroupPhotoService.remove] file removal failed:', error.message || error);
|
||||
});
|
||||
|
||||
return { status: 200, response: { success: true } };
|
||||
}
|
||||
|
||||
async getImage(userToken, clubId, photoId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const photo = await this.findPhoto(clubId, photoId);
|
||||
if (!photo) {
|
||||
return { status: 404, error: 'Group photo not found' };
|
||||
}
|
||||
|
||||
const imagePath = this.getPhotoPath(clubId, photo.fileName);
|
||||
if (!fs.existsSync(imagePath)) {
|
||||
return { status: 404, error: 'Group photo file not found' };
|
||||
}
|
||||
|
||||
return { status: 200, imagePath: path.resolve(imagePath) };
|
||||
}
|
||||
|
||||
async findPhoto(clubId, photoId) {
|
||||
return MemberGroupPhoto.findOne({ where: { id: photoId, clubId } });
|
||||
}
|
||||
|
||||
mapPhoto(photo) {
|
||||
const data = typeof photo?.toJSON === 'function' ? photo.toJSON() : photo;
|
||||
return {
|
||||
id: data.id,
|
||||
clubId: data.clubId,
|
||||
title: data.title,
|
||||
description: data.description || '',
|
||||
fileName: data.fileName,
|
||||
originalFileName: data.originalFileName || '',
|
||||
mimeType: data.mimeType || 'image/jpeg',
|
||||
fileSize: data.fileSize || null,
|
||||
width: data.width || null,
|
||||
height: data.height || null,
|
||||
takenAt: data.takenAt || null,
|
||||
createdByUserId: data.createdByUserId || null,
|
||||
createdAt: data.createdAt || null,
|
||||
updatedAt: data.updatedAt || null,
|
||||
imageUrl: `/api/member-group-photos/${data.clubId}/${data.id}/image?t=${new Date(data.updatedAt || Date.now()).getTime()}`
|
||||
};
|
||||
}
|
||||
|
||||
async ensurePhotoDirectory(clubId) {
|
||||
const directory = path.join('images', 'member-group-photos', String(clubId));
|
||||
await fs.promises.mkdir(directory, { recursive: true });
|
||||
return directory;
|
||||
}
|
||||
|
||||
getPhotoPath(clubId, fileName) {
|
||||
return path.join('images', 'member-group-photos', String(clubId), fileName);
|
||||
}
|
||||
|
||||
sanitizeText(value, maxLength) {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) return '';
|
||||
return text.slice(0, maxLength);
|
||||
}
|
||||
|
||||
parseDate(value) {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
defaultTitle() {
|
||||
return `Gruppenfoto ${new Date().toLocaleDateString('de-DE')}`;
|
||||
}
|
||||
}
|
||||
|
||||
export default new MemberGroupPhotoService();
|
||||
311
docs/feature_group_photo_member_crops.md
Normal file
311
docs/feature_group_photo_member_crops.md
Normal file
@@ -0,0 +1,311 @@
|
||||
# Feature-Konzept: Gruppenfoto zu Mitgliedsfotos
|
||||
|
||||
## Ziel
|
||||
|
||||
Trainer sollen ein Gruppenfoto hochladen oder mit der Kamera aufnehmen koennen und daraus per manuell gezogenem Rahmen einzelne Mitgliedsfotos erstellen. Die ausgeschnittenen Bilder werden wie normale Mitgliedsfotos gespeichert.
|
||||
|
||||
Zusaetzlich soll ein Gruppenfoto fuer spaetere Bearbeitung gespeichert werden koennen. So kann der Trainer im Training schnell ein Foto aufnehmen und die eigentliche Zuordnung und Bearbeitung spaeter, zum Beispiel zuhause, erledigen.
|
||||
|
||||
## Nutzerfluss
|
||||
|
||||
1. Trainer oeffnet `/members`.
|
||||
2. Trainer klickt auf `Gruppenfoto verarbeiten`.
|
||||
3. Ein Dialog oeffnet sich mit drei Einstiegen:
|
||||
- `Foto aufnehmen`
|
||||
- `Foto hochladen`
|
||||
- `Gespeichertes Foto oeffnen`
|
||||
4. Bei einem neuen Foto entscheidet der Trainer:
|
||||
- `Nur jetzt verarbeiten`
|
||||
- `Fuer spaetere Bearbeitung speichern`
|
||||
5. Das Gruppenfoto wird gross angezeigt.
|
||||
6. Trainer zieht einen Rahmen um eine Person oder ein Gesicht.
|
||||
7. Trainer waehlt das passende Mitglied aus.
|
||||
8. Trainer klickt `Als Mitgliedsfoto speichern`.
|
||||
9. Der Ausschnitt wird gespeichert.
|
||||
10. Trainer kann direkt den naechsten Ausschnitt aus demselben Gruppenfoto markieren.
|
||||
|
||||
## Erster Funktionsumfang
|
||||
|
||||
- Gruppenfoto hochladen.
|
||||
- Gruppenfoto mobil per Kamera aufnehmen.
|
||||
- Gruppenfoto optional dauerhaft fuer spaetere Bearbeitung speichern.
|
||||
- Gespeicherte Gruppenfotos pro Verein auflisten.
|
||||
- Gespeichertes Gruppenfoto spaeter wieder oeffnen.
|
||||
- Manuelle Rahmenauswahl auf dem Gruppenfoto.
|
||||
- Mitgliedsauswahl mit Suche.
|
||||
- Ausschnitt-Vorschau vor dem Speichern.
|
||||
- Ausschnitt als neues Mitgliedsfoto speichern.
|
||||
- Optionaler Schalter `Als Hauptfoto verwenden`.
|
||||
- Mehrere Ausschnitte nacheinander aus demselben Gruppenfoto erstellen.
|
||||
- Gruppenfoto loeschen, ohne bereits erzeugte Mitgliedsfotos zu loeschen.
|
||||
|
||||
## Nicht im ersten Wurf
|
||||
|
||||
- Automatische Gesichtserkennung.
|
||||
- Automatische Zuordnung zu Mitgliedern.
|
||||
- Mehrfachrahmen gleichzeitig.
|
||||
- Speichern unfertiger Crop-Rahmen als Entwurf.
|
||||
- Statusverwaltung pro markiertem Ausschnitt.
|
||||
- Nachtraegliche Bearbeitung bereits erzeugter Mitgliedsfotos.
|
||||
|
||||
Diese Punkte koennen spaeter ergaenzt werden. Fuer die erste Version bleibt das Feature bewusst manuell und kontrollierbar.
|
||||
|
||||
## UI-Konzept
|
||||
|
||||
Auf `/members` wird ein neuer Button im Aktionsbereich ergaenzt:
|
||||
|
||||
```text
|
||||
Gruppenfoto verarbeiten
|
||||
```
|
||||
|
||||
Der Dialog startet mit:
|
||||
|
||||
```text
|
||||
Gruppenfoto verarbeiten
|
||||
|
||||
[Foto aufnehmen]
|
||||
[Foto hochladen]
|
||||
[Gespeichertes Foto oeffnen]
|
||||
```
|
||||
|
||||
Nach Fotoauswahl:
|
||||
|
||||
```text
|
||||
[ ] Fuer spaetere Bearbeitung speichern
|
||||
Titel: Training 15.04.2026
|
||||
Datum: 15.04.2026
|
||||
Notiz: optional
|
||||
```
|
||||
|
||||
Der Bearbeitungsdialog besteht aus:
|
||||
|
||||
- links: grosses Gruppenfoto mit ziehbarem Crop-Rahmen
|
||||
- rechts: Ausschnitt-Vorschau
|
||||
- rechts: Mitgliedersuche
|
||||
- rechts: Checkbox `Als Hauptfoto verwenden`
|
||||
- rechts: Button `Als Mitgliedsfoto speichern`
|
||||
- rechts: Statusmeldung, zum Beispiel `Foto gespeichert fuer Max Mustermann`
|
||||
|
||||
Fuer mobile Ansicht:
|
||||
|
||||
- Bild oben
|
||||
- Vorschau darunter
|
||||
- Mitgliedersuche darunter
|
||||
- Speichern-Aktion am unteren Rand
|
||||
|
||||
## Gespeicherte Gruppenfotos
|
||||
|
||||
Unter `Gespeichertes Foto oeffnen` wird eine Liste angezeigt:
|
||||
|
||||
- Titel
|
||||
- Aufnahmedatum oder Erstelldatum
|
||||
- optional Notiz
|
||||
- Button `Oeffnen`
|
||||
- Button `Loeschen`
|
||||
|
||||
Ein geloeschtes Gruppenfoto entfernt nur das Original-Gruppenfoto. Bereits erstellte Mitgliedsfotos bleiben erhalten.
|
||||
|
||||
## Crop-Verhalten
|
||||
|
||||
Empfehlung fuer Mitgliedsfotos:
|
||||
|
||||
- Standard-Seitenverhaeltnis `1:1`
|
||||
- Mindestgroesse zum Beispiel `160 x 160 px`
|
||||
- Exportgroesse zum Beispiel `600 x 600 px`
|
||||
- JPEG-Qualitaet etwa `0.9`
|
||||
- Warnung bei sehr kleinen Ausschnitten:
|
||||
|
||||
```text
|
||||
Der Ausschnitt ist sehr klein und kann unscharf wirken.
|
||||
```
|
||||
|
||||
Optional spaeter:
|
||||
|
||||
- Umschalter `Freier Ausschnitt`
|
||||
- Zoom und Pan fuer grosse Gruppenfotos
|
||||
- Tastatursteuerung fuer Feinkorrektur
|
||||
|
||||
## Technische Umsetzung
|
||||
|
||||
### Frontend
|
||||
|
||||
Neue Komponente:
|
||||
|
||||
```text
|
||||
frontend/src/components/members/GroupPhotoCropDialog.vue
|
||||
```
|
||||
|
||||
Einbindung in:
|
||||
|
||||
```text
|
||||
frontend/src/views/MembersView.vue
|
||||
```
|
||||
|
||||
Technischer Ablauf:
|
||||
|
||||
1. Bilddatei per Dateiinput oder Kamera laden.
|
||||
2. Bild im Browser anzeigen.
|
||||
3. Crop-Koordinaten erfassen.
|
||||
4. Ausschnitt per Canvas in ein neues Canvas zeichnen.
|
||||
5. Canvas als `Blob` exportieren.
|
||||
6. Blob per `FormData` an die bestehende Mitgliedsfoto-API hochladen.
|
||||
7. Optional danach das Bild als Hauptfoto setzen.
|
||||
|
||||
Kameraaufnahme kann pragmatisch ueber einen Dateiinput starten:
|
||||
|
||||
```html
|
||||
<input type="file" accept="image/*" capture>
|
||||
```
|
||||
|
||||
### Bestehender Mitgliedsfoto-Upload
|
||||
|
||||
Wenn moeglich wird die bestehende Route genutzt:
|
||||
|
||||
```text
|
||||
POST /api/clubmembers/image/:clubId/:memberId
|
||||
```
|
||||
|
||||
Falls `Als Hauptfoto verwenden` aktiv ist, wird danach die bestehende Primary-Route genutzt:
|
||||
|
||||
```text
|
||||
POST /api/clubmembers/image/:clubId/:memberId/:imageId/primary
|
||||
```
|
||||
|
||||
Der Blob kann als Datei `group-photo-crop.jpg` gesendet werden.
|
||||
|
||||
## Backend fuer gespeicherte Gruppenfotos
|
||||
|
||||
Fuer dauerhaft gespeicherte Gruppenfotos wird eine neue Tabelle empfohlen:
|
||||
|
||||
```text
|
||||
member_group_photo
|
||||
```
|
||||
|
||||
Minimal benoetigte Felder:
|
||||
|
||||
- `id`
|
||||
- `club_id`
|
||||
- `title`
|
||||
- `photo_path`
|
||||
- `taken_at`
|
||||
- `created_by_user_id`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
Sinnvolle weitere Felder:
|
||||
|
||||
- `description`
|
||||
- `original_file_name`
|
||||
- `mime_type`
|
||||
- `file_size`
|
||||
- `width`
|
||||
- `height`
|
||||
- `status`, zum Beispiel `open`, `in_progress`, `done`, `archived`
|
||||
- `processed_at`
|
||||
- `archived_at`
|
||||
|
||||
## API-Vorschlag
|
||||
|
||||
```text
|
||||
GET /api/member-group-photos/:clubId
|
||||
POST /api/member-group-photos/:clubId
|
||||
GET /api/member-group-photos/:clubId/:photoId/image
|
||||
PUT /api/member-group-photos/:clubId/:photoId
|
||||
DELETE /api/member-group-photos/:clubId/:photoId
|
||||
```
|
||||
|
||||
Beschreibung:
|
||||
|
||||
- `GET /:clubId`: listet gespeicherte Gruppenfotos des Vereins.
|
||||
- `POST /:clubId`: speichert ein neues Gruppenfoto.
|
||||
- `GET /:clubId/:photoId/image`: liefert das Originalbild fuer spaetere Bearbeitung.
|
||||
- `PUT /:clubId/:photoId`: aktualisiert Titel, Datum oder Notiz.
|
||||
- `DELETE /:clubId/:photoId`: loescht das gespeicherte Gruppenfoto.
|
||||
|
||||
Alle Routen muessen authentifiziert sein und Vereinszugriff pruefen.
|
||||
|
||||
## Speicherort
|
||||
|
||||
Empfohlener Speicherort, getrennt von normalen Mitgliedsbildern:
|
||||
|
||||
```text
|
||||
uploads/member-group-photos/:clubId/:photoId/original.jpg
|
||||
```
|
||||
|
||||
Falls das Projekt bereits andere Upload-Konventionen nutzt, sollte der Pfad daran angepasst werden.
|
||||
|
||||
## Datenschutz und Berechtigungen
|
||||
|
||||
Wenn komplette Gruppenfotos gespeichert werden, koennen darauf auch Personen zu sehen sein, deren Einzelbild nicht gespeichert werden soll. Deshalb sollte im Dialog ein klarer Hinweis stehen:
|
||||
|
||||
```text
|
||||
Dieses Gruppenfoto wird gespeichert und kann andere Personen enthalten. Bitte nur speichern, wenn die Nutzung im Verein zulaessig ist.
|
||||
```
|
||||
|
||||
Empfehlungen:
|
||||
|
||||
- Gruppenfotos nur fuer berechtigte Vereinsnutzer sichtbar machen.
|
||||
- Keine oeffentliche Bild-URL ohne Authentifizierung.
|
||||
- Bildauslieferung nur ueber geschuetzte API.
|
||||
- Loeschfunktion direkt in der Gruppenfoto-Liste anbieten.
|
||||
- Das Speichern eines Gruppenfotos aendert keine bestehenden Einwilligungen, zum Beispiel `picsInInternetAllowed`.
|
||||
- Die Zuordnung eines Ausschnitts zu einem Mitglied bleibt eine bewusste Trainerentscheidung.
|
||||
|
||||
## Spaeterer Ausbau: Crop-Entwuerfe
|
||||
|
||||
Wenn spaeter auch unfertige Rahmen gespeichert werden sollen, kann eine zweite Tabelle ergaenzt werden:
|
||||
|
||||
```text
|
||||
member_group_photo_crop
|
||||
```
|
||||
|
||||
Moegliche Felder:
|
||||
|
||||
- `id`
|
||||
- `group_photo_id`
|
||||
- `member_id`
|
||||
- `x`
|
||||
- `y`
|
||||
- `width`
|
||||
- `height`
|
||||
- `status`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
Moegliche Statuswerte:
|
||||
|
||||
- `open`
|
||||
- `assigned`
|
||||
- `saved`
|
||||
- `discarded`
|
||||
|
||||
Das ist nicht Teil der ersten Version, waere aber die Grundlage fuer Mehrfachrahmen und spaetere Weiterbearbeitung.
|
||||
|
||||
## Fehlerfaelle
|
||||
|
||||
- Kein Foto gewaehlt: Bearbeitung nicht moeglich.
|
||||
- Kein Rahmen gezogen: Speichern deaktivieren.
|
||||
- Kein Mitglied gewaehlt: Speichern deaktivieren.
|
||||
- Ausschnitt zu klein: Warnung anzeigen oder Speichern deaktivieren.
|
||||
- Upload schlaegt fehl: Fehlermeldung anzeigen, Auswahl beibehalten.
|
||||
- Gruppenfoto kann nicht geladen werden: Fehlermeldung und Rueckkehr zur Liste.
|
||||
- Gruppenfoto wurde geloescht: Liste aktualisieren.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- Trainer kann ein Gruppenfoto hochladen.
|
||||
- Trainer kann ein Gruppenfoto mobil aufnehmen.
|
||||
- Trainer kann ein Gruppenfoto fuer spaetere Bearbeitung speichern.
|
||||
- Gespeicherte Gruppenfotos werden pro Verein aufgelistet.
|
||||
- Ein gespeichertes Gruppenfoto kann spaeter geoeffnet und bearbeitet werden.
|
||||
- Trainer kann per Rahmen einen Ausschnitt markieren.
|
||||
- Ausschnitt wird als Vorschau angezeigt.
|
||||
- Trainer kann ein Mitglied suchen und auswaehlen.
|
||||
- Ausschnitt wird als Mitgliedsfoto gespeichert.
|
||||
- Optional kann der Ausschnitt als Hauptfoto gesetzt werden.
|
||||
- Mehrere Mitglieder koennen nacheinander aus demselben Gruppenfoto versorgt werden.
|
||||
- Gruppenfoto kann geloescht werden.
|
||||
- Loeschen eines Gruppenfotos loescht keine bereits erstellten Mitgliedsfotos.
|
||||
- Gruppenfoto ist nur fuer berechtigte Nutzer des Vereins abrufbar.
|
||||
- Das vollstaendige Gruppenfoto wird nur gespeichert, wenn der Trainer dies bewusst auswaehlt.
|
||||
- Frontend-Build laeuft ohne Fehler.
|
||||
636
frontend/src/components/members/GroupPhotoCropDialog.vue
Normal file
636
frontend/src/components/members/GroupPhotoCropDialog.vue
Normal file
@@ -0,0 +1,636 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:model-value="modelValue"
|
||||
title="Gruppenfoto verarbeiten"
|
||||
size="large"
|
||||
:width="1180"
|
||||
max-width="96vw"
|
||||
@update:model-value="emitClose"
|
||||
@close="emitClose"
|
||||
>
|
||||
<div class="group-photo-dialog">
|
||||
<section class="group-photo-start">
|
||||
<div class="group-photo-actions">
|
||||
<input ref="fileInput" type="file" accept="image/*" class="hidden-input" @change="handleFileChange">
|
||||
<input ref="cameraInput" type="file" accept="image/*" capture="environment" class="hidden-input" @change="handleFileChange">
|
||||
<button type="button" @click="$refs.cameraInput?.click()">Foto aufnehmen</button>
|
||||
<button type="button" @click="$refs.fileInput?.click()">Foto hochladen</button>
|
||||
<button type="button" @click="loadSavedPhotos" :disabled="loadingSaved">
|
||||
{{ loadingSaved ? 'Lade ...' : 'Gespeicherte Fotos laden' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="group-photo-hint">
|
||||
Das Gruppenfoto kann nur lokal verarbeitet oder fuer spaetere Bearbeitung im Verein gespeichert werden.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section v-if="savedPhotos.length" class="saved-photo-list">
|
||||
<h3>Gespeicherte Gruppenfotos</h3>
|
||||
<div class="saved-photo-grid">
|
||||
<article v-for="photo in savedPhotos" :key="photo.id" class="saved-photo-item">
|
||||
<div>
|
||||
<strong>{{ photo.title }}</strong>
|
||||
<span>{{ formatDate(photo.takenAt || photo.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="saved-photo-actions">
|
||||
<button type="button" @click="openSavedPhoto(photo)">Oeffnen</button>
|
||||
<button type="button" class="danger-button" @click="deleteSavedPhoto(photo)">Loeschen</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="sourceUrl" class="save-source-panel">
|
||||
<label class="checkbox-line">
|
||||
<input type="checkbox" v-model="saveForLater">
|
||||
Fuer spaetere Bearbeitung speichern
|
||||
</label>
|
||||
<div v-if="saveForLater" class="save-source-fields">
|
||||
<label>
|
||||
<span>Titel</span>
|
||||
<input v-model="sourceTitle" type="text" placeholder="Training 15.04.2026">
|
||||
</label>
|
||||
<label>
|
||||
<span>Datum</span>
|
||||
<input v-model="sourceTakenAt" type="date">
|
||||
</label>
|
||||
<label>
|
||||
<span>Notiz</span>
|
||||
<input v-model="sourceDescription" type="text" placeholder="optional">
|
||||
</label>
|
||||
<button type="button" class="btn-primary" :disabled="savingSource || !sourceFile || currentSavedPhoto" @click="saveSourcePhoto">
|
||||
{{ currentSavedPhoto ? 'Gruppenfoto gespeichert' : savingSource ? 'Speichere ...' : 'Gruppenfoto speichern' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="saveForLater" class="privacy-note">
|
||||
Dieses Gruppenfoto wird gespeichert und kann andere Personen enthalten. Bitte nur speichern, wenn die Nutzung im Verein zulaessig ist.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section v-if="sourceUrl" class="crop-workspace">
|
||||
<div class="crop-stage">
|
||||
<div
|
||||
ref="imageFrame"
|
||||
class="crop-image-frame"
|
||||
@mousedown="startSelection"
|
||||
@mousemove="moveSelection"
|
||||
@mouseup="finishSelection"
|
||||
@mouseleave="finishSelection"
|
||||
@touchstart.prevent="startSelection"
|
||||
@touchmove.prevent="moveSelection"
|
||||
@touchend.prevent="finishSelection"
|
||||
>
|
||||
<img
|
||||
ref="sourceImage"
|
||||
:src="sourceUrl"
|
||||
alt="Gruppenfoto"
|
||||
draggable="false"
|
||||
@load="handleImageLoad"
|
||||
>
|
||||
<div
|
||||
v-if="selection"
|
||||
class="crop-selection"
|
||||
:style="selectionStyle"
|
||||
></div>
|
||||
</div>
|
||||
<p class="group-photo-hint">Rahmen um eine Person ziehen. Der Ausschnitt wird quadratisch als Mitgliedsfoto gespeichert.</p>
|
||||
</div>
|
||||
|
||||
<aside class="crop-side">
|
||||
<div>
|
||||
<h3>Ausschnitt</h3>
|
||||
<div class="crop-preview">
|
||||
<img v-if="previewUrl" :src="previewUrl" alt="Ausschnitt Vorschau">
|
||||
<span v-else>Kein Ausschnitt</span>
|
||||
</div>
|
||||
<p v-if="smallCropWarning" class="warning-text">Der Ausschnitt ist sehr klein und kann unscharf wirken.</p>
|
||||
</div>
|
||||
|
||||
<label class="member-search">
|
||||
<span>Mitglied suchen</span>
|
||||
<input v-model="memberQuery" type="search" placeholder="Name eingeben">
|
||||
</label>
|
||||
<select v-model="selectedMemberId" class="member-select">
|
||||
<option value="">Mitglied auswaehlen</option>
|
||||
<option v-for="member in filteredMembers" :key="member.id" :value="member.id">
|
||||
{{ member.lastName }}, {{ member.firstName }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<label class="checkbox-line">
|
||||
<input type="checkbox" v-model="makePrimary">
|
||||
Als Hauptfoto verwenden
|
||||
</label>
|
||||
|
||||
<button type="button" class="btn-primary" :disabled="!canSaveCrop || savingCrop" @click="saveCrop">
|
||||
{{ savingCrop ? 'Speichere ...' : 'Als Mitgliedsfoto speichern' }}
|
||||
</button>
|
||||
<button type="button" @click="resetSelection">Auswahl zuruecksetzen</button>
|
||||
<p v-if="statusMessage" class="status-message">{{ statusMessage }}</p>
|
||||
<p v-if="errorMessage" class="error-message">{{ errorMessage }}</p>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseDialog from '../BaseDialog.vue';
|
||||
import apiClient from '../../apiClient.js';
|
||||
|
||||
export default {
|
||||
name: 'GroupPhotoCropDialog',
|
||||
components: { BaseDialog },
|
||||
props: {
|
||||
modelValue: { type: Boolean, required: true },
|
||||
clubId: { type: [String, Number], required: true },
|
||||
members: { type: Array, required: true }
|
||||
},
|
||||
emits: ['update:modelValue', 'member-image-updated'],
|
||||
data() {
|
||||
return {
|
||||
sourceUrl: '',
|
||||
sourceFile: null,
|
||||
sourceTitle: '',
|
||||
sourceTakenAt: '',
|
||||
sourceDescription: '',
|
||||
saveForLater: false,
|
||||
currentSavedPhoto: null,
|
||||
savedPhotos: [],
|
||||
loadingSaved: false,
|
||||
savingSource: false,
|
||||
naturalSize: { width: 0, height: 0 },
|
||||
selection: null,
|
||||
selecting: false,
|
||||
selectionStart: null,
|
||||
previewUrl: '',
|
||||
previewBlob: null,
|
||||
memberQuery: '',
|
||||
selectedMemberId: '',
|
||||
makePrimary: true,
|
||||
savingCrop: false,
|
||||
statusMessage: '',
|
||||
errorMessage: ''
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
filteredMembers() {
|
||||
const query = this.memberQuery.trim().toLowerCase();
|
||||
return [...this.members]
|
||||
.filter(member => {
|
||||
if (!query) return true;
|
||||
return [member.firstName, member.lastName].filter(Boolean).join(' ').toLowerCase().includes(query);
|
||||
})
|
||||
.sort((a, b) => String(a.lastName || '').localeCompare(String(b.lastName || ''), 'de-DE'));
|
||||
},
|
||||
selectionStyle() {
|
||||
if (!this.selection) return {};
|
||||
const frameRect = this.$refs.imageFrame?.getBoundingClientRect();
|
||||
const imageRect = this.$refs.sourceImage?.getBoundingClientRect();
|
||||
const offsetX = frameRect && imageRect ? imageRect.left - frameRect.left : 0;
|
||||
const offsetY = frameRect && imageRect ? imageRect.top - frameRect.top : 0;
|
||||
return {
|
||||
left: `${this.selection.x + offsetX}px`,
|
||||
top: `${this.selection.y + offsetY}px`,
|
||||
width: `${this.selection.size}px`,
|
||||
height: `${this.selection.size}px`
|
||||
};
|
||||
},
|
||||
canSaveCrop() {
|
||||
return !!this.previewBlob && !!this.selectedMemberId;
|
||||
},
|
||||
smallCropWarning() {
|
||||
return this.selection && this.selection.size > 0 && this.selection.size < 80;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
modelValue(isOpen) {
|
||||
if (isOpen && !this.savedPhotos.length) {
|
||||
this.loadSavedPhotos();
|
||||
}
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.revokeSourceUrl();
|
||||
this.revokePreviewUrl();
|
||||
},
|
||||
methods: {
|
||||
emitClose() {
|
||||
this.$emit('update:modelValue', false);
|
||||
},
|
||||
async handleFileChange(event) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
this.errorMessage = 'Bitte eine Bilddatei auswaehlen.';
|
||||
return;
|
||||
}
|
||||
this.revokeSourceUrl();
|
||||
this.revokePreviewUrl();
|
||||
this.sourceFile = file;
|
||||
this.sourceUrl = URL.createObjectURL(file);
|
||||
this.sourceTitle = `Training ${new Date().toLocaleDateString('de-DE')}`;
|
||||
this.sourceTakenAt = new Date().toISOString().slice(0, 10);
|
||||
this.sourceDescription = '';
|
||||
this.currentSavedPhoto = null;
|
||||
this.selection = null;
|
||||
this.previewBlob = null;
|
||||
this.statusMessage = '';
|
||||
this.errorMessage = '';
|
||||
},
|
||||
handleImageLoad() {
|
||||
const image = this.$refs.sourceImage;
|
||||
this.naturalSize = {
|
||||
width: image?.naturalWidth || 0,
|
||||
height: image?.naturalHeight || 0
|
||||
};
|
||||
},
|
||||
getPointer(event) {
|
||||
const point = event.touches?.[0] || event.changedTouches?.[0] || event;
|
||||
const rect = this.$refs.imageFrame.getBoundingClientRect();
|
||||
const image = this.$refs.sourceImage;
|
||||
const imageRect = image.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.max(0, Math.min(point.clientX - imageRect.left, imageRect.width)),
|
||||
y: Math.max(0, Math.min(point.clientY - imageRect.top, imageRect.height)),
|
||||
maxWidth: imageRect.width,
|
||||
maxHeight: imageRect.height
|
||||
};
|
||||
},
|
||||
startSelection(event) {
|
||||
if (!this.sourceUrl) return;
|
||||
const pointer = this.getPointer(event);
|
||||
this.selecting = true;
|
||||
this.selectionStart = pointer;
|
||||
this.selection = { x: pointer.x, y: pointer.y, size: 0 };
|
||||
this.revokePreviewUrl();
|
||||
},
|
||||
moveSelection(event) {
|
||||
if (!this.selecting || !this.selectionStart) return;
|
||||
const pointer = this.getPointer(event);
|
||||
const dx = pointer.x - this.selectionStart.x;
|
||||
const dy = pointer.y - this.selectionStart.y;
|
||||
const size = Math.min(Math.abs(dx), Math.abs(dy));
|
||||
const x = dx < 0 ? this.selectionStart.x - size : this.selectionStart.x;
|
||||
const y = dy < 0 ? this.selectionStart.y - size : this.selectionStart.y;
|
||||
this.selection = {
|
||||
x: Math.max(0, Math.min(x, pointer.maxWidth - size)),
|
||||
y: Math.max(0, Math.min(y, pointer.maxHeight - size)),
|
||||
size
|
||||
};
|
||||
},
|
||||
async finishSelection() {
|
||||
if (!this.selecting) return;
|
||||
this.selecting = false;
|
||||
if (!this.selection || this.selection.size < 20) {
|
||||
this.resetSelection();
|
||||
return;
|
||||
}
|
||||
await this.updatePreview();
|
||||
},
|
||||
async updatePreview() {
|
||||
const blob = await this.createCropBlob();
|
||||
if (!blob) return;
|
||||
this.revokePreviewUrl();
|
||||
this.previewBlob = blob;
|
||||
this.previewUrl = URL.createObjectURL(blob);
|
||||
},
|
||||
async createCropBlob() {
|
||||
const image = this.$refs.sourceImage;
|
||||
if (!image || !this.selection || !this.naturalSize.width || !this.naturalSize.height) return null;
|
||||
const imageRect = image.getBoundingClientRect();
|
||||
const scaleX = this.naturalSize.width / imageRect.width;
|
||||
const scaleY = this.naturalSize.height / imageRect.height;
|
||||
const sx = this.selection.x * scaleX;
|
||||
const sy = this.selection.y * scaleY;
|
||||
const sourceSize = this.selection.size * Math.min(scaleX, scaleY);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 600;
|
||||
canvas.height = 600;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(image, sx, sy, sourceSize, sourceSize, 0, 0, 600, 600);
|
||||
return new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.9));
|
||||
},
|
||||
resetSelection() {
|
||||
this.selection = null;
|
||||
this.selectionStart = null;
|
||||
this.selecting = false;
|
||||
this.previewBlob = null;
|
||||
this.revokePreviewUrl();
|
||||
},
|
||||
async saveCrop() {
|
||||
if (!this.canSaveCrop) return;
|
||||
this.savingCrop = true;
|
||||
this.errorMessage = '';
|
||||
this.statusMessage = '';
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', this.previewBlob, 'group-photo-crop.jpg');
|
||||
if (this.makePrimary) {
|
||||
formData.append('makePrimary', 'true');
|
||||
}
|
||||
const response = await apiClient.post(`/clubmembers/image/${this.clubId}/${this.selectedMemberId}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
if (!response.data?.success) {
|
||||
throw new Error(response.data?.error || 'Ausschnitt konnte nicht gespeichert werden.');
|
||||
}
|
||||
this.$emit('member-image-updated', {
|
||||
memberId: this.selectedMemberId,
|
||||
payload: response.data
|
||||
});
|
||||
const member = this.members.find(entry => String(entry.id) === String(this.selectedMemberId));
|
||||
this.statusMessage = `Foto gespeichert${member ? ` fuer ${member.firstName} ${member.lastName}` : ''}.`;
|
||||
this.resetSelection();
|
||||
} catch (error) {
|
||||
this.errorMessage = error?.message || 'Ausschnitt konnte nicht gespeichert werden.';
|
||||
} finally {
|
||||
this.savingCrop = false;
|
||||
}
|
||||
},
|
||||
async saveSourcePhoto() {
|
||||
if (!this.sourceFile || this.currentSavedPhoto) return;
|
||||
this.savingSource = true;
|
||||
this.errorMessage = '';
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', this.sourceFile);
|
||||
formData.append('title', this.sourceTitle || `Training ${new Date().toLocaleDateString('de-DE')}`);
|
||||
if (this.sourceTakenAt) formData.append('takenAt', this.sourceTakenAt);
|
||||
if (this.sourceDescription) formData.append('description', this.sourceDescription);
|
||||
const response = await apiClient.post(`/member-group-photos/${this.clubId}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
if (!response.data?.success) {
|
||||
throw new Error(response.data?.error || 'Gruppenfoto konnte nicht gespeichert werden.');
|
||||
}
|
||||
this.currentSavedPhoto = response.data.photo;
|
||||
this.savedPhotos = [response.data.photo, ...this.savedPhotos.filter(photo => photo.id !== response.data.photo.id)];
|
||||
this.statusMessage = 'Gruppenfoto gespeichert.';
|
||||
} catch (error) {
|
||||
this.errorMessage = error?.message || 'Gruppenfoto konnte nicht gespeichert werden.';
|
||||
} finally {
|
||||
this.savingSource = false;
|
||||
}
|
||||
},
|
||||
async loadSavedPhotos() {
|
||||
if (!this.clubId) return;
|
||||
this.loadingSaved = true;
|
||||
try {
|
||||
const response = await apiClient.get(`/member-group-photos/${this.clubId}`);
|
||||
this.savedPhotos = Array.isArray(response.data?.photos) ? response.data.photos : [];
|
||||
} catch (error) {
|
||||
this.errorMessage = 'Gespeicherte Gruppenfotos konnten nicht geladen werden.';
|
||||
} finally {
|
||||
this.loadingSaved = false;
|
||||
}
|
||||
},
|
||||
async openSavedPhoto(photo) {
|
||||
try {
|
||||
const apiPath = String(photo.imageUrl || '').replace(/^\/?api\//i, '').replace(/^\//, '');
|
||||
const response = await apiClient.get(apiPath, { responseType: 'blob' });
|
||||
this.revokeSourceUrl();
|
||||
this.revokePreviewUrl();
|
||||
this.sourceUrl = URL.createObjectURL(response.data);
|
||||
this.sourceFile = null;
|
||||
this.currentSavedPhoto = photo;
|
||||
this.sourceTitle = photo.title || '';
|
||||
this.sourceTakenAt = photo.takenAt ? String(photo.takenAt).slice(0, 10) : '';
|
||||
this.sourceDescription = photo.description || '';
|
||||
this.saveForLater = true;
|
||||
this.selection = null;
|
||||
this.previewBlob = null;
|
||||
this.statusMessage = `Gruppenfoto "${photo.title}" geoeffnet.`;
|
||||
this.errorMessage = '';
|
||||
} catch (error) {
|
||||
this.errorMessage = 'Gruppenfoto konnte nicht geoeffnet werden.';
|
||||
}
|
||||
},
|
||||
async deleteSavedPhoto(photo) {
|
||||
if (!window.confirm(`Gruppenfoto "${photo.title}" wirklich loeschen? Bereits erstellte Mitgliedsfotos bleiben erhalten.`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiClient.delete(`/member-group-photos/${this.clubId}/${photo.id}`);
|
||||
this.savedPhotos = this.savedPhotos.filter(entry => entry.id !== photo.id);
|
||||
if (this.currentSavedPhoto?.id === photo.id) {
|
||||
this.currentSavedPhoto = null;
|
||||
this.revokeSourceUrl();
|
||||
this.sourceUrl = '';
|
||||
this.sourceFile = null;
|
||||
this.resetSelection();
|
||||
}
|
||||
} catch (error) {
|
||||
this.errorMessage = 'Gruppenfoto konnte nicht geloescht werden.';
|
||||
}
|
||||
},
|
||||
formatDate(value) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return date.toLocaleDateString('de-DE');
|
||||
},
|
||||
revokeSourceUrl() {
|
||||
if (this.sourceUrl) {
|
||||
URL.revokeObjectURL(this.sourceUrl);
|
||||
}
|
||||
},
|
||||
revokePreviewUrl() {
|
||||
if (this.previewUrl) {
|
||||
URL.revokeObjectURL(this.previewUrl);
|
||||
this.previewUrl = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.group-photo-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.group-photo-actions,
|
||||
.saved-photo-actions,
|
||||
.save-source-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.group-photo-hint,
|
||||
.privacy-note {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.privacy-note {
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.saved-photo-list,
|
||||
.save-source-panel {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 0.9rem;
|
||||
background: var(--background-light);
|
||||
}
|
||||
|
||||
.saved-photo-list h3,
|
||||
.crop-side h3 {
|
||||
margin: 0 0 0.7rem;
|
||||
}
|
||||
|
||||
.saved-photo-grid {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.saved-photo-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.saved-photo-item span {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.save-source-fields label,
|
||||
.member-search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.save-source-fields input,
|
||||
.member-search input,
|
||||
.member-select {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.checkbox-line {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.crop-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 300px;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.crop-image-frame {
|
||||
position: relative;
|
||||
min-height: 320px;
|
||||
max-height: 68vh;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: #111;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.crop-image-frame img {
|
||||
max-width: 100%;
|
||||
max-height: 68vh;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.crop-selection {
|
||||
position: absolute;
|
||||
border: 2px solid #fff;
|
||||
outline: 9999px solid rgba(0, 0, 0, 0.38);
|
||||
box-shadow: 0 0 0 1px var(--primary-color);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.crop-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 0.9rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.crop-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
background: var(--background-light);
|
||||
}
|
||||
|
||||
.crop-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: var(--text-on-primary);
|
||||
}
|
||||
|
||||
.danger-button {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
margin: 0.4rem 0 0;
|
||||
color: #856404;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
color: #1f7a3f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #b42318;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.crop-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.saved-photo-item {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,7 @@
|
||||
{{ memberFormIsOpen ? $t('members.closeEditor') : $t('members.newMember') }}
|
||||
</button>
|
||||
<button @click="$emit('create-phone-list')">{{ $t('members.generatePhoneList') }}</button>
|
||||
<button @click="$emit('open-group-photo-crop')">{{ $t('members.groupPhotoCrop') }}</button>
|
||||
<button @click="$emit('update-ratings')" class="btn-update-ratings" :disabled="isUpdatingRatings">
|
||||
{{ isUpdatingRatings ? $t('members.updating') : $t('members.updateRatings') }}
|
||||
</button>
|
||||
@@ -263,6 +264,7 @@ export default {
|
||||
emits: [
|
||||
'toggle-new-member',
|
||||
'create-phone-list',
|
||||
'open-group-photo-crop',
|
||||
'update-ratings',
|
||||
'open-transfer-dialog',
|
||||
'update:search-query',
|
||||
|
||||
@@ -450,6 +450,8 @@
|
||||
"testMembers": "Testmitglieder",
|
||||
"inactiveMembers": "Inaktive Mitglieder",
|
||||
"generatePhoneList": "Telefonliste generieren",
|
||||
"groupPhotoCrop": "Gruppenfoto verarbeiten",
|
||||
"groupPhotoCropSaved": "Mitgliedsfoto aus Gruppenfoto gespeichert.",
|
||||
"onlyActiveMembers": "Es werden nur aktive Mitglieder ausgegeben",
|
||||
"updateRatings": "TTR/QTTR von myTischtennis aktualisieren",
|
||||
"updating": "Aktualisiere...",
|
||||
|
||||
@@ -174,6 +174,8 @@
|
||||
"testMembers": "Testmitglieder",
|
||||
"inactiveMembers": "Inaktive Mitglieder",
|
||||
"generatePhoneList": "Telefonliste generieren",
|
||||
"groupPhotoCrop": "Gruppenfoto verarbeiten",
|
||||
"groupPhotoCropSaved": "Mitgliedsfoto aus Gruppenfoto gespeichert.",
|
||||
"onlyActiveMembers": "Es werden nur aktive Mitglieder ausgegeben",
|
||||
"updateRatings": "TTR/QTTR von myTischtennis aktualisieren",
|
||||
"updating": "Aktualisiere...",
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
"testMembers": "Testmitglieder",
|
||||
"inactiveMembers": "Inaktive Mitglieder",
|
||||
"generatePhoneList": "Telefonliste generieren",
|
||||
"groupPhotoCrop": "Gruppenfoto verarbeiten",
|
||||
"groupPhotoCropSaved": "Mitgliedsfoto aus Gruppenfoto gespeichert.",
|
||||
"onlyActiveMembers": "Es werden nur aktive Mitglieder ausgegeben",
|
||||
"updateRatings": "TTR/QTTR von myTischtennis aktualisieren",
|
||||
"updating": "Aktualisiere...",
|
||||
|
||||
@@ -448,6 +448,8 @@
|
||||
"testMembers": "Trial members",
|
||||
"inactiveMembers": "Inactive members",
|
||||
"generatePhoneList": "Generate phone list",
|
||||
"groupPhotoCrop": "Process group photo",
|
||||
"groupPhotoCropSaved": "Member photo saved from group photo.",
|
||||
"onlyActiveMembers": "Only active members are included",
|
||||
"updateRatings": "Update TTR/QTTR from myTischtennis",
|
||||
"updating": "Updating...",
|
||||
|
||||
@@ -723,6 +723,8 @@
|
||||
"testMembers": "Trial members",
|
||||
"inactiveMembers": "Inactive members",
|
||||
"generatePhoneList": "Generate phone list",
|
||||
"groupPhotoCrop": "Process group photo",
|
||||
"groupPhotoCropSaved": "Member photo saved from group photo.",
|
||||
"onlyActiveMembers": "Only active members are included",
|
||||
"updateRatings": "Update TTR/QTTR from myTischtennis",
|
||||
"updating": "Updating...",
|
||||
|
||||
@@ -448,6 +448,8 @@
|
||||
"testMembers": "Trial members",
|
||||
"inactiveMembers": "Inactive members",
|
||||
"generatePhoneList": "Generate phone list",
|
||||
"groupPhotoCrop": "Process group photo",
|
||||
"groupPhotoCropSaved": "Member photo saved from group photo.",
|
||||
"onlyActiveMembers": "Only active members are included",
|
||||
"updateRatings": "Update TTR/QTTR from myTischtennis",
|
||||
"updating": "Updating...",
|
||||
|
||||
@@ -413,6 +413,8 @@
|
||||
"testMembers": "Miembros de prueba",
|
||||
"inactiveMembers": "Miembros inactivos",
|
||||
"generatePhoneList": "Generar lista telefónica",
|
||||
"groupPhotoCrop": "Procesar foto de grupo",
|
||||
"groupPhotoCropSaved": "Foto del miembro guardada desde la foto de grupo.",
|
||||
"onlyActiveMembers": "Solo se incluyen miembros activos",
|
||||
"updateRatings": "Actualizar TTR/QTTR desde myTischtennis",
|
||||
"updating": "Actualizando...",
|
||||
|
||||
@@ -413,6 +413,8 @@
|
||||
"testMembers": "Mga kasaping pansubok",
|
||||
"inactiveMembers": "Mga hindi aktibong kasapi",
|
||||
"generatePhoneList": "Gumawa ng listahan ng telepono",
|
||||
"groupPhotoCrop": "Iproseso ang group photo",
|
||||
"groupPhotoCropSaved": "Na-save ang larawan ng miyembro mula sa group photo.",
|
||||
"onlyActiveMembers": "Tanging mga aktibong miyembro lamang ang ilalabas",
|
||||
"updateRatings": "I-update ang TTR/QTTR mula sa myTischtennis",
|
||||
"updating": "Nag-a-update...",
|
||||
|
||||
@@ -413,6 +413,8 @@
|
||||
"testMembers": "Membres à l'essai",
|
||||
"inactiveMembers": "Membres inactifs",
|
||||
"generatePhoneList": "Générer la liste téléphonique",
|
||||
"groupPhotoCrop": "Traiter la photo de groupe",
|
||||
"groupPhotoCropSaved": "Photo du membre enregistrée depuis la photo de groupe.",
|
||||
"onlyActiveMembers": "Seuls les membres actifs sont inclus",
|
||||
"updateRatings": "Mettre à jour TTR/QTTR depuis myTischtennis",
|
||||
"updating": "Mise à jour...",
|
||||
|
||||
@@ -413,6 +413,8 @@
|
||||
"testMembers": "Membri in prova",
|
||||
"inactiveMembers": "Membri inattivi",
|
||||
"generatePhoneList": "Genera elenco telefonico",
|
||||
"groupPhotoCrop": "Elabora foto di gruppo",
|
||||
"groupPhotoCropSaved": "Foto del membro salvata dalla foto di gruppo.",
|
||||
"onlyActiveMembers": "Sono inclusi solo i membri attivi",
|
||||
"updateRatings": "Aggiorna TTR/QTTR da myTischtennis",
|
||||
"updating": "Aggiornamento in corso...",
|
||||
|
||||
@@ -413,6 +413,8 @@
|
||||
"testMembers": "体験メンバー",
|
||||
"inactiveMembers": "非アクティブメンバー",
|
||||
"generatePhoneList": "電話リストを作成",
|
||||
"groupPhotoCrop": "集合写真を処理",
|
||||
"groupPhotoCropSaved": "集合写真から会員写真を保存しました。",
|
||||
"onlyActiveMembers": "アクティブメンバーのみ出力されます",
|
||||
"updateRatings": "myTischtennis から TTR/QTTR を更新",
|
||||
"updating": "更新中...",
|
||||
|
||||
@@ -413,6 +413,8 @@
|
||||
"testMembers": "Członkowie próbni",
|
||||
"inactiveMembers": "Nieaktywni członkowie",
|
||||
"generatePhoneList": "Generuj listę telefoniczną",
|
||||
"groupPhotoCrop": "Przetwórz zdjęcie grupowe",
|
||||
"groupPhotoCropSaved": "Zdjęcie członka zapisane ze zdjęcia grupowego.",
|
||||
"onlyActiveMembers": "Uwzględniani są tylko aktywni członkowie",
|
||||
"updateRatings": "Aktualizuj TTR/QTTR z myTischtennis",
|
||||
"updating": "Aktualizowanie...",
|
||||
|
||||
@@ -413,6 +413,8 @@
|
||||
"testMembers": "สมาชิกทดลอง",
|
||||
"inactiveMembers": "สมาชิกที่ไม่ใช้งาน",
|
||||
"generatePhoneList": "สร้างรายการโทรศัพท์",
|
||||
"groupPhotoCrop": "ประมวลผลรูปกลุ่ม",
|
||||
"groupPhotoCropSaved": "บันทึกรูปสมาชิกจากรูปกลุ่มแล้ว",
|
||||
"onlyActiveMembers": "จะแสดงเฉพาะสมาชิกที่ใช้งานอยู่",
|
||||
"updateRatings": "อัปเดต TTR/QTTR จาก myTischtennis",
|
||||
"updating": "กำลังอัปเดต...",
|
||||
|
||||
@@ -413,6 +413,8 @@
|
||||
"testMembers": "Mga trial na miyembro",
|
||||
"inactiveMembers": "Mga hindi aktibong miyembro",
|
||||
"generatePhoneList": "Gumawa ng listahan ng telepono",
|
||||
"groupPhotoCrop": "Iproseso ang group photo",
|
||||
"groupPhotoCropSaved": "Na-save ang larawan ng miyembro mula sa group photo.",
|
||||
"onlyActiveMembers": "Tanging mga aktibong miyembro lamang ang ilalabas",
|
||||
"updateRatings": "I-update ang TTR/QTTR mula sa myTischtennis",
|
||||
"updating": "Nag-a-update...",
|
||||
|
||||
@@ -413,6 +413,8 @@
|
||||
"testMembers": "试用成员",
|
||||
"inactiveMembers": "非活跃成员",
|
||||
"generatePhoneList": "生成电话列表",
|
||||
"groupPhotoCrop": "处理集体照",
|
||||
"groupPhotoCropSaved": "已从集体照保存会员照片。",
|
||||
"onlyActiveMembers": "仅导出活跃成员",
|
||||
"updateRatings": "从 myTischtennis 更新 TTR/QTTR",
|
||||
"updating": "更新中...",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
:export-preview-names="exportPreviewNames"
|
||||
@toggle-new-member="toggleNewMember"
|
||||
@create-phone-list="createPhoneList"
|
||||
@open-group-photo-crop="showGroupPhotoCropDialog = true"
|
||||
@update-ratings="updateRatingsFromMyTischtennis"
|
||||
@open-transfer-dialog="openTransferDialog"
|
||||
@update:search-query="searchQuery = $event"
|
||||
@@ -584,6 +585,13 @@
|
||||
:club-id="currentClub"
|
||||
@close="closeOrdersDialog"
|
||||
/>
|
||||
|
||||
<GroupPhotoCropDialog
|
||||
v-model="showGroupPhotoCropDialog"
|
||||
:club-id="currentClub"
|
||||
:members="members"
|
||||
@member-image-updated="handleGroupPhotoMemberImageUpdated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -610,6 +618,7 @@ import MemberTransferDialog from '../components/MemberTransferDialog.vue';
|
||||
import MemberTtrHistoryDialog from '../components/MemberTtrHistoryDialog.vue';
|
||||
import MemberOrdersDialog from '../components/MemberOrdersDialog.vue';
|
||||
import MembersOverviewSection from '../components/members/MembersOverviewSection.vue';
|
||||
import GroupPhotoCropDialog from '../components/members/GroupPhotoCropDialog.vue';
|
||||
import { debounce } from '../utils/debounce.js';
|
||||
import { buildInfoConfig, buildConfirmConfig, safeErrorMessage, sanitizeText } from '../utils/dialogUtils.js';
|
||||
|
||||
@@ -633,7 +642,8 @@ export default {
|
||||
MemberTransferDialog,
|
||||
MemberTtrHistoryDialog,
|
||||
MemberOrdersDialog,
|
||||
MembersOverviewSection
|
||||
MembersOverviewSection,
|
||||
GroupPhotoCropDialog
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['isAuthenticated', 'currentClub', 'token']),
|
||||
@@ -1005,6 +1015,7 @@ export default {
|
||||
showActivitiesModal: false,
|
||||
showMemberTtrHistoryDialog: false,
|
||||
showMemberOrdersDialog: false,
|
||||
showGroupPhotoCropDialog: false,
|
||||
selectedMemberForActivities: null,
|
||||
selectedMemberForTtrHistory: null,
|
||||
selectedMemberForOrders: null,
|
||||
@@ -2081,6 +2092,13 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
async handleGroupPhotoMemberImageUpdated(event) {
|
||||
const { memberId, payload } = event || {};
|
||||
const member = this.members.find(m => String(m.id) === String(memberId));
|
||||
if (!member) return;
|
||||
await this.applyMemberImageUpdate(member, payload);
|
||||
await this.showInfo(this.$t('messages.success'), this.$t('members.groupPhotoCropSaved'), '', 'success');
|
||||
},
|
||||
async applyMemberImageUpdate(member, payload) {
|
||||
if (!member || !payload) return;
|
||||
const images = Array.isArray(payload.images) ? payload.images.map(image => this.createImageObject(image)) : [];
|
||||
|
||||
Reference in New Issue
Block a user