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;