819 lines
31 KiB
TypeScript
819 lines
31 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useRef, useState } from 'react'
|
||
import { DndContext, closestCenter, DragOverlay } from '@dnd-kit/core'
|
||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||
import { DroppableZone } from './DroppableZone'
|
||
import MiniCard from './MiniCard'
|
||
import MiniCardDummy from './MiniCardDummy'
|
||
import SortableMiniCard from './SortableMiniCard'
|
||
import LeaveTeamModal from './LeaveTeamModal'
|
||
import InvitePlayersModal from './InvitePlayersModal'
|
||
import Modal from './Modal'
|
||
import { Player } from '../types/team'
|
||
import { AnimatePresence, motion } from 'framer-motion'
|
||
import { leaveTeam, reloadTeam, renameTeam } from '@/app/lib/sse-actions'
|
||
import Button from './Button'
|
||
import Image from 'next/image'
|
||
import TeamPremierRankBadge from './TeamPremierRankBadge'
|
||
import Link from 'next/link'
|
||
import { Team } from '../types/team'
|
||
import { useTeamStore } from '../lib/stores'
|
||
import { useSSEStore } from '@/app/lib/useSSEStore'
|
||
import {
|
||
TEAM_EVENTS,
|
||
SELF_EVENTS,
|
||
isSseEventType,
|
||
type SSEEventType,
|
||
} from '@/app/lib/sseEvents'
|
||
|
||
type Props = {
|
||
team?: Team
|
||
activeDragItem: Player | null
|
||
isDragging: boolean
|
||
showLeaveModal: boolean
|
||
showInviteModal: boolean
|
||
currentUserSteamId: string
|
||
setShowLeaveModal: (v: boolean) => void
|
||
setShowInviteModal: (v: boolean) => void
|
||
setActiveDragItem: (item: Player | null) => void
|
||
setIsDragging: (v: boolean) => void
|
||
adminMode?: boolean
|
||
}
|
||
|
||
type InvitedPlayer = Player & { invitationId?: string }
|
||
|
||
export default function TeamMemberView({
|
||
team: teamProp,
|
||
activeDragItem,
|
||
isDragging,
|
||
showLeaveModal,
|
||
showInviteModal,
|
||
currentUserSteamId,
|
||
setShowLeaveModal,
|
||
setShowInviteModal,
|
||
setActiveDragItem,
|
||
setIsDragging,
|
||
adminMode = false,
|
||
}: Props) {
|
||
const { team: storeTeam, setTeam } = useTeamStore()
|
||
const team = teamProp ?? storeTeam
|
||
if (!team) return null
|
||
|
||
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 isDraggingRef = useRef(false)
|
||
const [pendingRemote, setPendingRemote] = useState<{
|
||
active: Player[]
|
||
inactive: Player[]
|
||
invited: InvitedPlayer[]
|
||
} | null>(null)
|
||
const [remountKey, setRemountKey] = useState(0)
|
||
const { connect, lastEvent, isConnected } = useSSEStore()
|
||
const [activePlayers, setActivePlayers] = useState<Player[]>([])
|
||
const [inactivePlayers, setInactivePlayers] = useState<Player[]>([])
|
||
const [invitedPlayers, setInvitedPlayers] = useState<InvitedPlayer[]>([])
|
||
const [kickCandidate, setKickCandidate] = useState<Player | null>(null)
|
||
const [promoteCandidate, setPromoteCandidate] = useState<Player | null>(null)
|
||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||
const [isEditingName, setIsEditingName] = useState(false)
|
||
const [editedName, setEditedName] = useState(team.name || '')
|
||
const [saveSuccess, setSaveSuccess] = useState(false)
|
||
|
||
// Cache-Busting fürs Logo
|
||
const [logoVersion, setLogoVersion] = useState<number | null>(null)
|
||
|
||
// Upload-Progress
|
||
const [isUploadingLogo, setIsUploadingLogo] = useState(false)
|
||
const [uploadPct, setUploadPct] = useState(0)
|
||
const R = 28, S = 64, CIRC = 2 * Math.PI * R
|
||
const dashOffset = CIRC - (uploadPct / 100) * CIRC
|
||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||
const isClickable = canManage && !isUploadingLogo
|
||
|
||
// SSE-Verbindung
|
||
useEffect(() => {
|
||
if (!currentUserSteamId) return
|
||
if (!isConnected) connect(currentUserSteamId)
|
||
}, [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
|
||
}
|
||
|
||
// Team-Listen lokal synchronisieren
|
||
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))
|
||
|
||
const unchanged =
|
||
eqByIds(activePlayers, nextActive) &&
|
||
eqByIds(inactivePlayers, nextInactive) &&
|
||
eqByIds(invitedPlayers, nextInvited)
|
||
|
||
if (unchanged) return
|
||
|
||
if (!isDraggingRef.current) {
|
||
setActivePlayers(nextActive)
|
||
setInactivePlayers(nextInactive)
|
||
setInvitedPlayers(nextInvited)
|
||
} else {
|
||
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])
|
||
|
||
// Relevante SSE-Events
|
||
useEffect(() => {
|
||
if (!lastEvent || !team?.id) return
|
||
if (!isSseEventType(lastEvent.type)) return
|
||
const payload = lastEvent.payload ?? {}
|
||
|
||
// ► Spezialfall: nur Logo aktualisieren (ohne komplettes Reload)
|
||
if (lastEvent.type === 'team-logo-updated') {
|
||
if (payload.teamId && payload.teamId !== team.id) return
|
||
|
||
const current = useTeamStore.getState().team
|
||
if (payload?.filename && current) {
|
||
setTeam({ ...current, logo: payload.filename })
|
||
}
|
||
if (payload?.version) setLogoVersion(payload.version)
|
||
return
|
||
}
|
||
|
||
// andere Team/Self-Events
|
||
if (!RELEVANT.has(lastEvent.type)) return
|
||
if (payload.teamId && payload.teamId !== team.id) return
|
||
|
||
;(async () => {
|
||
const updated = await reloadTeam(team.id)
|
||
if (!updated) return
|
||
setTeam(updated)
|
||
setEditedName(updated.name || '')
|
||
|
||
const nextActive = (updated.activePlayers ?? []).slice().sort((a,b)=>a.name.localeCompare(b.name))
|
||
const nextInactive = (updated.inactivePlayers ?? []).slice().sort((a,b)=>a.name.localeCompare(b.name))
|
||
const nextInvited = Array.from(new Map((updated.invitedPlayers ?? []).map(p => [p.steamId, p])).values())
|
||
.sort((a,b)=>a.name.localeCompare(b.name))
|
||
|
||
if (isDraggingRef.current) {
|
||
setPendingRemote({ active: nextActive, inactive: nextInactive, invited: nextInvited })
|
||
return
|
||
}
|
||
setActivePlayers(nextActive)
|
||
setInactivePlayers(nextInactive)
|
||
setInvitedPlayers(nextInvited)
|
||
setRemountKey(k => k + 1)
|
||
})()
|
||
}, [lastEvent, team?.id, setTeam])
|
||
|
||
const handleDragStart = (event: any) => {
|
||
const id = event.active.id as string
|
||
const item =
|
||
activePlayers.find(p => p.steamId === id) ||
|
||
inactivePlayers.find(p => p.steamId === id)
|
||
if (item) {
|
||
setActiveDragItem(item)
|
||
setIsDragging(true)
|
||
isDraggingRef.current = true
|
||
}
|
||
}
|
||
|
||
const updateTeamMembers = async (teamId: string, active: Player[], inactive: Player[]) => {
|
||
try {
|
||
const res = await fetch('/api/team/update-players', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
teamId,
|
||
activePlayers: active.map(p => p.steamId),
|
||
inactivePlayers: inactive.map(p => p.steamId),
|
||
}),
|
||
})
|
||
if (!res.ok) throw new Error('Update fehlgeschlagen')
|
||
|
||
const updated = await reloadTeam(teamId)
|
||
if (updated) setTeam(updated)
|
||
} catch (err) {
|
||
console.error('Fehler beim Aktualisieren:', err)
|
||
}
|
||
}
|
||
|
||
const handleDragEnd = async (event: any) => {
|
||
setActiveDragItem(null)
|
||
setIsDragging(false)
|
||
isDraggingRef.current = false
|
||
|
||
const { active, over } = event
|
||
if (!over) {
|
||
if (pendingRemote) {
|
||
setActivePlayers(pendingRemote.active)
|
||
setInactivePlayers(pendingRemote.inactive)
|
||
setInvitedPlayers(pendingRemote.invited)
|
||
setPendingRemote(null)
|
||
}
|
||
return
|
||
}
|
||
|
||
const activeId = String(active.id)
|
||
const overId = String(over.id)
|
||
|
||
const movingItem =
|
||
activePlayers.find(p => p.steamId === activeId) ||
|
||
inactivePlayers.find(p => p.steamId === activeId)
|
||
if (!movingItem) return
|
||
|
||
const wasInActive = activePlayers.some(p => p.steamId === activeId)
|
||
const dropToActive =
|
||
overId === 'active' || activePlayers.some(p => p.steamId === overId)
|
||
|
||
if ((wasInActive && dropToActive) || (!wasInActive && !dropToActive)) {
|
||
if (pendingRemote) {
|
||
setActivePlayers(pendingRemote.active)
|
||
setInactivePlayers(pendingRemote.inactive)
|
||
setInvitedPlayers(pendingRemote.invited)
|
||
setPendingRemote(null)
|
||
}
|
||
return
|
||
}
|
||
|
||
let nextActive = [...activePlayers]
|
||
let nextInactive = [...inactivePlayers]
|
||
|
||
if (dropToActive) {
|
||
if (nextActive.length >= 5) return
|
||
nextInactive = nextInactive.filter(p => p.steamId !== activeId)
|
||
if (!nextActive.some(p => p.steamId === activeId)) nextActive.push(movingItem)
|
||
} else {
|
||
nextActive = nextActive.filter(p => p.steamId !== activeId)
|
||
if (!nextInactive.some(p => p.steamId === activeId)) nextInactive.push(movingItem)
|
||
}
|
||
|
||
nextActive.sort((a,b)=>a.name.localeCompare(b.name))
|
||
nextInactive.sort((a,b)=>a.name.localeCompare(b.name))
|
||
|
||
const noChange =
|
||
eqByIds(nextActive, activePlayers) && eqByIds(nextInactive, inactivePlayers)
|
||
if (noChange) {
|
||
if (pendingRemote) {
|
||
setActivePlayers(pendingRemote.active)
|
||
setInactivePlayers(pendingRemote.inactive)
|
||
setInvitedPlayers(pendingRemote.invited)
|
||
setPendingRemote(null)
|
||
}
|
||
return
|
||
}
|
||
|
||
setActivePlayers(nextActive)
|
||
setInactivePlayers(nextInactive)
|
||
|
||
updateTeamMembers(team.id, nextActive, nextInactive).catch(console.error)
|
||
|
||
setSaveSuccess(true)
|
||
setTimeout(()=>setSaveSuccess(false), 3000)
|
||
|
||
if (pendingRemote) {
|
||
const diff =
|
||
!eqByIds(pendingRemote.active, nextActive) ||
|
||
!eqByIds(pendingRemote.inactive, nextInactive) ||
|
||
!eqByIds(pendingRemote.invited, invitedPlayers)
|
||
if (diff) {
|
||
setActivePlayers(pendingRemote.active)
|
||
setInactivePlayers(pendingRemote.inactive)
|
||
setInvitedPlayers(pendingRemote.invited)
|
||
}
|
||
setPendingRemote(null)
|
||
}
|
||
}
|
||
|
||
const handleReload = async () => {
|
||
const updated = await reloadTeam(team.id)
|
||
if (updated) setTeam(updated)
|
||
}
|
||
|
||
const confirmKick = async () => {
|
||
if (!kickCandidate) return
|
||
const newActive = activePlayers.filter(p => p.steamId !== kickCandidate.steamId)
|
||
const newInactive = inactivePlayers.filter(p => p.steamId !== kickCandidate.steamId)
|
||
setActivePlayers(newActive)
|
||
setInactivePlayers(newInactive)
|
||
|
||
await fetch('/api/team/kick', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ steamId: kickCandidate.steamId, teamId: team.id }),
|
||
})
|
||
await updateTeamMembers(team.id, newActive, newInactive)
|
||
setKickCandidate(null)
|
||
}
|
||
|
||
const promoteToLeader = async (newLeaderId: string) => {
|
||
try {
|
||
const res = await fetch('/api/team/transfer-leader', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ teamId: team.id, newLeaderSteamId: newLeaderId }),
|
||
})
|
||
if (!res.ok) {
|
||
const data = await res.json()
|
||
console.error('Fehler bei Leader-Übertragung:', data.message)
|
||
return
|
||
}
|
||
await handleReload()
|
||
} catch (err) {
|
||
console.error('Fehler bei Leader-Übertragung:', err)
|
||
}
|
||
}
|
||
|
||
// Upload mit Progress via XHR – setzt filename/version direkt, kein Reload nötig
|
||
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')
|
||
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 = () => {
|
||
if (xhr.status >= 200 && xhr.status < 300) {
|
||
try {
|
||
const json = JSON.parse(xhr.responseText)
|
||
const current = useTeamStore.getState().team
|
||
if (json?.filename && current) setTeam({ ...current, logo: json.filename })
|
||
if (json?.version) setLogoVersion(json.version)
|
||
} catch {}
|
||
resolve()
|
||
} else {
|
||
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
|
||
|
||
const renderMemberList = (players: Player[]) => (
|
||
<AnimatePresence>
|
||
{players.map(player => (
|
||
<motion.div
|
||
key={player.steamId}
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -10 }}
|
||
transition={{ duration: 0.2 }}
|
||
className='max-w-[160px]'
|
||
>
|
||
<Link
|
||
href={`/profile/${player.steamId}`}
|
||
passHref
|
||
onClick={e => { if (isDragging) e.preventDefault() }}
|
||
>
|
||
<SortableMiniCard
|
||
player={player}
|
||
onKick={setKickCandidate}
|
||
onPromote={() => setPromoteCandidate(player)}
|
||
currentUserSteamId={manageSteam}
|
||
teamLeaderSteamId={team.leader}
|
||
isAdmin={adminMode}
|
||
isDraggingGlobal={isDragging}
|
||
hideOverlay={isDragging}
|
||
matchParentBg
|
||
/>
|
||
</Link>
|
||
</motion.div>
|
||
))}
|
||
</AnimatePresence>
|
||
)
|
||
|
||
return (
|
||
<div className={`p-4 mt-6 bg-white border border-gray-200 shadow-2xs rounded-xl dark:bg-neutral-800 dark:border-neutral-700 ${isDragging ? 'cursor-grabbing' : ''}`}>
|
||
<div className="flex justify-between items-center mb-6 flex-wrap gap-2">
|
||
<div className="flex items-center gap-4">
|
||
<div className="relative group">
|
||
<div
|
||
role="button"
|
||
aria-disabled={!isClickable}
|
||
aria-busy={isUploadingLogo}
|
||
tabIndex={isClickable ? 0 : -1}
|
||
className={[
|
||
"relative w-16 h-16 rounded-full overflow-hidden border border-gray-300 dark:border-neutral-600",
|
||
isClickable ? "cursor-pointer" : "cursor-not-allowed opacity-70"
|
||
].join(" ")}
|
||
onClick={() => { if (isClickable) fileInputRef.current?.click() }}
|
||
onKeyDown={(e) => {
|
||
if (!isClickable) return
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.preventDefault()
|
||
fileInputRef.current?.click()
|
||
}
|
||
}}
|
||
title={isUploadingLogo ? "Upload läuft…" : (canManage ? "Logo hochladen" : undefined)}
|
||
>
|
||
<Image
|
||
key={`${team.logo ?? 'fallback'}-${logoVersion ?? 0}`}
|
||
src={
|
||
team.logo
|
||
? `/assets/img/logos/${team.logo}${logoVersion ? `?v=${logoVersion}` : ''}`
|
||
: `/assets/img/logos/cs2.webp`
|
||
}
|
||
alt="Teamlogo"
|
||
fill
|
||
sizes="64px"
|
||
quality={75}
|
||
className={`object-cover ${isUploadingLogo ? 'opacity-70' : ''}`}
|
||
priority={false}
|
||
/>
|
||
|
||
{/* Hover-Overlay nur, wenn klickbar */}
|
||
{canManage && isClickable && (
|
||
<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>
|
||
)}
|
||
|
||
{/* Progress-Kreis (Start bei 12 Uhr via rotate(-90 …)) */}
|
||
{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">
|
||
<g transform={`rotate(-90 ${S/2} ${S/2})`}>
|
||
<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' }}
|
||
/>
|
||
</g>
|
||
</svg>
|
||
<span className="text-[11px] font-semibold text-white drop-shadow">{uploadPct}%</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{canManage && (
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/webp"
|
||
id="logoUpload"
|
||
className="hidden"
|
||
disabled={!isClickable}
|
||
onChange={async (e) => {
|
||
if (isUploadingLogo) return
|
||
const file = e.target.files?.[0]
|
||
if (!file) return
|
||
try {
|
||
await uploadTeamLogo(file)
|
||
} 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 = ''
|
||
}
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
{isEditingName ? (
|
||
<>
|
||
<input
|
||
type="text"
|
||
value={editedName}
|
||
onChange={(e) => setEditedName(e.target.value)}
|
||
className="py-1.5 px-3 border rounded-lg text-sm dark:bg-neutral-800 dark:border-neutral-700 dark:text-white"
|
||
/>
|
||
<Button
|
||
title="Übernehmen"
|
||
color="green"
|
||
size="sm"
|
||
variant="soft"
|
||
onClick={async () => {
|
||
await renameTeam(team.id, editedName)
|
||
setIsEditingName(false)
|
||
await handleReload()
|
||
}}
|
||
className="h-[34px] px-3 flex items-center justify-center"
|
||
>
|
||
✔
|
||
</Button>
|
||
<Button
|
||
title="Abbrechen"
|
||
color="red"
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => {
|
||
setIsEditingName(false)
|
||
setEditedName(team.name ?? '')
|
||
}}
|
||
className="h-[34px] px-3 flex items-center justify-center"
|
||
>
|
||
✖
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="flex items-center gap-3">
|
||
<h2 className="text-lg font-semibold text-gray-800 dark:text-white">
|
||
{team.name ?? 'Team'}
|
||
</h2>
|
||
<TeamPremierRankBadge players={activePlayers} />
|
||
</div>
|
||
{canManage && (
|
||
<Button
|
||
title="Bearbeiten"
|
||
color="blue"
|
||
size="sm"
|
||
variant="soft"
|
||
onClick={() => {
|
||
setIsEditingName(true)
|
||
setEditedName(team.name || '')
|
||
}}
|
||
className="h-[34px] px-3 flex items-center justify-center"
|
||
>
|
||
Bearbeiten
|
||
</Button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-2">
|
||
{canManage && (
|
||
<Button onClick={() => setShowDeleteModal(true)} color='red' size='sm'>
|
||
Team löschen
|
||
</Button>
|
||
)}
|
||
<Button
|
||
onClick={async () => {
|
||
if (isLeader) setShowLeaveModal(true)
|
||
else {
|
||
try { await leaveTeam(currentUserSteamId) }
|
||
catch (err) { console.error('Fehler beim Verlassen:', err) }
|
||
}
|
||
}}
|
||
color='blue'
|
||
size='sm'
|
||
>
|
||
Team verlassen
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<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 id="active" key={`sc-active-${remountKey}-${activePlayers.map(p=>p.steamId).join(',')}`} items={activePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
|
||
{renderMemberList(activePlayers)}
|
||
</SortableContext>
|
||
</DroppableZone>
|
||
|
||
<DroppableZone id="inactive" label="Inaktive Spieler" activeDragItem={activeDragItem} saveSuccess={saveSuccess}>
|
||
<SortableContext id="inactive" key={`sc-inactive-${remountKey}-${inactivePlayers.map(p=>p.steamId).join(',')}`} items={inactivePlayers.map(p => p.steamId)} strategy={verticalListSortingStrategy}>
|
||
{renderMemberList(inactivePlayers)}
|
||
{canManage && (
|
||
<motion.div key="mini-card-dummy" initial={{ opacity: 0 }} animate={{ opacity: 1}} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
|
||
<MiniCardDummy
|
||
zoneId="inactive"
|
||
title={canAddDirect ? 'Hinzufügen' : 'Einladen'}
|
||
onClick={() => {
|
||
setShowInviteModal(false)
|
||
setTimeout(() => setShowInviteModal(true), 0)
|
||
}}
|
||
>
|
||
<div className="flex items-center justify-center w-16 h-16 bg-white rounded-full text-black">
|
||
<svg xmlns="http://www.w3.org/2000/svg" className="w-8 h-8" viewBox="0 0 640 512" fill="currentColor">
|
||
<path d="M96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM0 482.3C0 383.8 79.8 304 178.3 304h91.4C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7H29.7C13.3 512 0 498.7 0 482.3zM504 312v-64h-64c-13.3 0-24-10.7-24-24s10.7-24 24-24h64v-64c0-13.3 10.7-24 24-24s24 10.7 24 24v64h64c13.3 0 24 10.7 24 24s-10.7 24-24 24h-64v64c0 13.3-10.7 24-24 24s-24-10.7-24-24z" />
|
||
</svg>
|
||
</div>
|
||
</MiniCardDummy>
|
||
</motion.div>
|
||
)}
|
||
</SortableContext>
|
||
</DroppableZone>
|
||
|
||
{invitedPlayers.length > 0 && (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-md font-semibold text-gray-700 dark:text-gray-300">Eingeladene Spieler</h3>
|
||
</div>
|
||
<div className="w-full rounded-lg p-4 transition-colors min-h-[200px] border border-gray-300 dark:border-neutral-700">
|
||
<div className="grid gap-4 justify-start grid-cols-[repeat(auto-fill,minmax(160px,1fr))]">
|
||
<AnimatePresence>
|
||
{invitedPlayers.map((player: InvitedPlayer) => (
|
||
<motion.div key={player.steamId} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.2 }}>
|
||
<MiniCard
|
||
steamId={player.steamId}
|
||
title={player.name}
|
||
avatar={player.avatar}
|
||
location={player.location}
|
||
selected={false}
|
||
onSelect={() => {}}
|
||
draggable={false}
|
||
currentUserSteamId={currentUserSteamId}
|
||
teamLeaderSteamId={team.leader}
|
||
isSelectable={false}
|
||
isInvite={true}
|
||
rank={player.premierRank}
|
||
invitationId={(player as any).invitationId}
|
||
onKick={async (sid) => {
|
||
setInvitedPlayers(list => list.filter(p => p.steamId !== sid))
|
||
try {
|
||
await fetch('/api/user/invitations/revoke', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
invitationId: (player as any).invitationId ?? undefined,
|
||
teamId: team.id,
|
||
steamId: sid,
|
||
}),
|
||
})
|
||
} catch (e) {
|
||
console.error('Revoke fehlgeschlagen:', e)
|
||
setInvitedPlayers(list => [...list, player].sort((a,b)=>a.name.localeCompare(b.name)))
|
||
} finally {
|
||
const updated = await reloadTeam(team.id)
|
||
if (updated) setTeam(updated)
|
||
}
|
||
}}
|
||
/>
|
||
</motion.div>
|
||
))}
|
||
</AnimatePresence>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<DragOverlay>
|
||
{activeDragItem && (
|
||
<SortableMiniCard
|
||
player={activeDragItem}
|
||
currentUserSteamId={currentUserSteamId}
|
||
teamLeaderSteamId={team.leader}
|
||
isAdmin={adminMode}
|
||
hideOverlay
|
||
matchParentBg
|
||
/>
|
||
)}
|
||
</DragOverlay>
|
||
</DndContext>
|
||
|
||
{canInvite && (
|
||
<InvitePlayersModal
|
||
show={showInviteModal}
|
||
onClose={() => setShowInviteModal(false)}
|
||
onSuccess={() => {}}
|
||
team={team}
|
||
/>
|
||
)}
|
||
{canAddDirect && (
|
||
<InvitePlayersModal
|
||
show={showInviteModal}
|
||
onClose={() => setShowInviteModal(false)}
|
||
onSuccess={() => {}}
|
||
team={team}
|
||
directAdd
|
||
/>
|
||
)}
|
||
|
||
{isLeader && (
|
||
<LeaveTeamModal
|
||
show={showLeaveModal}
|
||
onClose={() => setShowLeaveModal(false)}
|
||
onSuccess={() => setShowLeaveModal(false)}
|
||
team={team}
|
||
/>
|
||
)}
|
||
|
||
{canManage && promoteCandidate && (
|
||
<Modal
|
||
id={`modal-promote-player-${promoteCandidate.steamId}`}
|
||
title="Leader übertragen"
|
||
show={true}
|
||
onClose={() => setPromoteCandidate(null)}
|
||
onSave={async () => {
|
||
await promoteToLeader(promoteCandidate.steamId)
|
||
setPromoteCandidate(null)
|
||
}}
|
||
closeButtonTitle="Übertragen"
|
||
closeButtonColor="blue"
|
||
>
|
||
<div className="flex justify-center mb-4">
|
||
<MiniCard
|
||
steamId={promoteCandidate.steamId}
|
||
title={promoteCandidate.name}
|
||
avatar={promoteCandidate.avatar}
|
||
location={promoteCandidate.location}
|
||
selected={false}
|
||
onSelect={() => {}}
|
||
draggable={false}
|
||
currentUserSteamId={currentUserSteamId}
|
||
teamLeaderSteamId={team.leader}
|
||
hideActions
|
||
isSelectable={false}
|
||
/>
|
||
</div>
|
||
<p className="text-sm text-gray-700 dark:text-neutral-300">
|
||
Möchtest du <strong>{promoteCandidate.name}</strong> wirklich zum Team-Leader machen?
|
||
</p>
|
||
</Modal>
|
||
)}
|
||
|
||
{canManage && kickCandidate && (
|
||
<Modal
|
||
id={`modal-kick-player-${kickCandidate.steamId}`}
|
||
title="Mitglied entfernen"
|
||
show={true}
|
||
onClose={() => setKickCandidate(null)}
|
||
onSave={confirmKick}
|
||
closeButtonTitle="Entfernen"
|
||
closeButtonColor="red"
|
||
>
|
||
<div className="flex justify-center mb-4">
|
||
<MiniCard
|
||
steamId={kickCandidate.steamId}
|
||
title={kickCandidate.name}
|
||
avatar={kickCandidate.avatar}
|
||
location={kickCandidate.location}
|
||
selected={false}
|
||
onSelect={() => {}}
|
||
draggable={false}
|
||
currentUserSteamId={currentUserSteamId}
|
||
teamLeaderSteamId={team.leader}
|
||
hideActions
|
||
isSelectable={false}
|
||
/>
|
||
</div>
|
||
<p className="text-sm text-gray-700 dark:text-neutral-300">
|
||
Möchtest du <strong>{kickCandidate.name}</strong> wirklich aus dem Team entfernen?
|
||
</p>
|
||
</Modal>
|
||
)}
|
||
|
||
{canManage && (
|
||
<Modal
|
||
id="modal-delete-team"
|
||
title="Team löschen"
|
||
show={showDeleteModal}
|
||
onClose={() => setShowDeleteModal(false)}
|
||
onSave={async () => {
|
||
await fetch('/api/team/delete', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ teamId: team.id }),
|
||
})
|
||
setShowDeleteModal(false)
|
||
window.location.href = '/team'
|
||
}}
|
||
closeButtonTitle="Löschen"
|
||
closeButtonColor="red"
|
||
>
|
||
<p className="text-sm text-gray-700 dark:text-neutral-300">
|
||
Bist du sicher, dass du dieses Team löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.
|
||
</p>
|
||
</Modal>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|