Implement member image management features in backend and frontend
This commit introduces new functionalities for managing member images, including uploading, deleting, and setting primary images. The memberController and memberService have been updated to handle these operations, while new routes have been added to facilitate image management. The frontend has been enhanced with an ImageViewerDialog component that supports image rotation, deletion, and setting primary images. Additionally, improvements to the member view allow for better image handling and display. These changes enhance the overall user experience and functionality of the member management system.
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import axios from 'axios';
|
||||
import store from './store';
|
||||
|
||||
export const backendBaseUrl = import.meta.env.VITE_BACKEND || 'http://localhost:3005';
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: `${import.meta.env.VITE_BACKEND || 'http://localhost:3005'}/api`,
|
||||
baseURL: `${backendBaseUrl}/api`,
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use(config => {
|
||||
|
||||
@@ -9,56 +9,103 @@
|
||||
:close-on-overlay="true"
|
||||
@close="handleClose"
|
||||
>
|
||||
<!-- Image Content -->
|
||||
<div class="image-viewer-content">
|
||||
<div class="image-container">
|
||||
<img
|
||||
v-if="imageUrl"
|
||||
:src="imageUrl"
|
||||
:alt="title"
|
||||
class="viewer-image"
|
||||
:style="imageStyle"
|
||||
/>
|
||||
<div v-else class="no-image">
|
||||
Kein Bild verfügbar
|
||||
<div class="image-main" :class="{ 'has-images': hasImages }">
|
||||
<button
|
||||
v-if="hasImages && images.length > 1"
|
||||
class="nav-button nav-button--prev"
|
||||
@click="showPreviousImage"
|
||||
title="Vorheriges Bild"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
|
||||
<div class="image-container">
|
||||
<img
|
||||
v-if="currentImageUrl"
|
||||
:src="currentImageUrl"
|
||||
:alt="title"
|
||||
class="viewer-image"
|
||||
:style="imageStyle"
|
||||
/>
|
||||
<div v-else class="no-image">
|
||||
Kein Bild verfügbar
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="hasImages && images.length > 1"
|
||||
class="nav-button nav-button--next"
|
||||
@click="showNextImage"
|
||||
title="Nächstes Bild"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Bild-Aktionen -->
|
||||
<div v-if="showActions && imageUrl" class="image-actions">
|
||||
<button
|
||||
v-if="allowRotate"
|
||||
@click="rotateLeft"
|
||||
|
||||
<div v-if="showActions && hasImages" class="image-actions">
|
||||
<button
|
||||
v-if="allowRotate && currentImageId"
|
||||
@click="rotate('left')"
|
||||
class="action-btn"
|
||||
title="90° links drehen"
|
||||
>
|
||||
↺ Links drehen
|
||||
</button>
|
||||
<button
|
||||
v-if="allowRotate"
|
||||
@click="rotateRight"
|
||||
<button
|
||||
v-if="allowRotate && currentImageId"
|
||||
@click="rotate('right')"
|
||||
class="action-btn"
|
||||
title="90° rechts drehen"
|
||||
>
|
||||
↻ Rechts drehen
|
||||
</button>
|
||||
<button
|
||||
v-if="allowZoom"
|
||||
@click="resetZoom"
|
||||
<button
|
||||
v-if="showSetPrimary && currentImage && !currentImage.isPrimary"
|
||||
@click="setPrimary"
|
||||
class="action-btn"
|
||||
title="Zoom zurücksetzen"
|
||||
title="Als Hauptbild festlegen"
|
||||
>
|
||||
🔍 Zoom zurücksetzen
|
||||
⭐ Als Hauptbild setzen
|
||||
</button>
|
||||
<button
|
||||
v-if="showDelete && currentImageId"
|
||||
@click="deleteImage"
|
||||
class="action-btn action-btn--danger"
|
||||
title="Bild löschen"
|
||||
>
|
||||
🗑️ Löschen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Zusätzliche Inhalte -->
|
||||
|
||||
<div v-if="allowUpload" class="upload-section">
|
||||
<label class="upload-label">
|
||||
📤 Bilder hochladen
|
||||
<input type="file" multiple accept="image/*" @change="handleFileSelect" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="hasImages" class="thumbnail-strip">
|
||||
<div
|
||||
v-for="image in images"
|
||||
:key="image.id"
|
||||
class="thumbnail"
|
||||
:class="{
|
||||
'thumbnail--active': image.id === currentImageId,
|
||||
'thumbnail--primary': image.isPrimary
|
||||
}"
|
||||
@click="selectImage(image.id)"
|
||||
>
|
||||
<img :src="image.objectUrl || image.url" :alt="`Bild #${image.id}`" />
|
||||
<span v-if="image.isPrimary" class="thumbnail-badge">Primär</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.default" class="extra-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<template #footer>
|
||||
<slot name="footer">
|
||||
<button @click="handleClose" class="btn-secondary">
|
||||
@@ -86,9 +133,13 @@ export default {
|
||||
type: String,
|
||||
default: 'Bild'
|
||||
},
|
||||
imageUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
images: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
activeImageId: {
|
||||
type: [Number, String],
|
||||
default: null
|
||||
},
|
||||
memberId: {
|
||||
type: [Number, String],
|
||||
@@ -105,57 +156,149 @@ export default {
|
||||
allowZoom: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showDelete: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showSetPrimary: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
allowUpload: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
imageUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'close', 'rotate'],
|
||||
emits: [
|
||||
'update:modelValue',
|
||||
'update:activeImageId',
|
||||
'close',
|
||||
'rotate',
|
||||
'delete-image',
|
||||
'set-primary',
|
||||
'upload-images'
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
scale: 1
|
||||
scale: 1,
|
||||
currentImageId: this.activeImageId
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasImages() {
|
||||
return Array.isArray(this.images) && this.images.length > 0;
|
||||
},
|
||||
currentImage() {
|
||||
if (!this.hasImages) {
|
||||
return null;
|
||||
}
|
||||
const match = this.images.find(img => img.id === this.currentImageId);
|
||||
return match || this.images[0] || null;
|
||||
},
|
||||
currentImageUrl() {
|
||||
if (this.currentImage) {
|
||||
return this.currentImage.objectUrl || this.currentImage.url || '';
|
||||
}
|
||||
return this.imageUrl || '';
|
||||
},
|
||||
imageStyle() {
|
||||
return {
|
||||
transform: `scale(${this.scale})`
|
||||
};
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
activeImageId(newVal) {
|
||||
this.currentImageId = newVal;
|
||||
},
|
||||
images: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
if (!this.hasImages) {
|
||||
this.currentImageId = null;
|
||||
return;
|
||||
}
|
||||
if (!this.currentImageId || !this.images.some(img => img.id === this.currentImageId)) {
|
||||
this.currentImageId = this.images[0].id;
|
||||
this.emitActiveChange();
|
||||
}
|
||||
}
|
||||
},
|
||||
modelValue(newVal) {
|
||||
if (!newVal) {
|
||||
this.scale = 1;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleClose() {
|
||||
this.scale = 1;
|
||||
this.$emit('update:modelValue', false);
|
||||
this.$emit('close');
|
||||
},
|
||||
|
||||
rotateLeft() {
|
||||
// Emit rotate event - das Bild wird auf dem Server gedreht
|
||||
// und dann neu geladen, daher keine lokale Rotation
|
||||
selectImage(imageId) {
|
||||
if (!imageId || imageId === this.currentImageId) return;
|
||||
this.currentImageId = imageId;
|
||||
this.emitActiveChange();
|
||||
},
|
||||
emitActiveChange() {
|
||||
this.$emit('update:activeImageId', this.currentImageId);
|
||||
},
|
||||
showPreviousImage() {
|
||||
if (!this.hasImages || this.images.length <= 1) return;
|
||||
const currentIndex = this.images.findIndex(img => img.id === this.currentImageId);
|
||||
const prevIndex = currentIndex > 0 ? currentIndex - 1 : this.images.length - 1;
|
||||
this.currentImageId = this.images[prevIndex].id;
|
||||
this.emitActiveChange();
|
||||
},
|
||||
showNextImage() {
|
||||
if (!this.hasImages || this.images.length <= 1) return;
|
||||
const currentIndex = this.images.findIndex(img => img.id === this.currentImageId);
|
||||
const nextIndex = currentIndex === -1 || currentIndex === this.images.length - 1 ? 0 : currentIndex + 1;
|
||||
this.currentImageId = this.images[nextIndex].id;
|
||||
this.emitActiveChange();
|
||||
},
|
||||
rotate(direction) {
|
||||
if (!this.allowRotate || !this.currentImageId) return;
|
||||
this.$emit('rotate', {
|
||||
direction: 'left',
|
||||
memberId: this.memberId
|
||||
direction,
|
||||
memberId: this.memberId,
|
||||
imageId: this.currentImageId
|
||||
});
|
||||
},
|
||||
|
||||
rotateRight() {
|
||||
// Emit rotate event - das Bild wird auf dem Server gedreht
|
||||
// und dann neu geladen, daher keine lokale Rotation
|
||||
this.$emit('rotate', {
|
||||
direction: 'right',
|
||||
memberId: this.memberId
|
||||
deleteImage() {
|
||||
if (!this.showDelete || !this.currentImageId) return;
|
||||
this.$emit('delete-image', {
|
||||
memberId: this.memberId,
|
||||
imageId: this.currentImageId
|
||||
});
|
||||
},
|
||||
|
||||
setPrimary() {
|
||||
if (!this.showSetPrimary || !this.currentImageId) return;
|
||||
this.$emit('set-primary', {
|
||||
memberId: this.memberId,
|
||||
imageId: this.currentImageId
|
||||
});
|
||||
},
|
||||
handleFileSelect(event) {
|
||||
const files = event?.target?.files;
|
||||
if (!this.allowUpload || !files || files.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.$emit('upload-images', {
|
||||
memberId: this.memberId,
|
||||
files
|
||||
});
|
||||
event.target.value = '';
|
||||
},
|
||||
resetZoom() {
|
||||
this.scale = 1;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
modelValue(newVal) {
|
||||
if (!newVal) {
|
||||
// Dialog geschlossen - Reset
|
||||
this.scale = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -164,19 +307,30 @@ export default {
|
||||
.image-viewer-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.image-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.image-main.has-images {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
min-height: 220px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.viewer-image {
|
||||
@@ -193,20 +347,40 @@ export default {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: #fff;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
padding: 0.5rem 1.25rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
color: var(--text-color);
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
@@ -217,11 +391,92 @@ export default {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.action-btn--danger {
|
||||
border-color: #dc3545;
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.action-btn--danger:hover {
|
||||
background: #dc354514;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.upload-label {
|
||||
position: relative;
|
||||
padding: 0.6rem 1.4rem;
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text-color);
|
||||
font-size: 0.95rem;
|
||||
transition: border 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.upload-label:hover {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
.upload-label input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.thumbnail-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
border: 2px solid transparent;
|
||||
transition: transform 0.2s ease, border 0.2s ease;
|
||||
}
|
||||
|
||||
.thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.thumbnail--active {
|
||||
border-color: var(--primary-color);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.thumbnail--primary {
|
||||
box-shadow: 0 0 0 2px rgba(40, 167, 69, 0.5);
|
||||
}
|
||||
|
||||
.thumbnail-badge {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
background: rgba(40, 167, 69, 0.85);
|
||||
color: #fff;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.65rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.extra-content {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@@ -240,19 +495,17 @@ export default {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.viewer-image {
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
|
||||
.image-main {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
|
||||
.nav-button {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -162,10 +162,16 @@
|
||||
<template v-for="member in filteredMembers" :key="member.id">
|
||||
<tr v-if="member.active || showInactiveMembers" class="member-row" :class="{ 'row-inactive': !member.active, 'row-test': member.testMembership && !member.memberFormHandedOver, 'row-test-form': member.testMembership && member.memberFormHandedOver }" @click="editMember(member)">
|
||||
<td>
|
||||
<img v-if="member.imageUrl" :src="member.imageUrl" alt="Mitgliedsbild"
|
||||
style="max-width: 50px; max-height: 50px;"
|
||||
@click.stop="openImageModal(member.imageUrl, member.id)">
|
||||
<span>{{ member.picsInInternetAllowed ? '✓' : '' }}</span>
|
||||
<div @click.stop="openImageModal(member)">
|
||||
<img
|
||||
v-if="member.latestImageUrl"
|
||||
:src="member.latestImageUrl"
|
||||
alt="Mitgliedsbild"
|
||||
class="member-image-thumb-small"
|
||||
>
|
||||
<div v-else class="member-image-thumb__placeholder">+</div>
|
||||
</div>
|
||||
<span class="pics-allowed-indicator">{{ member.picsInInternetAllowed ? '✓' : '' }}</span>
|
||||
</td>
|
||||
<td>{{ member.testMembership ? '*' : '' }}</td>
|
||||
<td>
|
||||
@@ -221,12 +227,21 @@
|
||||
<!-- Image Viewer Dialog -->
|
||||
<ImageViewerDialog
|
||||
v-model="showImageModal"
|
||||
title="Mitgliedsbild"
|
||||
:image-url="selectedImageUrl"
|
||||
title="Mitgliedsbilder"
|
||||
:images="selectedMemberImages"
|
||||
:active-image-id="selectedImageId"
|
||||
:member-id="selectedMemberId"
|
||||
:show-actions="true"
|
||||
:allow-rotate="true"
|
||||
:show-delete="true"
|
||||
:show-set-primary="true"
|
||||
:allow-upload="true"
|
||||
@update:activeImageId="selectedImageId = $event"
|
||||
@rotate="handleRotate"
|
||||
@delete-image="handleDeleteImage"
|
||||
@set-primary="handleSetPrimaryImage"
|
||||
@upload-images="handleUploadImages"
|
||||
@close="onGalleryClosed"
|
||||
/>
|
||||
|
||||
<!-- Notes Modal -->
|
||||
@@ -305,7 +320,7 @@ export default {
|
||||
MemberTransferDialog
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['isAuthenticated', 'currentClub']),
|
||||
...mapGetters(['isAuthenticated', 'currentClub', 'token']),
|
||||
|
||||
activeMembersCount() {
|
||||
return this.members.filter(member => member.active && !member.testMembership).length;
|
||||
@@ -389,9 +404,11 @@ export default {
|
||||
notes: [],
|
||||
newNoteContent: '',
|
||||
showNotesModal: false,
|
||||
showImageModal: false,
|
||||
selectedImageUrl: null,
|
||||
showImageModal: false,
|
||||
selectedMemberId: null,
|
||||
selectedMemberImages: [],
|
||||
selectedImageId: null,
|
||||
selectedMemberForImages: null,
|
||||
testMembership: false,
|
||||
showInactiveMembers: false,
|
||||
newPicsInInternetAllowed: false,
|
||||
@@ -440,7 +457,9 @@ export default {
|
||||
},
|
||||
async loadMembers() {
|
||||
const response = await apiClient.get(`/clubmembers/get/${this.currentClub}/true`);
|
||||
this.members = response.data.sort((a, b) => {
|
||||
this.members = response.data
|
||||
.map(member => this.applyBackendBaseUrlToMember(member))
|
||||
.sort((a, b) => {
|
||||
const lastNameA = a.lastName ? a.lastName.toLowerCase() : '';
|
||||
const lastNameB = b.lastName ? b.lastName.toLowerCase() : '';
|
||||
if (lastNameA && !lastNameB) return -1;
|
||||
@@ -453,12 +472,11 @@ export default {
|
||||
const firstNameB = b.firstName ? b.firstName.toLowerCase() : '';
|
||||
return firstNameA.localeCompare(firstNameB, 'de-DE');
|
||||
});
|
||||
this.members.forEach(member => {
|
||||
this.loadMemberImage(member);
|
||||
});
|
||||
|
||||
// Lade Trainingsteilnahmen für alle Testmitglieder auf einmal über /training-stats
|
||||
await this.loadTrainingParticipations();
|
||||
await Promise.allSettled(this.members.map(member => this.prefetchMemberPrimaryImage(member)));
|
||||
await Promise.allSettled(this.members.map(member => this.prefetchMemberLatestImage(member)));
|
||||
},
|
||||
|
||||
async loadTrainingParticipations() {
|
||||
@@ -957,80 +975,237 @@ export default {
|
||||
this.selectedMemberForActivities = member;
|
||||
this.showActivitiesModal = true;
|
||||
},
|
||||
openImageModal(imageUrl, memberId) {
|
||||
this.selectedImageUrl = imageUrl;
|
||||
this.selectedMemberId = memberId;
|
||||
async openImageModal(member, imageId = null) {
|
||||
if (!member) return;
|
||||
this.selectedMemberForImages = member;
|
||||
this.selectedMemberId = member.id;
|
||||
const images = Array.isArray(member.images) ? [...member.images] : [];
|
||||
await Promise.allSettled(images.map(image => this.ensureImageObjectUrl(image)));
|
||||
this.selectedMemberImages = images;
|
||||
this.selectedImageId = imageId || member.primaryImageId || (this.selectedMemberImages[0]?.id ?? null);
|
||||
this.showImageModal = true;
|
||||
},
|
||||
closeImageModal() {
|
||||
this.showImageModal = false;
|
||||
this.selectedImageUrl = null;
|
||||
this.selectedMemberId = null;
|
||||
this.selectedMemberImages = [];
|
||||
this.selectedImageId = null;
|
||||
this.selectedMemberForImages = null;
|
||||
},
|
||||
async handleRotate(event) {
|
||||
const { direction, memberId } = event;
|
||||
if (!memberId) return;
|
||||
|
||||
const { direction, memberId, imageId } = event || {};
|
||||
if (!memberId || !imageId) return;
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(`/clubmembers/rotate-image/${this.currentClub}/${memberId}`, {
|
||||
direction: direction
|
||||
const response = await apiClient.post(`/clubmembers/rotate-image/${this.currentClub}/${memberId}/${imageId}`, {
|
||||
direction
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
// Reload the member's image to show the rotated version
|
||||
const member = this.members.find(m => m.id === memberId);
|
||||
if (member) {
|
||||
await this.reloadMemberImage(member);
|
||||
// Update the modal image URL
|
||||
if (member.imageUrl) {
|
||||
this.selectedImageUrl = member.imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const member = this.members.find(m => m.id === memberId);
|
||||
|
||||
if (response.data?.success && member) {
|
||||
await this.applyMemberImageUpdate(member, response.data);
|
||||
this.showInfo('Erfolg', getSafeMessage(response.data.message, 'Bild wurde aktualisiert'), '', 'success');
|
||||
} else {
|
||||
this.showInfo('Fehler', 'Fehler beim Drehen des Bildes', getSafeErrorMessage(error), 'error');
|
||||
const msg = getSafeErrorMessage(response.data?.error || null, 'Fehler beim Drehen des Bildes');
|
||||
this.showInfo('Fehler', msg, '', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Drehen des Bildes:', error);
|
||||
this.showInfo('Fehler', 'Fehler beim Drehen des Bildes', getSafeErrorMessage(error), 'error');
|
||||
this.showInfo('Fehler', getSafeErrorMessage(error, 'Fehler beim Drehen des Bildes'), '', 'error');
|
||||
}
|
||||
},
|
||||
async loadMemberImage(member) {
|
||||
async handleDeleteImage(event) {
|
||||
const { memberId, imageId } = event || {};
|
||||
if (!memberId || !imageId) return;
|
||||
const member = this.members.find(m => m.id === memberId);
|
||||
if (!member) return;
|
||||
|
||||
const confirmed = await this.showConfirm(
|
||||
'Bild löschen',
|
||||
'Möchten Sie dieses Bild wirklich löschen?',
|
||||
'',
|
||||
'danger'
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/clubmembers/image/${this.currentClub}/${member.id}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const imageUrl = URL.createObjectURL(response.data);
|
||||
member.imageUrl = imageUrl;
|
||||
const response = await apiClient.delete(`/clubmembers/image/${this.currentClub}/${memberId}/${imageId}`);
|
||||
if (response.data?.success) {
|
||||
this.applyMemberImageUpdate(member, response.data);
|
||||
this.showInfo('Erfolg', 'Bild wurde gelöscht.', '', 'success');
|
||||
} else {
|
||||
const msg = getSafeMessage(response.data?.error, 'Bild konnte nicht gelöscht werden.');
|
||||
this.showInfo('Fehler', msg, '', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
// Kein Alert - es ist normal, dass nicht alle Mitglieder Bilder haben
|
||||
member.imageUrl = null;
|
||||
console.error('Fehler beim Löschen des Bildes:', error);
|
||||
this.showInfo('Fehler', getSafeErrorMessage(error, 'Bild konnte nicht gelöscht werden'), '', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async reloadMemberImage(member) {
|
||||
async handleSetPrimaryImage(event) {
|
||||
const { memberId, imageId } = event || {};
|
||||
if (!memberId || !imageId) return;
|
||||
const member = this.members.find(m => m.id === memberId);
|
||||
if (!member) return;
|
||||
|
||||
try {
|
||||
// Revoke old blob URL to free memory
|
||||
if (member.imageUrl) {
|
||||
URL.revokeObjectURL(member.imageUrl);
|
||||
const response = await apiClient.post(`/clubmembers/image/${this.currentClub}/${memberId}/${imageId}/primary`);
|
||||
if (response.data?.success) {
|
||||
this.applyMemberImageUpdate(member, response.data);
|
||||
this.showInfo('Erfolg', 'Hauptbild wurde aktualisiert.', '', 'success');
|
||||
} else {
|
||||
const msg = getSafeMessage(response.data?.error, 'Hauptbild konnte nicht gesetzt werden.');
|
||||
this.showInfo('Fehler', msg, '', 'error');
|
||||
}
|
||||
|
||||
const response = await apiClient.get(`/clubmembers/image/${this.currentClub}/${member.id}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
// Create new blob URL with timestamp for cache busting
|
||||
const imageUrl = URL.createObjectURL(response.data);
|
||||
member.imageUrl = imageUrl;
|
||||
|
||||
// Also update selectedImageUrl if this is the currently viewed member
|
||||
if (member.id === this.selectedMemberId) {
|
||||
this.selectedImageUrl = imageUrl;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Neuladen des Bildes:', error);
|
||||
console.error('Fehler beim Setzen des Hauptbildes:', error);
|
||||
this.showInfo('Fehler', getSafeErrorMessage(error, 'Hauptbild konnte nicht gesetzt werden'), '', 'error');
|
||||
}
|
||||
},
|
||||
async handleUploadImages(event) {
|
||||
const { memberId, files } = event || {};
|
||||
if (!memberId || !files || files.length === 0) return;
|
||||
const member = this.members.find(m => m.id === memberId);
|
||||
if (!member) return;
|
||||
|
||||
const fileArray = Array.from(files);
|
||||
for (let index = 0; index < fileArray.length; index += 1) {
|
||||
const file = fileArray[index];
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
const shouldBePrimary = (!member.images || member.images.length === 0) && index === 0;
|
||||
if (shouldBePrimary) {
|
||||
formData.append('makePrimary', 'true');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(`/clubmembers/image/${this.currentClub}/${memberId}`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
if (response.data?.success) {
|
||||
await this.applyMemberImageUpdate(member, response.data);
|
||||
} else {
|
||||
const msg = getSafeMessage(response.data?.error, 'Bild konnte nicht hochgeladen werden.');
|
||||
this.showInfo('Fehler', msg, '', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Hochladen des Bildes:', error);
|
||||
this.showInfo('Fehler', getSafeErrorMessage(error, 'Bild konnte nicht hochgeladen werden'), '', 'error');
|
||||
}
|
||||
}
|
||||
},
|
||||
async applyMemberImageUpdate(member, payload) {
|
||||
if (!member || !payload) return;
|
||||
const images = Array.isArray(payload.images) ? payload.images.map(image => this.createImageObject(image)) : [];
|
||||
member.images = images;
|
||||
member.primaryImageId = payload.primaryImageId || (images[0]?.id ?? null);
|
||||
member.primaryImageUrl = null;
|
||||
member.imageUrl = null;
|
||||
member.latestImageId = this.getLatestImageId(images);
|
||||
member.latestImageUrl = null;
|
||||
member.hasImage = images.length > 0;
|
||||
|
||||
await this.prefetchMemberPrimaryImage(member);
|
||||
await this.prefetchMemberLatestImage(member);
|
||||
|
||||
if (this.selectedMemberForImages && this.selectedMemberForImages.id === member.id) {
|
||||
this.selectedMemberForImages.images = images;
|
||||
await Promise.allSettled(images.map(image => this.ensureImageObjectUrl(image)));
|
||||
this.selectedMemberImages = [...images];
|
||||
if (!this.selectedImageId || !images.some(img => img.id === this.selectedImageId)) {
|
||||
this.selectedImageId = member.primaryImageId || (images[0]?.id ?? null);
|
||||
}
|
||||
}
|
||||
},
|
||||
applyBackendBaseUrlToMember(member) {
|
||||
if (!member) return member;
|
||||
const cloned = { ...member };
|
||||
cloned.images = Array.isArray(member.images) ? member.images.map(image => this.createImageObject(image)) : [];
|
||||
cloned.primaryImageId = member.primaryImageId || (cloned.images[0]?.id ?? null);
|
||||
cloned.primaryImageUrl = null;
|
||||
cloned.imageUrl = null;
|
||||
cloned.latestImageId = this.getLatestImageId(cloned.images);
|
||||
cloned.latestImageUrl = null;
|
||||
return cloned;
|
||||
},
|
||||
createImageObject(image) {
|
||||
const apiPath = (image?.url || '').replace(/^\/?api\//i, '').replace(/^\//, '');
|
||||
return {
|
||||
...image,
|
||||
apiPath,
|
||||
objectUrl: image?.objectUrl || null,
|
||||
url: image?.objectUrl || null
|
||||
};
|
||||
},
|
||||
async prefetchMemberPrimaryImage(member) {
|
||||
if (!member || !member.primaryImageId) {
|
||||
member.primaryImageUrl = null;
|
||||
member.imageUrl = null;
|
||||
return;
|
||||
}
|
||||
const primaryImage = Array.isArray(member.images)
|
||||
? member.images.find(img => img.id === member.primaryImageId) || member.images[0]
|
||||
: null;
|
||||
if (!primaryImage) {
|
||||
member.primaryImageUrl = null;
|
||||
member.imageUrl = null;
|
||||
return;
|
||||
}
|
||||
const objectUrl = await this.ensureImageObjectUrl(primaryImage);
|
||||
member.primaryImageUrl = objectUrl;
|
||||
member.imageUrl = objectUrl;
|
||||
},
|
||||
async prefetchMemberLatestImage(member) {
|
||||
if (!member || !member.latestImageId) {
|
||||
member.latestImageUrl = member.primaryImageUrl || null;
|
||||
return;
|
||||
}
|
||||
const latestImage = Array.isArray(member.images)
|
||||
? member.images.find(img => img.id === member.latestImageId) || member.images[member.images.length - 1]
|
||||
: null;
|
||||
if (!latestImage) {
|
||||
member.latestImageUrl = member.primaryImageUrl || null;
|
||||
return;
|
||||
}
|
||||
const objectUrl = await this.ensureImageObjectUrl(latestImage);
|
||||
member.latestImageUrl = objectUrl || member.primaryImageUrl || null;
|
||||
},
|
||||
getLatestImageId(images) {
|
||||
if (!Array.isArray(images) || images.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return images
|
||||
.filter(image => typeof image.id === 'number' || typeof image.id === 'string')
|
||||
.map(image => Number(image.id))
|
||||
.filter(id => !Number.isNaN(id))
|
||||
.sort((a, b) => a - b)
|
||||
.pop() || images[images.length - 1].id;
|
||||
},
|
||||
async ensureImageObjectUrl(image) {
|
||||
if (!image || image.objectUrl) {
|
||||
return image?.objectUrl || null;
|
||||
}
|
||||
if (!image.apiPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const response = await apiClient.get(image.apiPath, { responseType: 'blob' });
|
||||
const objectUrl = URL.createObjectURL(response.data);
|
||||
image.objectUrl = objectUrl;
|
||||
image.url = objectUrl;
|
||||
return objectUrl;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des Bildes:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
onGalleryClosed() {
|
||||
this.closeImageModal();
|
||||
},
|
||||
async createPhoneList() {
|
||||
const activeMembers = this.members.filter(member => member.active && !member.testMembership);
|
||||
@@ -1746,4 +1921,23 @@ table td {
|
||||
margin-right: 0.25rem;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.member-image-gallery {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.member-image-thumb-small {
|
||||
max-width: 48px;
|
||||
max-height: 48px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user