This commit is contained in:
Linrador 2025-08-11 22:02:48 +02:00
parent af9fe48584
commit 134955c829
5 changed files with 109 additions and 73 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -12,7 +12,7 @@ import InvitePlayersModal from './InvitePlayersModal'
import Modal from './Modal'
import { Player } from '../types/team'
import { AnimatePresence, motion } from 'framer-motion'
import { leaveTeam, reloadTeam, renameTeam, revokeInvitation } from '@/app/lib/sse-actions'
import { leaveTeam, reloadTeam, renameTeam } from '@/app/lib/sse-actions'
import Button from './Button'
import Image from 'next/image'
import TeamPremierRankBadge from './TeamPremierRankBadge'
@ -61,10 +61,7 @@ export default function TeamMemberView({
const team = teamProp ?? storeTeam
if (!team) return null
const RELEVANT: ReadonlySet<SSEEventType> = new Set([
...TEAM_EVENTS,
...SELF_EVENTS,
])
const RELEVANT: ReadonlySet<SSEEventType> = new Set([...TEAM_EVENTS, ...SELF_EVENTS])
const isLeader = currentUserSteamId === team.leader
const canManage = adminMode || isLeader
@ -78,7 +75,7 @@ export default function TeamMemberView({
invited: InvitedPlayer[]
} | null>(null)
const [remountKey, setRemountKey] = useState(0)
const { connect, disconnect, lastEvent, isConnected } = useSSEStore()
const { connect, lastEvent, isConnected } = useSSEStore()
const [activePlayers, setActivePlayers] = useState<Player[]>([])
const [inactivePlayers, setInactivePlayers] = useState<Player[]>([])
const [invitedPlayers, setInvitedPlayers] = useState<InvitedPlayer[]>([])
@ -89,26 +86,35 @@ export default function TeamMemberView({
const [editedName, setEditedName] = useState(team.name || '')
const [saveSuccess, setSaveSuccess] = useState(false)
// Upload-Progress für Teamlogo
const [isUploadingLogo, setIsUploadingLogo] = useState(false)
const [uploadPct, setUploadPct] = useState(0)
const R = 28
const S = 64
const CIRC = 2 * Math.PI * R
const dashOffset = CIRC - (uploadPct / 100) * CIRC
// 1) SSE-Verbindung sicherstellen
useEffect(() => {
if (!currentUserSteamId) return
if (!isConnected) connect(currentUserSteamId)
return () => {
// Falls du global verbunden bleiben willst: disconnect() hier weglassen
// disconnect()
}
}, [currentUserSteamId, connect, isConnected])
const eqByIds = (a: Player[], b: Player[]) => {
if (a.length !== b.length) return false
const aa = a.map(p=>p.steamId).join(',')
const bb = b.map(p=>p.steamId).join(',')
return aa === bb
}
useEffect(() => {
if (!team) return
const nextActive = (team.activePlayers ?? []).slice().sort((a,b)=>a.name.localeCompare(b.name))
const nextInactive = (team.inactivePlayers ?? []).slice().sort((a,b)=>a.name.localeCompare(b.name))
const nextInvited = Array.from(
new Map((team.invitedPlayers ?? []).map(p => [p.steamId, p])).values()
).sort((a,b)=>a.name.localeCompare(b.name))
// wenn nichts geändert: nichts tun (vermeidet Flicker)
const unchanged =
eqByIds(activePlayers, nextActive) &&
eqByIds(inactivePlayers, nextInactive) &&
@ -117,30 +123,23 @@ export default function TeamMemberView({
if (unchanged) return
if (!isDraggingRef.current) {
// 🔄 nicht am Ziehen → direkt übernehmen
setActivePlayers(nextActive)
setInactivePlayers(nextInactive)
setInvitedPlayers(nextInvited)
} else {
// ✋ während Drag → puffern
setPendingRemote({ active: nextActive, inactive: nextInactive, invited: nextInvited })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [team?.id, team?.name, team?.logo, team?.leader,
team?.activePlayers, team?.inactivePlayers, team?.invitedPlayers])
}, [team?.id, team?.name, team?.logo, team?.leader, team?.activePlayers, team?.inactivePlayers, team?.invitedPlayers])
// 2) Auf ALLE relevanten Events reagieren
// 2) Relevante SSE-Events -> Team neu laden
useEffect(() => {
if (!lastEvent || !team?.id) return
// Typ-Safety: nur kanonische Events weiter verarbeiten
if (!isSseEventType(lastEvent.type)) return
const evtType: SSEEventType = lastEvent.type
if (!RELEVANT.has(evtType)) return
if (!RELEVANT.has(lastEvent.type)) return
const payload = lastEvent.payload ?? {}
if (payload.teamId !== team.id) return
if (payload.teamId && payload.teamId !== team.id) return
;(async () => {
const updated = await reloadTeam(team.id)
@ -155,12 +154,9 @@ export default function TeamMemberView({
).sort((a,b)=>a.name.localeCompare(b.name))
if (isDraggingRef.current) {
// während Drag: puffern, kein Remount
setPendingRemote({ active: nextActive, inactive: nextInactive, invited: nextInvited })
return
}
// sofort in die lokalen DnD-Listen übernehmen + DnD intern neu aufbauen
setActivePlayers(nextActive)
setInactivePlayers(nextInactive)
setInvitedPlayers(nextInvited)
@ -168,16 +164,8 @@ export default function TeamMemberView({
})()
}, [lastEvent, team?.id, setTeam])
const eqByIds = (a: Player[], b: Player[]) => {
if (a.length !== b.length) return false
const aa = a.map(p=>p.steamId).join(',')
const bb = b.map(p=>p.steamId).join(',')
return aa === bb
}
const handleDragStart = (event: any) => {
const id = event.active.id
const id = event.active.id as string
const item =
activePlayers.find(p => p.steamId === id) ||
inactivePlayers.find(p => p.steamId === id)
@ -201,7 +189,6 @@ export default function TeamMemberView({
})
if (!res.ok) throw new Error('Update fehlgeschlagen')
// 👇 Fail-safe: eigenes Team sofort frisch holen & in den Store schieben
const updated = await reloadTeam(teamId)
if (updated) setTeam(updated)
} catch (err) {
@ -209,7 +196,6 @@ export default function TeamMemberView({
}
}
const handleDragEnd = async (event: any) => {
setActiveDragItem(null)
setIsDragging(false)
@ -217,7 +203,6 @@ export default function TeamMemberView({
const { active, over } = event
if (!over) {
// falls während des Drags Remote-Updates ankamen → jetzt anwenden
if (pendingRemote) {
setActivePlayers(pendingRemote.active)
setInactivePlayers(pendingRemote.inactive)
@ -235,14 +220,11 @@ export default function TeamMemberView({
inactivePlayers.find(p => p.steamId === activeId)
if (!movingItem) return
// Woher kam die Karte?
const wasInActive = activePlayers.some(p => p.steamId === activeId)
// Wohin wurde gedroppt?
const dropToActive =
overId === 'active' || activePlayers.some(p => p.steamId === overId)
// 🚫 Selbe Zone -> nichts speichern, nur evtl. pendingRemote anwenden
// selbe Zone -> nichts speichern
if ((wasInActive && dropToActive) || (!wasInActive && !dropToActive)) {
if (pendingRemote) {
setActivePlayers(pendingRemote.active)
@ -253,7 +235,6 @@ export default function TeamMemberView({
return
}
// ── tatsächliche Zonen-Änderung ──────────────────────────────────
let nextActive = [...activePlayers]
let nextInactive = [...inactivePlayers]
@ -266,11 +247,9 @@ export default function TeamMemberView({
if (!nextInactive.some(p => p.steamId === activeId)) nextInactive.push(movingItem)
}
// deine UI sortiert eh alphabetisch → Reihenfolge innerhalb der Zone ist egal
nextActive.sort((a,b)=>a.name.localeCompare(b.name))
nextInactive.sort((a,b)=>a.name.localeCompare(b.name))
// 🔍 Sicherheit: Wenn sich durch die Operation nichts geändert hat → abbrechen
const noChange =
eqByIds(nextActive, activePlayers) && eqByIds(nextInactive, inactivePlayers)
if (noChange) {
@ -283,17 +262,14 @@ export default function TeamMemberView({
return
}
// ✅ Optimistisches UI
setActivePlayers(nextActive)
setInactivePlayers(nextInactive)
// 🔔 Server informieren
updateTeamMembers(team.id, nextActive, nextInactive).catch(console.error)
setSaveSuccess(true)
setTimeout(()=>setSaveSuccess(false), 3000)
// 📨 evtl. gepufferte Remote-Änderungen übernehmen
if (pendingRemote) {
const diff =
!eqByIds(pendingRemote.active, nextActive) ||
@ -308,7 +284,6 @@ export default function TeamMemberView({
}
}
// Wenn du von hier manuell reloaden willst, dann Store updaten (nicht lokalen State)
const handleReload = async () => {
const updated = await reloadTeam(team.id)
if (updated) setTeam(updated)
@ -348,6 +323,30 @@ export default function TeamMemberView({
}
}
// Upload mit Progress via XHR
async function uploadTeamLogo(file: File) {
return new Promise<void>((resolve, reject) => {
const formData = new FormData()
formData.append('logo', file)
formData.append('teamId', team!.id)
const xhr = new XMLHttpRequest()
xhr.open('POST', '/api/team/upload-logo') // ggf. anpassen
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
setUploadPct(Math.round((e.loaded / e.total) * 100))
} else {
setUploadPct((p) => (p < 90 ? p + 1 : p))
}
}
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error('Upload fehlgeschlagen')))
xhr.onerror = () => reject(new Error('Netzwerkfehler beim Upload'))
setIsUploadingLogo(true)
setUploadPct(0)
xhr.send(formData)
})
}
if (!adminMode && !currentUserSteamId) return null
const manageSteam: string = adminMode ? (team.leader ?? '') : currentUserSteamId
@ -392,7 +391,7 @@ export default function TeamMemberView({
<div className="relative group">
<div
className="relative w-16 h-16 rounded-full overflow-hidden border border-gray-300 dark:border-neutral-600 cursor-pointer"
onClick={() => canManage && document.getElementById('logoUpload')?.click()}
onClick={() => canManage && !isUploadingLogo && document.getElementById('logoUpload')?.click()}
>
<Image
src={team.logo ? `/assets/img/logos/${team.logo}` : `/assets/img/logos/cs2.webp`}
@ -400,17 +399,41 @@ export default function TeamMemberView({
fill
sizes="64px"
quality={75}
className="object-cover"
className={`object-cover ${isUploadingLogo ? 'opacity-70' : ''}`}
priority={false}
/>
{canManage && (
<div className="absolute inset-0 bg-black bg-opacity-50 text-white flex flex-col items-center justify-center text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity">
{/* Icon */}
{canManage && !isUploadingLogo && (
<div className="absolute inset-0 bg-black/50 text-white flex flex-col items-center justify-center text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity">
<svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 mb-1" viewBox="0 0 576 512" fill="currentColor">
<path d="M288 109.3L288 352c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-242.7-73.4 73.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l128-128c12.5-12.5 32.8-12.5 45.3 0l128 128c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L288 109.3zM64 352l128 0c0 35.3 28.7 64 64 64s64-28.7 64-64l128 0c35.3 0 64 28.7 64 64l0 32c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64l0-32c0-35.3 28.7-64 64-64zM432 456a24 24 0 1 0 0-48 24 24 0 1 0 0 48z"/>
</svg>
</div>
)}
{isUploadingLogo && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<svg width={S} height={S} viewBox={`0 0 ${S} ${S}`} className="absolute">
<circle
cx={S/2} cy={S/2} r={R}
stroke="rgba(255,255,255,0.35)"
strokeWidth="6"
fill="none"
/>
<circle
cx={S/2} cy={S/2} r={R}
stroke="#16a34a"
strokeWidth="6"
fill="none"
strokeLinecap="round"
strokeDasharray={CIRC}
strokeDashoffset={dashOffset}
style={{ transition: 'stroke-dashoffset 120ms linear' }}
/>
</svg>
<span className="text-[11px] font-semibold text-white drop-shadow">{uploadPct}%</span>
</div>
)}
</div>
{canManage && (
@ -422,13 +445,21 @@ export default function TeamMemberView({
onChange={async (e) => {
const file = e.target.files?.[0]
if (!file) return
const formData = new FormData()
formData.append('logo', file)
formData.append('teamId', team.id)
const res = await fetch('/api/team/upload-logo', { method: 'POST', body: formData })
if (res.ok) await handleReload()
else alert('Fehler beim Hochladen des Logos.')
try {
await uploadTeamLogo(file)
await handleReload()
} catch (err) {
console.error('Fehler beim Hochladen des Logos:', err)
alert('Fehler beim Hochladen des Logos.')
} finally {
setTimeout(() => {
setIsUploadingLogo(false)
setUploadPct(0)
}, 300)
e.currentTarget.value = ''
}
}}
disabled={isUploadingLogo}
/>
)}
</div>
@ -524,7 +555,12 @@ export default function TeamMemberView({
</div>
</div>
<DndContext key={`dnd-${team.id}-${remountKey}`} collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<DndContext
key={`dnd-${team.id}-${remountKey}`}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="space-y-8">
<DroppableZone id="active" label={`Aktive Spieler (${activePlayers.length} / 5)`} activeDragItem={activeDragItem} saveSuccess={saveSuccess}>
<SortableContext key={`sc-active-${remountKey}-${activePlayers.map(p=>p.steamId).join(',')}`} items={activePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
@ -580,10 +616,10 @@ export default function TeamMemberView({
rank={player.premierRank}
invitationId={(player as any).invitationId}
onKick={async (sid) => {
// 1) optimistisch entfernen
// optimistisch entfernen
setInvitedPlayers(list => list.filter(p => p.steamId !== sid))
try {
// 2) API: mit invitationId ODER Fallback teamId+steamId
// API: mit invitationId ODER Fallback teamId+steamId
await fetch('/api/user/invitations/revoke', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -595,10 +631,10 @@ export default function TeamMemberView({
})
} catch (e) {
console.error('Revoke fehlgeschlagen:', e)
// optional: bei Fehler wieder einfügen
// bei Fehler wieder einfügen
setInvitedPlayers(list => [...list, player].sort((a,b)=>a.name.localeCompare(b.name)))
} finally {
// 3) sicherheitshalber Team neu laden
// sicherheitshalber Team neu laden
const updated = await reloadTeam(team.id)
if (updated) setTeam(updated)
}

View File

@ -303,7 +303,7 @@ const config = {
"value": "prisma-client-js"
},
"output": {
"value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
"fromEnvVar": null
},
"config": {
@ -317,7 +317,7 @@ const config = {
}
],
"previewFeatures": [],
"sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"isCustomOutput": true
},
"relativeEnvPaths": {

View File

@ -304,7 +304,7 @@ const config = {
"value": "prisma-client-js"
},
"output": {
"value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
"fromEnvVar": null
},
"config": {
@ -318,7 +318,7 @@ const config = {
}
],
"previewFeatures": [],
"sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"isCustomOutput": true
},
"relativeEnvPaths": {