From 1f20737721d758b1ea8cf4d7ec6688e3b4257e8f Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 12 Nov 2025 11:58:37 +0100 Subject: [PATCH] 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. --- backend/controllers/memberController.js | 16 +- backend/services/memberService.js | 11 +- frontend/src/views/DiaryView.vue | 217 +++++++++++++++++-- frontend/src/views/ScheduleView.vue | 273 +++++++++++++++++++++++- 4 files changed, 494 insertions(+), 23 deletions(-) diff --git a/backend/controllers/memberController.js b/backend/controllers/memberController.js index 23b20cb..36ddbc1 100644 --- a/backend/controllers/memberController.js +++ b/backend/controllers/memberController.js @@ -59,7 +59,9 @@ const getMemberImage = async (req, res) => { try { const { clubId, memberId, imageId } = req.params; const { authcode: userToken } = req.headers; - const result = await MemberService.getMemberImage(userToken, clubId, memberId, imageId || null); + // Support "latest" as imageId to get the latest image + const actualImageId = imageId === 'latest' ? null : (imageId || null); + const result = await MemberService.getMemberImage(userToken, clubId, memberId, actualImageId); if (result.status === 200) { res.sendFile(result.imagePath); } else { @@ -121,8 +123,20 @@ const generateMemberGallery = async (req, res) => { const { clubId } = req.params; const { authcode: userToken } = req.headers; const size = parseInt(req.query.size) || 200; // Default: 200x200 + const format = req.query.format || 'image'; // 'image' or 'json' const result = await MemberService.generateMemberGallery(userToken, clubId, size); if (result.status === 200) { + if (format === 'json') { + // Return member information for interactive gallery + return res.status(200).json({ + members: result.galleryEntries.map(entry => ({ + memberId: entry.memberId, + firstName: entry.firstName, + lastName: entry.lastName, + fullName: entry.fullName + })) + }); + } res.setHeader('Content-Type', 'image/png'); res.setHeader('Cache-Control', 'no-store'); return res.status(200).send(result.buffer); diff --git a/backend/services/memberService.js b/backend/services/memberService.js index bf817a7..8f2da14 100644 --- a/backend/services/memberService.js +++ b/backend/services/memberService.js @@ -282,9 +282,10 @@ class MemberService { } if (!imageRecord) { + // Get latest image (highest sortOrder, then highest id) imageRecord = await MemberImage.findOne({ where: { memberId }, - order: [['sortOrder', 'ASC'], ['id', 'ASC']] + order: [['sortOrder', 'DESC'], ['id', 'DESC']] }); } @@ -1124,7 +1125,10 @@ class MemberService { galleryEntries.push({ filePath, - fullName: `${member.firstName || ''} ${member.lastName || ''}`.trim() || member.lastName || member.firstName || 'Unbekannt' + fullName: `${member.firstName || ''} ${member.lastName || ''}`.trim() || member.lastName || member.firstName || 'Unbekannt', + memberId: member.id, + firstName: member.firstName || '', + lastName: member.lastName || '' }); } @@ -1194,7 +1198,8 @@ class MemberService { return { status: 200, - buffer + buffer, + galleryEntries // Für interaktive Galerie }; } catch (error) { console.error('[generateMemberGallery] - Error:', error); diff --git a/frontend/src/views/DiaryView.vue b/frontend/src/views/DiaryView.vue index 49ff314..a254c63 100644 --- a/frontend/src/views/DiaryView.vue +++ b/frontend/src/views/DiaryView.vue @@ -504,7 +504,7 @@ - - @@ -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; diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue index d4d085d..f7602a5 100644 --- a/frontend/src/views/ScheduleView.vue +++ b/frontend/src/views/ScheduleView.vue @@ -5,6 +5,14 @@ +

{{ hoveredMatch.location.name || 'N/A' }}

{{ hoveredMatch.location.address || 'N/A' }}

@@ -173,6 +181,46 @@ :details="confirmDialog.details" :type="confirmDialog.type" @confirm="handleConfirmResult(true)" @cancel="handleConfirmResult(false)" /> + + + + + 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;