update
This commit is contained in:
parent
af9fe48584
commit
134955c829
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@ -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,15 +61,12 @@ 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
|
||||
const canInvite = isLeader && !adminMode
|
||||
const canAddDirect= adminMode
|
||||
const isLeader = currentUserSteamId === team.leader
|
||||
const canManage = adminMode || isLeader
|
||||
const canInvite = isLeader && !adminMode
|
||||
const canAddDirect = adminMode
|
||||
|
||||
const isDraggingRef = useRef(false)
|
||||
const [pendingRemote, setPendingRemote] = useState<{
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user