diff --git a/backend/controllers/memberGroupPhotoController.js b/backend/controllers/memberGroupPhotoController.js new file mode 100644 index 00000000..49f95b4a --- /dev/null +++ b/backend/controllers/memberGroupPhotoController.js @@ -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' }); + } +}; diff --git a/backend/migrations/20260415_create_member_group_photo.sql b/backend/migrations/20260415_create_member_group_photo.sql new file mode 100644 index 00000000..073d069a --- /dev/null +++ b/backend/migrations/20260415_create_member_group_photo.sql @@ -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 +); diff --git a/backend/models/MemberGroupPhoto.js b/backend/models/MemberGroupPhoto.js new file mode 100644 index 00000000..92895fb4 --- /dev/null +++ b/backend/models/MemberGroupPhoto.js @@ -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; diff --git a/backend/models/index.js b/backend/models/index.js index 7bf85a51..d08bb399 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -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, diff --git a/backend/routes/memberGroupPhotoRoutes.js b/backend/routes/memberGroupPhotoRoutes.js new file mode 100644 index 00000000..8c71db86 --- /dev/null +++ b/backend/routes/memberGroupPhotoRoutes.js @@ -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; diff --git a/backend/server.js b/backend/server.js index 7366dd1d..3197e3d4 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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); diff --git a/backend/services/memberGroupPhotoService.js b/backend/services/memberGroupPhotoService.js new file mode 100644 index 00000000..ff06b5d8 --- /dev/null +++ b/backend/services/memberGroupPhotoService.js @@ -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(); diff --git a/docs/feature_group_photo_member_crops.md b/docs/feature_group_photo_member_crops.md new file mode 100644 index 00000000..b69a466a --- /dev/null +++ b/docs/feature_group_photo_member_crops.md @@ -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 + +``` + +### 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. diff --git a/frontend/src/components/members/GroupPhotoCropDialog.vue b/frontend/src/components/members/GroupPhotoCropDialog.vue new file mode 100644 index 00000000..cf7e91d0 --- /dev/null +++ b/frontend/src/components/members/GroupPhotoCropDialog.vue @@ -0,0 +1,636 @@ + + + + + diff --git a/frontend/src/components/members/MembersOverviewSection.vue b/frontend/src/components/members/MembersOverviewSection.vue index dc4b2a02..d0fa15cc 100644 --- a/frontend/src/components/members/MembersOverviewSection.vue +++ b/frontend/src/components/members/MembersOverviewSection.vue @@ -9,6 +9,7 @@ {{ memberFormIsOpen ? $t('members.closeEditor') : $t('members.newMember') }} + @@ -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', diff --git a/frontend/src/i18n/locales/de-CH.json b/frontend/src/i18n/locales/de-CH.json index b090e9a1..044b747f 100644 --- a/frontend/src/i18n/locales/de-CH.json +++ b/frontend/src/i18n/locales/de-CH.json @@ -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...", diff --git a/frontend/src/i18n/locales/de-extended.json b/frontend/src/i18n/locales/de-extended.json index 1355676a..03e0b13c 100644 --- a/frontend/src/i18n/locales/de-extended.json +++ b/frontend/src/i18n/locales/de-extended.json @@ -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...", diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index dc950bd7..cf4150e1 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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...", diff --git a/frontend/src/i18n/locales/en-AU.json b/frontend/src/i18n/locales/en-AU.json index e36d172b..db6d8da9 100644 --- a/frontend/src/i18n/locales/en-AU.json +++ b/frontend/src/i18n/locales/en-AU.json @@ -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...", diff --git a/frontend/src/i18n/locales/en-GB.json b/frontend/src/i18n/locales/en-GB.json index 6be339c7..087c45d5 100644 --- a/frontend/src/i18n/locales/en-GB.json +++ b/frontend/src/i18n/locales/en-GB.json @@ -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...", diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index 3a1bec0d..0b74aabd 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -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...", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 894d6ac7..507aee3f 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -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...", diff --git a/frontend/src/i18n/locales/fil.json b/frontend/src/i18n/locales/fil.json index 1a035e3e..d66c654a 100644 --- a/frontend/src/i18n/locales/fil.json +++ b/frontend/src/i18n/locales/fil.json @@ -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...", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 12a4f370..a6c0d3c7 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -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...", diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index dfe54861..0e5d456c 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -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...", diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index c19c8406..98610685 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -413,6 +413,8 @@ "testMembers": "体験メンバー", "inactiveMembers": "非アクティブメンバー", "generatePhoneList": "電話リストを作成", + "groupPhotoCrop": "集合写真を処理", + "groupPhotoCropSaved": "集合写真から会員写真を保存しました。", "onlyActiveMembers": "アクティブメンバーのみ出力されます", "updateRatings": "myTischtennis から TTR/QTTR を更新", "updating": "更新中...", diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index cb564932..c9aadb6a 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -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...", diff --git a/frontend/src/i18n/locales/th.json b/frontend/src/i18n/locales/th.json index 81ca3fe2..9a91d8d7 100644 --- a/frontend/src/i18n/locales/th.json +++ b/frontend/src/i18n/locales/th.json @@ -413,6 +413,8 @@ "testMembers": "สมาชิกทดลอง", "inactiveMembers": "สมาชิกที่ไม่ใช้งาน", "generatePhoneList": "สร้างรายการโทรศัพท์", + "groupPhotoCrop": "ประมวลผลรูปกลุ่ม", + "groupPhotoCropSaved": "บันทึกรูปสมาชิกจากรูปกลุ่มแล้ว", "onlyActiveMembers": "จะแสดงเฉพาะสมาชิกที่ใช้งานอยู่", "updateRatings": "อัปเดต TTR/QTTR จาก myTischtennis", "updating": "กำลังอัปเดต...", diff --git a/frontend/src/i18n/locales/tl.json b/frontend/src/i18n/locales/tl.json index 04e6fbaf..fd1bd938 100644 --- a/frontend/src/i18n/locales/tl.json +++ b/frontend/src/i18n/locales/tl.json @@ -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...", diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index d724c7a4..84f9062a 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -413,6 +413,8 @@ "testMembers": "试用成员", "inactiveMembers": "非活跃成员", "generatePhoneList": "生成电话列表", + "groupPhotoCrop": "处理集体照", + "groupPhotoCropSaved": "已从集体照保存会员照片。", "onlyActiveMembers": "仅导出活跃成员", "updateRatings": "从 myTischtennis 更新 TTR/QTTR", "updating": "更新中...", diff --git a/frontend/src/views/MembersView.vue b/frontend/src/views/MembersView.vue index 109035ec..845d2b8e 100644 --- a/frontend/src/views/MembersView.vue +++ b/frontend/src/views/MembersView.vue @@ -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" /> + + @@ -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)) : [];