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:
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