Enhance member gallery functionality with latest image retrieval and JSON format support

This commit improves the member gallery feature by allowing users to request the latest member images and receive member information in JSON format. The backend has been updated to handle "latest" as a valid imageId, ensuring the most recent image is fetched. Additionally, the frontend has been modified to support displaying member details in an interactive gallery format, enhancing user experience and providing more flexibility in how member information is presented.
This commit is contained in:
Torsten Schulz (local)
2025-11-12 11:58:37 +01:00
parent 8ef4e1dc9d
commit 1f20737721
4 changed files with 494 additions and 23 deletions

View File

@@ -504,7 +504,7 @@
<!-- Mitglieder-Galerie Dialog -->
<BaseDialog
v-model="showGalleryDialog"
title="Mitglieder-Galerie"
:title="date && date !== 'new' ? 'Mitglieder-Galerie - Klicken Sie auf ein Bild, um als Teilnehmer hinzuzufügen' : 'Mitglieder-Galerie'"
size="large"
:close-on-overlay="true"
@close="closeGalleryDialog"
@@ -512,18 +512,36 @@
<div class="gallery-dialog-content">
<div class="gallery-controls">
<label for="gallery-size">Bildgröße:</label>
<select id="gallery-size" v-model="gallerySize" @change="regenerateGallery" :disabled="galleryLoading">
<select id="gallery-size" v-model="gallerySize" @change="loadGalleryMembers" :disabled="galleryLoading">
<option :value="100">100x100 px</option>
<option :value="150">150x150 px</option>
<option :value="200">200x200 px</option>
</select>
</div>
<div v-if="galleryLoading" class="gallery-loading">Galerie wird erstellt</div>
<div v-else-if="galleryImageUrl" class="gallery-image-wrapper">
<img :src="galleryImageUrl" alt="Mitglieder-Galerie" class="gallery-dialog-image">
<div v-if="galleryLoading" class="gallery-loading">Galerie wird geladen</div>
<div v-else-if="galleryMembers.length > 0" class="gallery-members-grid" :style="{ gridTemplateColumns: 'repeat(auto-fill, ' + gallerySize + 'px)' }">
<div
v-for="member in galleryMembers"
:key="member.memberId"
class="gallery-member-item"
:class="{ 'is-participant': date && date !== 'new' && isParticipant(member.memberId) }"
:style="{ width: gallerySize + 'px', minWidth: gallerySize + 'px', height: gallerySize + 'px' }"
@click="handleGalleryMemberClick(member)"
>
<img
v-if="member.imageUrl"
:src="member.imageUrl"
:alt="member.fullName"
class="gallery-member-image"
@error="handleImageError(member)"
@load="handleImageLoad(member)"
/>
<div v-else class="gallery-member-placeholder">Kein Bild</div>
<div class="gallery-member-name">{{ member.fullName }}</div>
</div>
</div>
<div v-else class="gallery-error">
{{ galleryError || 'Keine Galerie verfügbar.' }}
{{ galleryError || 'Keine Mitglieder mit Bildern gefunden.' }}
</div>
</div>
</BaseDialog>
@@ -676,6 +694,7 @@ export default {
galleryImageUrl: null,
galleryError: '',
gallerySize: 200,
galleryMembers: [],
editShowDropdown: false,
editSearchResults: [],
editSearchForId: null,
@@ -799,32 +818,108 @@ export default {
return;
}
this.showGalleryDialog = true;
await this.loadGallery();
await this.loadGalleryMembers();
},
async loadGallery() {
updateGallerySizeBasedOnCount() {
const count = this.galleryMembers.length;
if (count < 11) {
this.gallerySize = 200;
} else if (count < 22) {
this.gallerySize = 150;
} else {
this.gallerySize = 100;
}
},
async loadGalleryMembers() {
if (!this.currentClub || this.galleryLoading) {
return;
}
this.galleryLoading = true;
this.galleryError = '';
this.galleryImageUrl = null;
this.revokeGalleryImage();
try {
const response = await apiClient.get(`/clubmembers/gallery/${this.currentClub}?size=${this.gallerySize}`, { responseType: 'blob' });
this.revokeGalleryImage();
this.galleryImageUrl = URL.createObjectURL(response.data);
const response = await apiClient.get(`/clubmembers/gallery/${this.currentClub}?format=json&size=${this.gallerySize}`);
const members = response.data.members || [];
// Setze Größe basierend auf Anzahl der Mitglieder (vor dem Laden der Bilder)
this.galleryMembers = members; // Temporär setzen für count
this.updateGallerySizeBasedOnCount();
// Lade Bilder als Blobs und erstelle ObjectURLs
this.galleryMembers = await Promise.all(members.map(async (member) => {
try {
const imageUrl = await this.loadMemberImageAsBlob(member.memberId);
console.log(`[loadGalleryMembers] Member ${member.memberId} (${member.fullName}): imageUrl =`, imageUrl ? 'OK' : 'null');
return {
...member,
imageUrl: imageUrl || null
};
} catch (error) {
console.error(`[loadGalleryMembers] Fehler beim Laden des Bildes für Mitglied ${member.memberId}:`, error);
return {
...member,
imageUrl: null
};
}
}));
console.log('[loadGalleryMembers] Geladene Mitglieder:', this.galleryMembers.map(m => ({ id: m.memberId, name: m.fullName, hasImage: !!m.imageUrl })));
} catch (error) {
console.error('Fehler beim Erstellen der Mitglieder-Galerie:', error);
this.galleryError = error?.response?.data?.error || 'Galerie konnte nicht erstellt werden.';
this.showInfo('Fehler', 'Galerie konnte nicht erstellt werden.', this.galleryError, 'error');
console.error('Fehler beim Laden der Galerie:', error);
this.galleryError = error?.response?.data?.error || 'Galerie konnte nicht geladen werden.';
this.galleryMembers = [];
} finally {
this.galleryLoading = false;
}
},
async loadMemberImageAsBlob(memberId) {
try {
const response = await apiClient.get(`/clubmembers/image/${this.currentClub}/${memberId}`, { responseType: 'blob' });
if (!response.data || response.data.size === 0) {
console.warn(`[loadMemberImageAsBlob] Leeres Blob für Mitglied ${memberId}`);
return null;
}
const objectUrl = URL.createObjectURL(response.data);
console.log(`[loadMemberImageAsBlob] ObjectURL erstellt für Mitglied ${memberId}:`, objectUrl.substring(0, 50) + '...');
return objectUrl;
} catch (error) {
// 404 ist OK - Mitglied hat kein Bild
if (error?.response?.status === 404) {
console.log(`[loadMemberImageAsBlob] Kein Bild für Mitglied ${memberId} (404)`);
return null;
}
console.error(`[loadMemberImageAsBlob] Fehler beim Laden des Bildes für Mitglied ${memberId}:`, error);
return null;
}
},
async regenerateGallery() {
await this.loadGallery();
await this.loadGalleryMembers();
},
closeGalleryDialog() {
this.showGalleryDialog = false;
this.revokeGalleryImage();
// Revoke all object URLs
this.galleryMembers.forEach(member => {
if (member.imageUrl) {
URL.revokeObjectURL(member.imageUrl);
}
});
this.galleryMembers = [];
},
async handleGalleryMemberClick(member) {
if (!this.date || this.date === 'new') {
return;
}
console.log('[handleGalleryMemberClick] Clicked member:', member);
await this.toggleParticipant(member.memberId);
},
handleImageError(member) {
console.error(`[handleImageError] Bild konnte nicht geladen werden für Mitglied ${member.memberId} (${member.fullName}), URL:`, member.imageUrl);
// Setze imageUrl auf null, damit der Placeholder angezeigt wird
member.imageUrl = null;
},
handleImageLoad(member) {
console.log(`[handleImageLoad] Bild erfolgreich geladen für Mitglied ${member.memberId} (${member.fullName})`);
},
hasActivityVisual(pa) {
@@ -2510,6 +2605,8 @@ h3 {
gap: 12px;
background: #f9f9f9;
flex-shrink: 0;
position: relative;
z-index: 10;
}
.gallery-controls label {
@@ -2557,6 +2654,96 @@ h3 {
color: var(--text-color, #333);
}
.gallery-members-grid {
display: grid;
grid-template-columns: repeat(auto-fill, max-content);
gap: 0;
padding: 0;
width: 100%;
position: relative;
z-index: 1;
align-items: start;
box-sizing: border-box;
justify-items: start;
}
.gallery-member-item {
display: block;
cursor: pointer;
padding: 0;
border: none;
border-radius: 0;
transition: all 0.2s ease;
margin: 0;
box-sizing: border-box;
overflow: hidden;
position: relative;
}
.gallery-member-item:hover {
background-color: transparent;
}
.gallery-member-item.is-participant {
background-color: transparent;
border: none;
}
.gallery-member-image {
object-fit: cover;
border-radius: 0;
margin: 0;
pointer-events: none; /* Prevent image from blocking clicks on parent */
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
max-width: 100%;
max-height: 100%;
border: none !important;
}
.gallery-member-name {
font-size: 12px;
text-align: center;
color: #ff6b6b;
font-weight: 500;
margin: 0;
padding: 4px 8px;
position: absolute;
bottom: 0;
left: 0;
right: 0;
width: 100%;
box-sizing: border-box;
background-color: rgba(0, 0, 0, 0.5) !important;
z-index: 10;
}
.gallery-member-item.is-participant .gallery-member-name {
color: #51cf66;
}
.gallery-member-placeholder {
display: flex;
align-items: center;
justify-content: center;
background-color: #f0f0f0;
border-radius: 0;
color: #999;
font-size: 12px;
margin: 0;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
}
.column:first-child {
flex: 1;
overflow: visible;

View File

@@ -5,6 +5,14 @@
<SeasonSelector v-model="selectedSeasonId" @season-change="onSeasonChange" :show-current-season="true" />
<button @click="openImportModal">Spielplanimport</button>
<button
v-if="playerSelectionDialog.match"
@click="openGalleryDialog"
class="btn-secondary"
:disabled="galleryLoading"
>
{{ galleryLoading ? 'Galerie wird geladen…' : 'Mitglieder-Galerie' }}
</button>
<div v-if="hoveredMatch && hoveredMatch.location" class="hover-info">
<p><strong>{{ hoveredMatch.location.name || 'N/A' }}</strong></p>
<p>{{ hoveredMatch.location.address || 'N/A' }}</p>
@@ -173,6 +181,46 @@
:details="confirmDialog.details" :type="confirmDialog.type" @confirm="handleConfirmResult(true)"
@cancel="handleConfirmResult(false)" />
<!-- Mitglieder-Galerie Dialog -->
<BaseDialog
v-model="showGalleryDialog"
title="Mitglieder-Galerie - Klicken Sie auf ein Bild, um als 'Bereit' zu markieren"
size="large"
:close-on-overlay="true"
@close="closeGalleryDialog"
>
<div class="gallery-dialog-content">
<div class="gallery-controls">
<label for="gallery-size">Bildgröße:</label>
<select id="gallery-size" v-model="gallerySize" @change="loadGalleryMembers" :disabled="galleryLoading">
<option :value="100">100x100 px</option>
<option :value="150">150x150 px</option>
<option :value="200">200x200 px</option>
</select>
</div>
<div v-if="galleryLoading" class="gallery-loading">Galerie wird geladen</div>
<div v-else-if="galleryMembers.length > 0" class="gallery-members-grid">
<div
v-for="member in galleryMembers"
:key="member.memberId"
class="gallery-member-item"
:class="{ 'is-ready': isMemberReady(member.memberId) }"
@click="toggleMemberReady(member)"
>
<img
:src="getMemberImageUrl(member.memberId)"
:alt="member.fullName"
class="gallery-member-image"
/>
<div class="gallery-member-name">{{ member.fullName }}</div>
</div>
</div>
<div v-else class="gallery-error">
{{ galleryError || 'Keine Mitglieder mit Bildern gefunden.' }}
</div>
</div>
</BaseDialog>
<!-- Player Selection Dialog -->
<BaseDialog
v-model="playerSelectionDialog.isOpen"
@@ -306,6 +354,12 @@ export default {
members: [],
loading: false
},
// Gallery Dialog
showGalleryDialog: false,
galleryLoading: false,
galleryMembers: [],
galleryError: '',
gallerySize: 200,
};
},
methods: {
@@ -425,7 +479,7 @@ export default {
member.hasPlayed = !member.hasPlayed;
},
async savePlayerSelection() {
async savePlayerSelection(closeDialog = true) {
const match = this.playerSelectionDialog.match;
if (!match) return;
@@ -439,26 +493,144 @@ export default {
.filter(m => m.hasPlayed)
.map(m => m.id);
console.log('[savePlayerSelection] Saving players:', { playersReady, playersPlanned, playersPlayed, matchId: match.id });
try {
await apiClient.patch(`/matches/${match.id}/players`, {
const response = await apiClient.patch(`/matches/${match.id}/players`, {
playersReady,
playersPlanned,
playersPlayed
});
console.log('[savePlayerSelection] API response:', response);
// Update local match data
match.playersReady = playersReady;
match.playersPlanned = playersPlanned;
match.playersPlayed = playersPlayed;
await this.showInfo('Erfolg', 'Spielerauswahl gespeichert', '', 'success');
this.closePlayerSelectionDialog();
// Update all members in the list to reflect the current state
this.playerSelectionDialog.members.forEach(m => {
m.isReady = playersReady.includes(m.id);
m.isPlanned = playersPlanned.includes(m.id);
m.hasPlayed = playersPlayed.includes(m.id);
});
if (closeDialog) {
await this.showInfo('Erfolg', 'Spielerauswahl gespeichert', '', 'success');
this.closePlayerSelectionDialog();
}
} catch (error) {
console.error('Error saving player selection:', error);
await this.showInfo('Fehler', 'Fehler beim Speichern der Spielerauswahl', '', 'error');
}
},
// Gallery Methods
async openGalleryDialog() {
if (!this.playerSelectionDialog.match) {
await this.showInfo('Hinweis', 'Bitte wählen Sie zuerst ein Spiel aus', '', 'info');
return;
}
this.showGalleryDialog = true;
await this.loadGalleryMembers();
},
async loadGalleryMembers() {
if (!this.currentClub || this.galleryLoading) {
return;
}
this.galleryLoading = true;
this.galleryError = '';
try {
const response = await apiClient.get(`/clubmembers/gallery/${this.currentClub}?format=json&size=${this.gallerySize}`);
this.galleryMembers = response.data.members || [];
} catch (error) {
console.error('Fehler beim Laden der Galerie:', error);
this.galleryError = error?.response?.data?.error || 'Galerie konnte nicht geladen werden.';
this.galleryMembers = [];
} finally {
this.galleryLoading = false;
}
},
closeGalleryDialog() {
this.showGalleryDialog = false;
this.galleryMembers = [];
},
getMemberImageUrl(memberId) {
const token = this.$store.getters.token;
const backendBaseUrl = import.meta.env.VITE_BACKEND || 'http://localhost:3005';
// Get primary image (latest will be handled by backend)
return `${backendBaseUrl}/api/clubmembers/image/${this.currentClub}/${memberId}?authcode=${token}`;
},
isMemberReady(memberId) {
const match = this.playerSelectionDialog.match;
if (!match || !match.playersReady) {
return false;
}
return match.playersReady.includes(memberId);
},
async toggleMemberReady(member) {
console.log('[toggleMemberReady] Called with member:', member);
const match = this.playerSelectionDialog.match;
if (!match) {
console.error('[toggleMemberReady] No match selected');
return;
}
console.log('[toggleMemberReady] Match:', match.id);
// Find member in playerSelectionDialog.members
let memberInList = this.playerSelectionDialog.members.find(m => m.id === member.memberId);
if (!memberInList) {
// Member not in list yet, add it
try {
const response = await apiClient.get(`/clubmembers/get/${this.currentClub}/true`);
const allMembers = response.data;
const foundMember = allMembers.find(m => m.id === member.memberId);
if (foundMember) {
// Check if member is already marked as ready
const isCurrentlyReady = match.playersReady?.includes(member.memberId) || false;
memberInList = {
...foundMember,
isReady: isCurrentlyReady,
isPlanned: match.playersPlanned?.includes(member.memberId) || false,
hasPlayed: match.playersPlayed?.includes(member.memberId) || false
};
this.playerSelectionDialog.members.push(memberInList);
} else {
console.error('Member not found:', member.memberId);
return;
}
} catch (error) {
console.error('Error loading member:', error);
return;
}
}
// Toggle ready status
const wasReady = memberInList.isReady;
memberInList.isReady = !wasReady;
// Update match.playersReady immediately
if (!match.playersReady) {
match.playersReady = [];
}
const index = match.playersReady.indexOf(member.memberId);
if (wasReady && index > -1) {
match.playersReady.splice(index, 1);
} else if (!wasReady && index === -1) {
match.playersReady.push(member.memberId);
}
// Auto-save (don't close dialog if it's open)
console.log('[toggleMemberReady] Calling savePlayerSelection');
await this.savePlayerSelection(false);
console.log('[toggleMemberReady] savePlayerSelection completed');
},
...mapActions(['openDialog']),
openImportModal() {
this.showImportModal = true;
@@ -1263,6 +1435,99 @@ li {
font-weight: 500;
}
/* Gallery Styles */
.gallery-dialog-content {
display: flex;
flex-direction: column;
padding: 0;
min-height: 60vh;
max-height: 70vh;
overflow: auto;
}
.gallery-controls {
width: 100%;
padding: 12px 16px;
border-bottom: 1px solid var(--border-color, #ddd);
display: flex;
align-items: center;
gap: 12px;
background: #f9f9f9;
flex-shrink: 0;
}
.gallery-controls label {
font-weight: 500;
color: var(--text-color, #333);
}
.gallery-controls select {
padding: 6px 12px;
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
font-size: 14px;
background: white;
cursor: pointer;
}
.gallery-controls select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.gallery-members-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 16px;
padding: 16px;
}
.gallery-member-item {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
padding: 8px;
border: 2px solid transparent;
border-radius: 8px;
transition: all 0.2s ease;
}
.gallery-member-item:hover {
background-color: #f0f0f0;
border-color: #007bff;
}
.gallery-member-item.is-ready {
border-color: #28a745;
background-color: #d4edda;
}
.gallery-member-image {
width: 100%;
height: auto;
aspect-ratio: 1;
object-fit: cover;
border-radius: 4px;
margin-bottom: 8px;
pointer-events: none; /* Prevent image from blocking clicks on parent */
}
.gallery-member-name {
font-size: 12px;
text-align: center;
color: var(--text-color, #333);
font-weight: 500;
}
.gallery-loading,
.gallery-error {
padding: 20px;
text-align: center;
font-size: 1rem;
color: var(--text-color, #333);
}
.checkbox-cell {
text-align: center;
width: 100px;