ironie-nextjs/src/app/[locale]/components/TeamMemberView.tsx
2025-10-14 15:30:11 +02:00

1192 lines
45 KiB
TypeScript

// /src/app/[locale]/components/TeamMemberView.tsx
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
DndContext,
closestCenter,
DragOverlay,
type DragStartEvent,
type DragEndEvent,
} 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 type { Player, InvitedPlayer, Team, TeamJoinPolicy } from '@/types/team'
import { AnimatePresence, motion } from 'framer-motion'
import { leaveTeam, reloadTeam, renameTeam } from '@/lib/sse-actions'
import Button from './Button'
import NextImage from 'next/image'
import TeamPremierRankBadge from './TeamPremierRankBadge'
import Link from 'next/link'
import { useTeamStore } from '@/lib/stores'
import { useSSEStore } from '@/lib/useSSEStore'
import {
TEAM_EVENTS,
SELF_EVENTS,
isSseEventType,
type SSEEventType,
} from '@/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
}
export default function TeamMemberView(props: Props) {
const { team: storeTeam, setTeam } = useTeamStore()
// Prop -> Store spiegeln
useEffect(() => {
if (!props.team) return
const curr = useTeamStore.getState().team
if (!curr || curr.id !== props.team.id) {
setTeam(props.team as Team)
return
}
// gleiche ID → selektiv patchen
const next = props.team as Team
const diff: Partial<Team> = {}
if (curr.name !== next.name) diff.name = next.name
if (curr.logo !== next.logo) diff.logo = next.logo
if ((curr.leader?.steamId ?? null) !== (next.leader?.steamId ?? null)) diff.leader = next.leader
if (typeof next.joinPolicy === 'string' && curr.joinPolicy !== next.joinPolicy) {
diff.joinPolicy = next.joinPolicy as TeamJoinPolicy
}
if (Object.keys(diff).length) setTeam({ ...curr, ...diff } as Team)
}, [props.team, setTeam])
if (!props.adminMode && !props.currentUserSteamId) return null
const team = props.team ?? storeTeam ?? null
if (!team) return null
return <TeamMemberViewBody {...props} team={team} />
}
function TeamMemberViewBody({
team,
activeDragItem,
isDragging,
showLeaveModal,
showInviteModal,
currentUserSteamId,
setShowLeaveModal,
setShowInviteModal,
setActiveDragItem,
setIsDragging,
adminMode = false,
}: Props & { team: Team }) {
const { setTeam } = useTeamStore()
const teamId = team.id
const teamLeaderSteamId = team.leader?.steamId ?? ''
// stabile Menge für useEffect-Deps
const RELEVANT = useMemo<ReadonlySet<SSEEventType>>(
() => new Set([...TEAM_EVENTS, ...SELF_EVENTS]),
[]
)
const isLeader = currentUserSteamId === team.leader?.steamId
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)
const [joinPolicy, setJoinPolicy] = useState<TeamJoinPolicy>(
(team.joinPolicy as TeamJoinPolicy) ?? 'REQUEST'
)
const policyChangedAtRef = useRef<number | null>(null)
const [savingPolicy, setSavingPolicy] = useState(false)
const [policySaved, setPolicySaved] = useState(false)
const [inviteKey, setInviteKey] = useState(0)
const openInvite = () => {
setInviteKey(k => k + 1)
setShowInviteModal(true)
}
// Cache-Busting fürs Logo (ohne any)
type TeamWithStamp = Team & { logoUpdatedAt?: string | Date; updatedAt?: string | Date }
const tStamped = team as TeamWithStamp
const initialLogoVersion =
tStamped.logoUpdatedAt
? new Date(tStamped.logoUpdatedAt).getTime()
: tStamped.updatedAt
? new Date(tStamped.updatedAt).getTime()
: 0
const [logoVersion, setLogoVersion] = useState<number | null>(initialLogoVersion)
// 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
}
const eqSetByIds = (a: {steamId:string}[], b: {steamId:string}[]) => {
if (a.length !== b.length) return false
const sa = [...a.map(p => p.steamId)].sort()
const sb = [...b.map(p => p.steamId)].sort()
for (let i = 0; i < sa.length; i++) {
if (sa[i] !== sb[i]) return false
}
return true
}
useEffect(() => {
if (typeof team.joinPolicy === 'string') {
setJoinPolicy(team.joinPolicy as TeamJoinPolicy)
}
}, [team.id, team.joinPolicy])
// Team-Listen lokal synchronisieren
useEffect(() => {
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 ?? {}) as Record<string, unknown>
const now = Date.now()
// Nach lokalem Speichern: kurzes Ignore-Fenster
if (lastEvent.type === 'team-updated' && payload.teamId === team.id) {
if (policyChangedAtRef.current && (now - policyChangedAtRef.current) < 2000) {
policyChangedAtRef.current = null
return
}
}
// nur Logo geändert → minimal patchen
if (lastEvent.type === 'team-logo-updated') {
const curr = useTeamStore.getState().team
const filename = (payload as { filename?: string }).filename
const version = (payload as { version?: number }).version
const evTeamId = (payload as { teamId?: string }).teamId
if (evTeamId && evTeamId !== team.id) return
if (filename && curr) setTeam({ ...curr, logo: filename })
if (typeof version === 'number') setLogoVersion(version)
return
}
// Rest: reload wenn relevant
if (!RELEVANT.has(lastEvent.type)) return
const evTeamId = (payload as { teamId?: string }).teamId
if (evTeamId && evTeamId !== team.id) return
;(async () => {
const updated = await reloadTeam(team.id)
if (!updated) return
setTeam(updated)
setEditedName(updated.name || '')
if (typeof updated.joinPolicy === 'string') {
setJoinPolicy(updated.joinPolicy as TeamJoinPolicy)
}
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
}
const contentChanged =
!eqSetByIds(activePlayers, nextActive) ||
!eqSetByIds(inactivePlayers, nextInactive) ||
!eqSetByIds(invitedPlayers, nextInvited)
const orderChanged =
!eqByIds(activePlayers, nextActive) ||
!eqByIds(inactivePlayers, nextInactive) ||
!eqByIds(invitedPlayers, nextInvited)
if (contentChanged) {
setActivePlayers(nextActive)
setInactivePlayers(nextInactive)
setInvitedPlayers(nextInvited)
setRemountKey(k => k + 1)
} else if (orderChanged) {
setActivePlayers(nextActive)
setInactivePlayers(nextInactive)
setInvitedPlayers(nextInvited)
}
})()
}, [RELEVANT, lastEvent, team.id, setTeam, activePlayers, inactivePlayers, invitedPlayers])
const handleDragStart = (event: DragStartEvent) => {
const id = String(event.active.id)
const item =
activePlayers.find(p => p.steamId === id) ||
inactivePlayers.find(p => p.steamId === id)
if (item) {
setActiveDragItem(item)
setIsDragging(true)
isDraggingRef.current = true
}
}
const [showPolicyMenu, setShowPolicyMenu] = useState(false)
const policyMenuRef = useRef<HTMLDivElement>(null)
const applyPolicy = async (p: TeamJoinPolicy) => {
if (p === joinPolicy) { setShowPolicyMenu(false); return }
setJoinPolicy(p)
await saveJoinPolicy(p)
setShowPolicyMenu(false)
}
useEffect(() => {
if (!showPolicyMenu) return
const onOutside = (e: PointerEvent) => {
if (!policyMenuRef.current) return
if (!policyMenuRef.current.contains(e.target as Node)) {
e.preventDefault()
e.stopPropagation()
setShowPolicyMenu(false)
}
}
const onEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') setShowPolicyMenu(false)
}
document.addEventListener('pointerdown', onOutside, { capture: true })
document.addEventListener('keydown', onEsc)
return () => {
document.removeEventListener('pointerdown', onOutside, { capture: true })
document.removeEventListener('keydown', onEsc)
}
}, [showPolicyMenu])
const updateTeamMembers = async (tId: 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: tId,
activePlayers: active.map(p => p.steamId),
inactivePlayers: inactive.map(p => p.steamId),
}),
})
if (!res.ok) throw new Error('Update fehlgeschlagen')
const updated = await reloadTeam(tId)
if (updated) setTeam(updated)
} catch (err) {
console.error('Fehler beim Aktualisieren:', err)
}
}
const handleDragEnd = async (event: DragEndEvent) => {
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(teamId, 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 }),
})
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, newLeaderSteamId: newLeaderId }),
})
if (!res.ok) {
const data: unknown = await res.json().catch(() => ({}))
console.error('Fehler bei Leader-Übertragung:', (data as { message?: string }).message)
return
}
await handleReload()
} catch (err) {
console.error('Fehler bei Leader-Übertragung:', err)
}
}
type DownscaleOpts = {
size?: number
quality?: number
mime?: string
square?: boolean
}
async function saveJoinPolicy(next: TeamJoinPolicy = joinPolicy) {
const prev = joinPolicy
try {
setSavingPolicy(true)
const res = await fetch('/api/team/update-join-policy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
cache: 'no-store',
body: JSON.stringify({ teamId, joinPolicy: next }),
})
if (!res.ok) {
const data: unknown = await res.json().catch(() => ({}))
const msg = (data as { message?: string }).message
throw new Error(msg ?? `Speichern fehlgeschlagen (${res.status})`)
}
const parsed: unknown = await res.json().catch(() => ({}))
const serverPolicy = (parsed as { joinPolicy?: TeamJoinPolicy }).joinPolicy
const patched = (serverPolicy ?? next) as TeamJoinPolicy
setJoinPolicy(patched)
const curr = useTeamStore.getState().team
if (curr && curr.id === teamId && curr.joinPolicy !== patched) {
setTeam({ ...curr, joinPolicy: patched })
}
policyChangedAtRef.current = Date.now()
setPolicySaved(true)
setTimeout(() => setPolicySaved(false), 2000)
} catch (e) {
setJoinPolicy(prev)
console.error(e)
alert((e as Error).message || 'Speichern fehlgeschlagen')
} finally {
setSavingPolicy(false)
}
}
async function canEncode(mime: string): Promise<boolean> {
try {
if ('OffscreenCanvas' in window) {
const c = new OffscreenCanvas(2, 2)
const b = await c.convertToBlob({ type: mime, quality: 0.8 })
return !!b
}
const c = document.createElement('canvas')
c.width = 2; c.height = 2
const url = c.toDataURL(mime)
return typeof url === 'string' && url.startsWith(`data:${mime}`)
} catch {
return false
}
}
async function downscaleImage(file: File, opts: DownscaleOpts = {}): Promise<Blob> {
const {
size = 512,
quality = 0.85,
mime: wantedMime = 'image/webp',
square = true,
} = opts
// 1) Bild laden (ImageBitmap bevorzugt, ohne any)
let url: string | null = null
let img: ImageBitmap | HTMLImageElement
try {
img = await createImageBitmap(file)
} catch {
url = URL.createObjectURL(file)
img = await new Promise<HTMLImageElement>((res, rej) => {
const im = new window.Image()
im.onload = () => res(im)
im.onerror = rej
im.src = url!
})
}
const dims = img as unknown as { width: number; height: number }
const srcW = dims.width
const srcH = dims.height
if (!srcW || !srcH) {
if (url) URL.revokeObjectURL(url)
if ('close' in (img as ImageBitmap)) try { (img as ImageBitmap).close() } catch {}
throw new Error('Invalid image dimensions')
}
// 2) Zielgröße + optionaler Center-Crop
let sx = 0, sy = 0, sw = srcW, sh = srcH
if (square) {
const side = Math.min(srcW, srcH)
sx = Math.max(0, Math.floor((srcW - side) / 2))
sy = Math.max(0, Math.floor((srcH - side) / 2))
sw = side; sh = side
}
const scale = Math.min(size / sw, size / sh, 1)
const dw = Math.max(1, Math.round(sw * scale))
const dh = Math.max(1, Math.round(sh * scale))
// 3) Canvas (Offscreen bevorzugt)
const source = img as unknown as CanvasImageSource
const offscreen = 'OffscreenCanvas' in window
let blob: Blob | null = null
if (offscreen) {
const c = new OffscreenCanvas(dw, dh)
const ctx = c.getContext('2d', { alpha: true })!
ctx.imageSmoothingQuality = 'high'
ctx.drawImage(source, sx, sy, sw, sh, 0, 0, dw, dh)
// 4) Format mit Fallbacks
const canWebp = await canEncode('image/webp')
const canJpeg = await canEncode('image/jpeg')
const targetMime =
(wantedMime === 'image/webp' && canWebp) ? 'image/webp' :
(wantedMime === 'image/jpeg' && canJpeg) ? 'image/jpeg' :
canWebp ? 'image/webp' :
canJpeg ? 'image/jpeg' : 'image/png'
blob = await c.convertToBlob({ type: targetMime, quality: targetMime === 'image/png' ? undefined : quality })
} else {
const c = document.createElement('canvas')
c.width = dw; c.height = dh
const ctx = c.getContext('2d')!
ctx.imageSmoothingQuality = 'high'
ctx.drawImage(source, sx, sy, sw, sh, 0, 0, dw, dh)
const canWebp = await canEncode('image/webp')
const canJpeg = await canEncode('image/jpeg')
const targetMime =
(wantedMime === 'image/webp' && canWebp) ? 'image/webp' :
(wantedMime === 'image/jpeg' && canJpeg) ? 'image/jpeg' :
canWebp ? 'image/webp' :
canJpeg ? 'image/jpeg' : 'image/png'
blob = await new Promise<Blob | null>((res) =>
c.toBlob(b => res(b), targetMime, targetMime === 'image/png' ? undefined : quality)
)
}
if (url) URL.revokeObjectURL(url)
if ('close' in (img as ImageBitmap)) { try { (img as ImageBitmap).close() } catch {} }
if (!blob) throw new Error('Canvas encoding failed (toBlob returned null)')
return blob
}
async function uploadTeamLogo(file: File) {
return new Promise<void>((resolve, reject) => {
const formData = new FormData()
formData.append('logo', file)
formData.append('teamId', teamId)
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: unknown = JSON.parse(xhr.responseText)
const current = useTeamStore.getState().team
const filename = (json as { filename?: string }).filename
const version = (json as { version?: number }).version
if (filename && current) setTeam({ ...current, logo: filename })
if (typeof version === 'number') setLogoVersion(version)
} catch {}
resolve()
} else {
reject(new Error('Upload fehlgeschlagen'))
}
}
xhr.onerror = () => reject(new Error('Netzwerkfehler beim Upload'))
setIsUploadingLogo(true)
setUploadPct(0)
xhr.send(formData)
})
}
const manageSteam: string = adminMode ? teamLeaderSteamId : 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}`}
onClick={e => { if (isDragging) e.preventDefault() }}
>
<SortableMiniCard
player={player}
onKick={setKickCandidate}
onPromote={() => setPromoteCandidate(player)}
currentUserSteamId={manageSteam}
teamLeaderSteamId={teamLeaderSteamId}
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)}
>
<NextImage
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}
unoptimized
/>
{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>
)}
{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 {
const blob = await downscaleImage(file, { size: 512, quality: 0.85, mime: 'image/webp', square: true })
const mime = blob.type || 'image/webp'
const ext = mime === 'image/jpeg' ? 'jpg' : mime === 'image/png' ? 'png' : 'webp'
const processed = new File([blob], `${team!.id}.${ext}`, { type: mime })
await uploadTeamLogo(processed)
} 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"
>
<span className="text-green-600"></span>
</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"
>
<span className="text-red-600"></span>
</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>
{/* Policy-Pill */}
<div className="relative" ref={policyMenuRef}>
<button
type="button"
onPointerDownCapture={(e) => { e.stopPropagation() }}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); setShowPolicyMenu(v => !v) }}
className="h-[32px] px-2.5 rounded-xl text-xs border border-gray-300 dark:border-neutral-600
bg-white dark:bg-neutral-800 text-gray-700 dark:text-neutral-200
hover:bg-gray-100 hover:dark:bg-neutral-700 inline-flex items-center gap-1"
title="Beitrittsmodus ändern"
>
{joinPolicy === 'INVITE_ONLY' ? (
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor">
<path d="M17 8V7a5 5 0 1 0-10 0v1H5v12h14V8h-2Zm-8 0V7a3 3 0 1 1 6 0v1H9Zm-2 2h10v8H7v-8Z"/>
</svg>
) : (
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2a5 5 0 0 0-5 5v1H5v12h14V8h-2V7a5 5 0 0 0-5-5Zm-3 6V7a3 3 0 1 1 6 0v1H9Z"/>
</svg>
)}
<span>{joinPolicy === 'INVITE_ONLY' ? 'Nur Einladung' : 'Mit Genehmigung'}</span>
{savingPolicy && (
<span className="ml-1 inline-block size-3 border-2 border-current border-t-transparent rounded-xl animate-spin" />
)}
{policySaved && !savingPolicy && <span className="ml-1 text-green-600"></span>}
</button>
{showPolicyMenu && (
<div
className="absolute right-0 z-[60] mt-1 w-56 rounded-md border border-gray-200
dark:border-neutral-700 bg-white dark:bg-neutral-800 shadow-lg p-1"
onPointerDownCapture={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onPointerDownCapture={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); applyPolicy('REQUEST') }}
className={`w-full text-left px-2.5 py-2 rounded-md text-sm
${joinPolicy === 'REQUEST'
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-200'
: 'hover:bg-gray-100 dark:hover:bg-neutral-700 text-gray-800 dark:text-neutral-200'}`}
>
<div className="font-medium">Mit Genehmigung</div>
<div className="text-xs text-gray-500 dark:text-neutral-400">
Spieler stellen eine Anfrage; Leader entscheidet.
</div>
</button>
<button
type="button"
onPointerDownCapture={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); applyPolicy('INVITE_ONLY') }}
className={`w-full text-left px-2.5 py-2 rounded-md text-sm
${joinPolicy === 'INVITE_ONLY'
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-200'
: 'hover:bg-gray-100 dark:hover:bg-neutral-700 text-gray-800 dark:text-neutral-200'}`}
>
<div className="font-medium">Nur Einladung</div>
<div className="text-xs text-gray-500 dark:text-neutral-400">
Beitritt nur per Einladung.
</div>
</button>
</div>
)}
</div>
</>
)}
</>
)}
</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-${teamId}-${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={openInvite}
>
<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) => (
<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?.steamId}
isSelectable={false}
isInvite={true}
rank={player.premierRank}
// optional lokales Extra-Feld sicher lesen
invitationId={(player as InvitedPlayer & { invitationId?: string }).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 InvitedPlayer & { invitationId?: string }).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?.steamId}
isAdmin={adminMode}
hideOverlay
matchParentBg
/>
)}
</DragOverlay>
</DndContext>
{canInvite && (
<InvitePlayersModal
key={inviteKey}
show={showInviteModal}
onClose={() => setShowInviteModal(false)}
onSuccess={() => {}}
team={team}
/>
)}
{canAddDirect && (
<InvitePlayersModal
key={inviteKey}
show={showInviteModal}
onClose={() => setShowInviteModal(false)}
onSuccess={() => setShowInviteModal(false)}
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}
rank={promoteCandidate.premierRank}
currentUserSteamId={currentUserSteamId}
teamLeaderSteamId={team.leader?.steamId}
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}
rank={kickCandidate.premierRank}
currentUserSteamId={currentUserSteamId}
teamLeaderSteamId={team.leader?.steamId}
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="Team 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>
)
}